mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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]>
214 lines
11 KiB
Go
214 lines
11 KiB
Go
// 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
|
||
}
|