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