Files
consensus/engine/chain/bootstrap_accept.go
T
zeekayandHanzo Dev 8793eb2d71 fix(engine/chain): QCv2 canonical-commitment finality + seed-hint semantics (incident 1082814)
Durable fix for the finality-equivocation bug class. Root cause: finality was
defined over the OUTER proposervm envelope id, and the in-memory finality ledger
was SEEDED from vm.LastAccepted on boot. Two outer envelopes wrapping the SAME
inner execution block looked like a fork -> fatal EQUIVOCATION (reportCertEquivocation
-> Logger.Crit -> Fatal -> os.Exit), halting mainnet 43h. They were duplicates.

Two layers, shipped together (atomic roll, all validators upgrade at once):

PART A - seed semantics. ChainConsensus.SyncState (the vm.LastAccepted reconcile)
now writes a NON-AUTHORITATIVE recovery HINT, never certified finality.
FinalityLedger separates a certified frontier (advanced ONLY by a verified QC or
bootstrap frontier-trust) from a hint anchor. GetFinalizedHeight is certified-only;
GetFinalizedTip/BuildAnchor return the higher of {certified tip, hint} for the VM
to build on. A locally-seeded sibling can never manufacture a finalized entry, so
it can never equivocate. The equal-height "different block => equivocation" check
in SyncState was the bug and is deleted.

PART B - QCv2 canonical cert. Finality is keyed on the inner EXECUTION commitment,
not the envelope. VotePosition carries both outer transport ids {BlockID, ParentID}
and canonical {CanonicalID, ParentCanonicalID, ExecutionStateRoot, PayloadRoot}.
byHeight indexes the CANONICAL id. Two certs at one height conflict IFF canonical
differs; same canonical / different envelope = duplicate alias (idempotent no-op).
CanonicalVoteMessage (tag LUX/chain/vote/v2, QuorumCertVersion=3) binds the
canonical identity and EXCLUDES the envelope, so validators that executed the same
inner block sign byte-identical messages even under different envelopes -> their
votes interoperate. The non-wrapped degrade (Empty canonical => bind BlockID) is
resolved in ONE place (canonicalVoteMessageFor). Canonical id is plumbed from
proposervm via the optional canonicalCommitter block iface + setCanonicalFromVM /
canonicalIDOf (falls back to b.ID for a bare block).

HARD GATE: the only writers of certified finalizedTip/byHeight are the cert fold
(Finalize, via ApplyCert/FinalizeBranch) and bootstrap frontier-trust
(AcceptBootstrapBlock). SyncState writes only the hint; preferredTip/build anchor
are build-only, never finality.

Proof suite (all green): 6-row regression matrix + invariant (incident_1082814_test.go),
canonical inversions (cert_canonical_inversion_test.go), two fuzz targets
(cert_fuzz_test.go: codec round-trip/malformed + finalize-decision invariant),
-race clean, go vet clean, staticcheck adds zero findings. 335 engine/chain tests
pass. Pre-fix repro captured (seed A + cert B at one height returned
ErrHeightAlreadyFinalized; now finalizes the canonical block, no equivocation).

No deploy/push/merge/tag — review-pending.

Co-authored-by: Hanzo Dev <[email protected]>
2026-06-30 13:45:42 -07:00

214 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// bootstrap_accept.go — the BOOTSTRAP accept path: how an EMPTY or BEHIND node
// performs INITIAL SYNC by fetching the chain from a peer's accepted frontier and
// re-executing it to the tip, WITHOUT a vote and WITHOUT a stored α-of-K cert.
//
// WHY A SEPARATE PATH. There are now three roads a block can take to finality and
// they are deliberately decomplected:
//
// - LIVE (cert) — followVerifiedBlock → vote → α-of-K QuorumCert → AcceptWithCert.
// The block is being decided NOW; we participate in the quorum. This is the only
// road once the chain is live.
// - CATCH-UP (cert-carry) — AcceptCatchupBlock: a behind node is handed each gap
// block TOGETHER WITH the finality cert the network already assembled, and
// finalizes through the SAME verified-cert predicate. This recovers a node lagging
// by at most the served-cert window (maxServedCerts).
// - BOOTSTRAP (frontier-trust) — THIS FILE: an EMPTY node (genesis → tip) or a node
// lagging by MORE than the cert window cannot use either road above. The producers
// do not re-gossip already-finalized blocks and the network will NOT re-vote a
// decided height (so the live vote road is dead for it), and a peer does not retain
// certs for ancient heights (so the cert-carry road cannot serve genesis → tip).
// The only way in is the standard avalanche weak-subjectivity-on-the-beacon-set
// model: FETCH each block from the network's accepted frontier and RE-EXECUTE it.
//
// THE TRUST MODEL — why accepting a fetched block without a vote/cert is safe HERE,
// and ONLY here. During bootstrap the node trusts the BEACON/VALIDATOR SET it samples
// for the accepted frontier (the same weak-subjectivity anchor avalanche bootstraps
// against). It does NOT trust the bytes: every fetched block is RE-EXECUTED
// (block.Verify) against the already-accepted parent state, so a malicious peer cannot
// advance the sync with an invalid block — Verify fails and the block is REJECTED. And
// it does NOT trust ORDER: the single finalize (consensus.FinalizeBranch) finalizes
// only the contiguous ancestry from the finalized tip, so a bootstrap fed oldest-first
// single-steps (height == finalizedHeight+1, parent == finalizedTip) and a gapped or
// forked block is refused. The result is exactly avalanche's: a bootstrapping node
// converges to the beacon set's frontier by re-execution, with no quorum to join.
//
// WHERE BOOTSTRAP ENDS AND LIVE CONSENSUS BEGINS. This path is permitted ONLY while
// Transitive.InBootstrapPhase() is true. The node ends the phase (FinishBootstrap)
// exactly when it has executed up to the discovered frontier and signals the chain
// bootstrapped — and from that instant AcceptBootstrapBlock is fail-closed: a fetched
// block can no longer finalize without an α-of-K cert. So the frontier-trust authority
// can never be used to bypass the live cert-gate. The live path (vote/cert) is
// UNCHANGED by this file.
package chain
import (
"context"
"errors"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
// ErrBootstrapBlockRejected is returned by AcceptBootstrapBlock when the fetched
// block did NOT finalize: the bootstrap phase had already ended (the node is live —
// fail-closed), the bytes did not parse or did not locally Verify (we never finalize
// contents we have not validated), or the block was out of parent order / gapped (the
// per-height guard refused it). It is a CLEAN rejection — nothing was finalized,
// nothing was VM-accepted. The caller (the fetch loop) tries the parent / another
// peer; it must NEVER treat this as a finalize.
var ErrBootstrapBlockRejected = errors.New("chain: bootstrap block rejected — phase ended, unverifiable block, or out-of-order/gapped (not finalized)")
// AcceptBootstrapBlock finalizes ONE block during INITIAL SYNC from a peer's
// accepted frontier: parse → contiguity → local Verify → ledger commit → VM.Accept →
// SetPreference. It accepts on FRONTIER-TRUST + RE-EXECUTION — no vote, no cert — and
// is the missing primitive that lets an empty/behind node sync genesis → tip.
//
// It NEVER finalizes outside the bootstrap phase (InBootstrapPhase), and NEVER
// finalizes a block it has not locally Verified or that violates contiguity:
// - phase ended (node is live) ⇒ ErrBootstrapBlockRejected (fail-closed: only the
// α-of-K cert path finalizes once live);
// - bytes do not parse / do not Verify ⇒ ErrBootstrapBlockRejected (a malicious
// peer cannot advance the sync with an invalid block);
// - height ≤ finalized ⇒ no-op nil (the frontier responder always serves some
// blocks we already hold; not new work, not an error);
// - height > finalized+1, or parent != finalized tip ⇒ ErrBootstrapBlockRejected
// (gapped / out of order — the loop must fetch and accept the parent FIRST).
//
// ORDERING INVARIANT (caller's responsibility, engine-ENFORCED). Blocks MUST be fed
// oldest-first — ascending height, each block's parent already finalized — so the EVM
// Verify runs against the already-accepted parent state and the per-height guard is
// satisfied. The guard ENFORCES it (a gapped/out-of-order block is refused, never
// force-accepted), so the invariant is not merely assumed.
func (rt *Runtime) AcceptBootstrapBlock(ctx context.Context, blockBytes []byte) error {
if rt.config.VM == nil {
return ErrBootstrapBlockRejected
}
// SAFETY GATE — fail-closed once live. The frontier-trust authority exists ONLY
// for initial sync; the instant the node reaches the frontier (FinishBootstrap)
// this path is refused and finality flows only through the α-of-K cert-gate.
if !rt.Transitive.InBootstrapPhase() {
return ErrBootstrapBlockRejected
}
// Parse through the SAME builder the engine frames/parses through, so the block
// ID and (height, parent) match what the per-height guard records.
blk, err := rt.config.VM.ParseBlock(ctx, blockBytes)
if err != nil {
return errors.Join(ErrBootstrapBlockRejected, err)
}
// CONTIGUITY pre-check (cheap, oldest-first). The frontier responder serves an
// oldest-first window that overlaps blocks we already hold, and on a node lagging
// by more than one window starts ABOVE our tip:
// - height ≤ finalized: already synced past — skip cleanly (responder overlap).
// - height > finalized+1: NOT our contiguous next block (out of order, or the
// fetch delivered a higher segment first) — reject WITHOUT verifying/accepting;
// the loop fetches the parent and comes back. The per-height guard would refuse
// it regardless; this just avoids the wasted Verify.
// Within an ordered (oldest-first) feed this never wrongly rejects: by the time
// N+1 is processed, N has finalized, so N+1.height == finalized+1.
if fh, set := rt.Transitive.consensus.GetFinalizedHeight(); set {
if blk.Height() <= fh {
return nil
}
if blk.Height() > fh+1 {
return ErrBootstrapBlockRejected
}
// height == fh+1: parent == finalizedTip is enforced by FinalizeBranch's walk.
} else {
// M2 — FIRST-BLOCK ANCHOR. The finalized-height tracker is UNSET (the un-seeded
// / empty-genesis path: SyncState only sets it when the VM has a non-empty last
// accepted). In that state the first FinalizeBranch would SEED the ledger with
// WHATEVER (height,
// parent) the peer's first block claims — so a peer could seed finality at an
// arbitrary height/parent. Bind the first bootstrap block to the VM's ACTUAL
// last-accepted instead of trusting Verify alone.
lastID, lastH, lerr := rt.localLastAccepted(ctx)
if lerr != nil {
return errors.Join(ErrBootstrapBlockRejected, lerr)
}
if lastID == ids.Empty {
// Truly empty (no accepted block — not even genesis). The only valid first
// block is genesis itself: height 0 with no parent. Anything else is a peer
// trying to seed finality mid-chain.
if blk.Height() != 0 || blk.ParentID() != ids.Empty {
return ErrBootstrapBlockRejected
}
} else {
if blk.Height() <= lastH {
return nil // already hold it (responder overlap)
}
if blk.Height() != lastH+1 || blk.ParentID() != lastID {
return ErrBootstrapBlockRejected // not our contiguous next block
}
}
}
// RE-EXECUTE. Local Verify proves the block is VALID against our (already-accepted)
// parent state — this is the integrity check that makes frontier-trust safe: a
// peer cannot advance our sync with an invalid block. Verified BEFORE any ledger
// mutation, so a bad block never advances finalized height.
if err := blk.Verify(ctx); err != nil {
return errors.Join(ErrBootstrapBlockRejected, err)
}
// LEDGER COMMIT through the single finalize (frontier-trust authority). Bootstrap
// feeds blocks oldest-first and contiguously (the checks above), so this is always
// a SINGLE-STEP FinalizeBranch: parent == finalized tip, height == finalized+1, no
// siblings to prune. FinalizeBranch advances finalized history and (defensively)
// returns a prune plan; bootstrap never produces one (single-branch). On any
// violation NOTHING advances and we reject — a bootstrapping node cannot be fed a
// forked/gapped history any more than a live one.
// The canonical execution commitment is what byHeight records (the authoritative
// finality id); for a non-wrapped block canonicalIDOf falls back to the outer id.
// Bootstrap uses the canonical-aware ApplyCert so a proposervm-wrapped bootstrap
// block records its INNER id, not the envelope.
if _, err := rt.Transitive.consensus.ApplyCert(Cert{
Block: blk.ID(),
Parent: blk.ParentID(),
Height: blk.Height(),
Canonical: canonicalIDOf(blk),
}); err != nil {
return errors.Join(ErrBootstrapBlockRejected, err)
}
// STATE TRANSITION — Accept then SetPreference, the SAME order as the cert
// finalizer (acceptWithCertCore), so the next block builds/verifies on this
// accepted parent. The block was just Verified; an Accept failure here is a local
// VM fault (the network finalized this block — the ledger correctly reflects it),
// and the NEXT block's Verify against the missing state will halt forward progress,
// surfacing it. SetPreference keeps the VM's preferred head on the synced tip.
if err := blk.Accept(ctx); err != nil {
if rt.config.Logger != nil && !rt.config.Logger.IsZero() {
rt.config.Logger.Error("bootstrap: VM.Accept failed after Verify (sync will halt at next block)",
log.Stringer("blockID", blk.ID()), log.Err(err))
}
}
_ = rt.config.VM.SetPreference(ctx, blk.ID())
return nil
}
// localLastAccepted reads the VM's last-accepted block id and height — the anchor the
// FIRST bootstrap block must extend when the consensus finalized-height tracker is not
// yet seeded (M2). Returns (ids.Empty, 0, nil) for a VM with no accepted block.
func (rt *Runtime) localLastAccepted(ctx context.Context) (ids.ID, uint64, error) {
id, err := rt.config.VM.LastAccepted(ctx)
if err != nil {
return ids.Empty, 0, err
}
if id == ids.Empty {
return ids.Empty, 0, nil
}
blk, err := rt.config.VM.GetBlock(ctx, id)
if err != nil {
// Id known, height unknown — treat height as 0 (genesis-ish). The parent check
// in the caller still binds the block to this id.
return id, 0, nil
}
return id, blk.Height(), nil
}