Files
zeekayandHanzo Dev 0c8e4f1163 consensus: comments state the contract, not the history behind it
The vote topology, the cert path and the bootstrap accept rule each explained
themselves by describing a failure they had already been changed to prevent,
including one written out in enough detail to be a recipe. What a maintainer
needs is the property being held.

Finality does not depend on any one node's inbound Chits, because a vote that
reaches only the proposer leaves the round resting on what that node hears.
Arrival is not agreement, so a follower Accepts on an α-of-K witness and not
before. A position is recorded before the path that reads it opens, or there is
a window where the path is open and the position absent. The ledger height alone
cannot decide what is already synced.

Behaviour is unchanged; every line here is a comment.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-26 13:18:52 -07:00

275 lines
15 KiB
Go
Raw Permalink 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"
"fmt"
"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 ≤ the SETTLED floor (decided AND executed): already synced past —
// skip cleanly (responder overlap).
// - settled < height ≤ finalized: decided but never executed — the ledger folded
// across blocks this VM did not run, so what these heights need is execution,
// not a decision. Verify against our own applied parent state and Accept. The
// authority is the same one this whole path rests on — the ⅔-stake-named
// frontier plus local Verify — and the ledger serves as the NEGATIVE check: a
// block contradicting what it finalized at a height is refused outright.
// - 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.
// The LEDGER height alone cannot decide this: every block in (applied, finalized]
// reads as already-synced against it and would be dropped with a nil error, so the
// loop would count a full batch accepted, advance nothing, and re-ask forever.
// Within an ordered (oldest-first) feed this never wrongly rejects: by the time
// N+1 is processed, N has been executed, so N+1.height == floor+1.
if fh, set := rt.Transitive.consensus.GetFinalizedHeight(); set {
// ONE floor, shared with the runtime catch-up lane (settledHeight): the lower of
// the ledger and the VM's applied head, with the absence/zero-height guards in
// exactly one place. Re-deriving it inline is how the two lanes drifted — one got
// the applied==0 guard, the other collapsed to genesis on an unreadable head.
floor, _ := rt.settledHeight(ctx)
if blk.Height() <= floor {
return nil
}
if h := blk.Height(); h <= fh {
// The replay band: decided by the ledger, not yet executed by the VM. Bind
// BOTH the height AND the parent to the applied head — a height match alone
// admits a sibling at the right height, and proposervm commits its outer
// envelope before the inner EVM refuses a non-child.
headID, headH, herr := rt.AppliedHead(ctx)
if herr != nil || h != headH+1 || blk.ParentID() != headID {
return ErrBootstrapBlockRejected
}
// Identity, matched exactly as the catch-up lane matches it — a one-sided
// negative (reject only on a KNOWN mismatch) let an unknown height fall
// through to Accept with nothing vouching for it. The ledger's own record
// first; where it has pruned the height, this node's deep recovery index.
if canonical, envelope, known := rt.Transitive.consensus.FinalizedAt(h); known {
if canonical != canonicalIDOf(blk) && envelope != blk.ID() {
return ErrBootstrapBlockRejected
}
} else if outer, ok := rt.recoveredOuterAt(h); !ok || outer != blk.ID() {
return ErrBootstrapBlockRejected
}
// Prefer the copy the VM already holds (gossiped ahead into the store), and
// Verify only when it is NOT held: re-verifying our own stored block asks the
// VM to insert it a second time, which it refuses.
exec, held := blk, false
if hb, herr := rt.config.VM.GetBlock(ctx, blk.ID()); herr == nil && hb != nil {
exec, held = hb, true
}
if !held {
if err := exec.Verify(ctx); err != nil {
return errors.Join(ErrBootstrapBlockRejected, err)
}
}
if err := exec.Accept(ctx); err != nil {
return errors.Join(ErrBootstrapBlockRejected, err)
}
_ = rt.config.VM.SetPreference(ctx, exec.ID())
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. This is the FRESH-SYNC path (height == finalized+1, or the M2 first
// block): the block is being added to the ledger for the FIRST time, on frontier
// trust. Local Verify is the integrity check that makes that trust safe — a peer
// cannot advance our sync with an invalid block — so it runs UNCONDITIONALLY here,
// never skipped. The held-preference belongs only to the REPLAY band above (h ≤ fh),
// where the block was already finalized and the VM genuinely holds a verified copy;
// a not-yet-finalized block at fh+1 must be validated, held or not.
exec := blk
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 := exec.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", exec.ID()), log.Err(err))
}
}
_ = rt.config.VM.SetPreference(ctx, exec.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 {
// The VM names a last-accepted id but cannot produce the block — a pruned or
// partially-imported index. This is NOT height 0: reporting it as 0 let the
// floor collapse to genesis (a fabricated "we're at the start" that skips the
// whole chain as already-applied) and let M2 bind a first block to an unread
// head. Surface it as the error it is; every caller fails closed on it.
return id, 0, fmt.Errorf("last-accepted block %s is unreadable: %w", id, err)
}
return id, blk.Height(), nil
}