Files
node/chains/bootstrap_sync.go
T
zeekayandHanzo Dev a63a18bf71 node v1.32.11: C-Chain restart/recovery serving hardening (isBootstrapped truth + wipe-path ancestry)
Two node/bootstrap-layer fixes for a clean rolling validator upgrade. Consensus
engine untouched (v1.35.5 boot-seed from v1.32.10 carried forward).

[HIGH — correctness] info.isBootstrapped(C) tracks REAL state, not premature true.
  manager.IsBootstrapped(id) returned true the instant a chain merely EXISTED in
  m.chains (set right after createChain launched the async, possibly-stalling
  bootstrap goroutine) — so a C-Chain stalled at genesis (head 0x0) reported
  info.isBootstrapped(C)=true, masking the stall from any wait-for-healthy gate.
  Now keys on the SAME sb.Bootstrapped signal the readiness health check uses
  (m.Nets.IsChainBootstrapped), set only after runInitialSync reaches the named
  frontier and the VM goes to normal operation (head advanced, eth-RPC live).
  GetChains().Bootstrapped fixed identically. New per-chain query on nets.Net +
  chains.Nets (nil-safe). Regression test TestIsBootstrappedTracksRealConvergence.

[MEDIUM — robustness] WIPE-path GetAncestors fetch hardened for ≥2 peers at genesis.
  Applying the proven avalanchego contract (studied in snowman/bootstrap +
  getter): (a) Ancestors buffers the whole sample and SKIPS empty batches,
  returning the first NON-EMPTY one — a size-1 channel let a fast empty reply from
  a genesis peer win the race and starve a peer that actually held the ancestry;
  (b) sampleAncestorBeacons PREFERS beacons the frontier round found genuinely
  ahead (they hold the ancestry) — ava's PeerTracker "ask a prover" bias sourced
  from the replies we already have, no separate tracker. Tests:
  TestNodeBootstrap_WipePath_MixedGenesisPeers_ObtainsAncestry,
  TestSampleAncestorBeacons_PrefersAheadBeacons.

Build: main+chains+nets+service/info compile CGO_ENABLED=0 (ARC/Dockerfile path);
full chains+nets suites green.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-02 00:05:36 -07:00

1147 lines
60 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_sync.go — the node-side wiring for INITIAL SYNC. The blockHandler is both
// the fetch transport (chainbootstrap.BlockSource) and the execute sink
// (chainbootstrap.Chain) the engine/chain/bootstrap loop drives, so an EMPTY or BEHIND
// node converges from its local last-accepted to the network frontier by fetch+execute
// (re-execute each fetched block; no vote, no cert). When the loop reaches the
// frontier the driver ENDS the engine's bootstrap phase (FinishBootstrap — only the
// α-of-K cert-gate finalizes thereafter) and flips bootstrapDone, the REAL ready
// signal monitorBootstrap gates the chain on. Decomplected from the live cert/vote
// path: when bsActive is false every method below is inert and the handler behaves
// exactly as it did before initial sync existed.
package chains
import (
"context"
"encoding/binary"
"errors"
"time"
cblock "github.com/luxfi/consensus/engine/chain/block"
chainbootstrap "github.com/luxfi/consensus/engine/chain/bootstrap"
"github.com/luxfi/ids"
"github.com/luxfi/log"
"github.com/luxfi/math/set"
"github.com/luxfi/node/proto/p2p"
)
// The blockHandler IS the bootstrap loop's fetch transport AND execute sink. If it
// ever stops satisfying either, the chain cannot initial-sync — catch it at compile
// time, not in production (the same discipline as the ancestorRequester assertion). It is
// also the BootstrapPolicy's AncestrySource (the content-addressed ancestry the responder-
// agreement tally walks), keeping the trust DECISION (bootstrap_trust.go) separate from the
// transport that feeds it.
var (
_ chainbootstrap.BlockSource = (*blockHandler)(nil)
_ chainbootstrap.Chain = (*blockHandler)(nil)
_ AncestrySource = (*blockHandler)(nil)
)
const (
// bootstrapFrontierWindow bounds how long FrontierTip collects weighted beacon
// replies before tallying the ⅔-by-stake quorum. A beacon answering later just
// misses this round (the driver re-samples) — it is not abandoned.
bootstrapFrontierWindow = 3 * time.Second
// bootstrapAncestorsTimeout bounds how long Ancestors waits for a peer to serve a
// batch (longer than the 10s GetAncestors deadline so a slow-but-honest peer is not
// abandoned prematurely).
bootstrapAncestorsTimeout = 12 * time.Second
// bootstrapAncestorSample is how many beacons one Ancestors round asks. The sample
// ROTATES (bsRotor) so no single beacon monopolizes the descent or can repeatedly
// stall it (M1). Content addressing in the loop makes the fetched ancestry safe
// regardless of WHICH beacon serves it.
bootstrapAncestorSample = 4
// bootstrapMinAgreeingBeacons floors the DISTINCT beacons that must name the same
// tip, so a single (even >⅔-stake) validator cannot alone determine the frontier.
// Capped at the beacon-set size for tiny networks.
bootstrapMinAgreeingBeacons = 2
// bootstrapNamingWindow bounds the ancestry the ANCESTOR-TOLERANT frontier tally fetches
// per candidate anchor to resolve the bleeding-edge skew between honest beacons. On a live
// chain that skew is tiny (the canary was ONE block: 2 producers at N, 1 at N+1), so a
// single 256-block window covers it with enormous margin. A ⅔-common height more than this
// far below the highest tip is not a healthy bleeding-edge split — the tally names nothing
// (the loop then retries / fails safe), never a wrong block. Matches the consensus descent's
// per-round fetch size.
bootstrapNamingWindow = 256
// maxNamingAnchors bounds how many DISTINCT reported tips the ancestor-tolerant tally will
// fetch ancestry for in one round. Honest beacons cluster on a handful of adjacent tips, so
// this is never reached in practice; it caps the work a Byzantine swarm reporting many
// distinct forged tips could induce. Anchors are tried most-stake-first (honest tips hold ⅔
// of the stake, so they always fall within the cap) and a tip already seen on a
// previously-fetched chain is skipped, so the common split costs ONE or TWO fetches.
maxNamingAnchors = 8
// bootstrapNamingTimeout TOTAL-bounds the ancestor-tolerant resolution (all anchor fetches
// combined) so a genuine eclipse/partition — beacons that answer the frontier query but do
// not serve ancestry — cannot make FrontierTip hang. When it elapses the resolution returns
// what it found (or nothing → FrontierNoQuorum), and the loop's bounded retry (F2) tries
// again next round against a fresh, rotated beacon sample. Honest beacons that JUST answered
// the frontier serve the small skew ancestry near-instantly, well within this window.
bootstrapNamingTimeout = 3 * time.Second
)
// beaconWeights returns the beacon set's nodeID→stake map and total stake under this
// chain's validation network, or ok=false when no beacon set is configured (single-node
// / --skip-bootstrap). This is the TRUST ANCHOR for initial sync: only a node in this
// set, weighted by its stake, can contribute to naming the frontier — the C1
// forged-chain gate. An arbitrary connected peer is not in this map and is ignored.
func (b *blockHandler) beaconWeights() (weights map[ids.NodeID]uint64, total uint64, ok bool) {
if b.beacons == nil {
return nil, 0, false
}
vmap := b.beacons.GetMap(b.networkID)
if len(vmap) == 0 {
return nil, 0, false
}
weights = make(map[ids.NodeID]uint64, len(vmap))
for id, v := range vmap {
w := v.Weight
if w == 0 {
w = v.Light // Weight is an alias for Light
}
weights[id] = w
total += w
}
if total == 0 {
return nil, 0, false
}
return weights, total, true
}
// connectedBeacons returns the beacons from `weights` currently CONNECTED and tracking
// this chain's validation NETWORK. The frontier quorum is tallied over these, but the FULL
// beacon stake (the `total` from beaconWeights) stays the quorum DENOMINATOR — so a node
// must hear a ⅔-stake supermajority. An eclipse that reaches < ⅔ of beacon stake cannot
// name a frontier. Returns nil when none are connected (or there is no transport).
//
// The tracking filter matches against b.networkID (the NET the beacon set is anchored to —
// constants.PrimaryNetworkID for native chains, the L1 net ID for sovereign chains), NOT
// b.chainID. This is the mainnet-canary fix: a peer advertises the NETS it tracks in its
// handshake (network/peer/peer.go adds constants.PrimaryNetworkID plus every tracked L1 net
// ID to peer.Info.TrackedChains) — it NEVER advertises an individual native chain ID like
// the C-Chain's. Filtering on b.chainID therefore matched ZERO real peers, so connectedStake
// stayed 0, FrontierTip reported FrontierConnecting forever, and a healthy stale node failed
// safe at the connect deadline instead of converging. Matching on b.networkID counts exactly
// the beacons participating in this chain's validation network. C1 is untouched: a peer is
// still admitted ONLY if it is in `weights` (the staked validator set) — a non-staked peer
// that tracks the network is still ignored.
func (b *blockHandler) connectedBeacons(weights map[ids.NodeID]uint64) []ids.NodeID {
if b.net == nil || len(weights) == 0 {
return nil
}
beaconIDs := make([]ids.NodeID, 0, len(weights))
for id := range weights {
beaconIDs = append(beaconIDs, id)
}
var connected []ids.NodeID
for _, p := range b.net.PeerInfo(beaconIDs) {
if _, isBeacon := weights[p.ID]; isBeacon && p.TrackedChains.Contains(b.networkID) {
connected = append(connected, p.ID)
}
}
return connected
}
// sampleAncestorBeacons returns a small ROTATED sample of connected beacons to fetch
// ancestry from (M1 — bsRotor advances each call so the sample walks the beacon set and
// no single beacon monopolizes the descent). Safety is independent of WHICH beacon
// serves: the loop's content-addressed descent rejects any block off the agreed
// frontier's parent chain, so a withholding/forging beacon costs only a re-sample.
func (b *blockHandler) sampleAncestorBeacons() (set.Set[ids.NodeID], bool) {
weights, _, ok := b.beaconWeights()
if !ok {
return nil, false
}
connected := b.connectedBeacons(weights)
if len(connected) == 0 {
return nil, false
}
// Prefer beacons that reported an ahead tip in the last frontier round — they HOLD the
// ancestry the descent needs, so asking them (rather than a rotated peer that may be at
// genesis) is what lets a re-bootstrapping node obtain ancestry even when ≥2 peers are still
// at genesis. This is ava's PeerTracker "ask a prover" bias sourced from the frontier replies
// we already have (no separate tracker). Fill any remaining slots from the rotated full set so
// the sample never shrinks below what blind rotation would pick (defense against a stale/empty
// ahead-set). Empty ahead-set ⇒ pure rotation, identical to prior behavior.
b.bsMu.Lock()
ahead := b.bsAheadBeacons
b.bsMu.Unlock()
sample := set.NewSet[ids.NodeID](bootstrapAncestorSample)
for _, id := range connected {
if sample.Len() >= bootstrapAncestorSample {
break
}
if ahead != nil && ahead.Contains(id) {
sample.Add(id)
}
}
start := int(b.bsRotor.Add(1)-1) % len(connected)
for i := 0; i < len(connected) && sample.Len() < bootstrapAncestorSample; i++ {
sample.Add(connected[(start+i)%len(connected)])
}
return sample, sample.Len() > 0
}
// fullyConnectedBeacons reports whether the node has reached its ENTIRE EXTERNAL beacon set — every
// beacon OTHER than itself is connected and tracking this chain's network. This is the eclipse-free
// condition that makes the FrontierTip SELF-VOTE safe: when the full set is reached no beacon can be
// suppressed, so a unanimous "nobody ahead" from the full set PLUS the node itself is a TRUE caught-up
// — an eclipse hiding any ahead-producer would drop that beacon from `connected` and break full-set,
// falling back to the partition-capture floor (fail safe). `connected` is connectedBeacons(weights);
// the external-set size is the beacon set minus the node itself (a node cannot, and need not, connect
// to itself). Returns false for an empty external set (a degenerate single-beacon / self-only set, so
// the self-vote is never the SOLE basis for caught-up).
func (b *blockHandler) fullyConnectedBeacons(weights map[ids.NodeID]uint64, connected []ids.NodeID) bool {
external := len(weights)
if _, selfIsBeacon := weights[b.selfNodeID]; selfIsBeacon {
external-- // the node is in its own beacon set but is never one of its own peers
}
return external > 0 && len(connected) >= external
}
// withSelfVote returns `replies` plus the node's OWN accepted frontier as a beacon reply — the
// SELF-VOTE. The node is itself a beacon (selfNodeID in `weights`, the trust anchor) and knows its
// own accepted tip (lastID) with certainty, so it vouches for it exactly as a connected peer's reply
// would (deduped by NodeID in the policy's tally; collectFrontierReplies samples only PEERS, never
// self, so there is no double-count). Returned UNCHANGED when the node is not in the beacon set
// (degenerate / single-node / a P-chain whose CustomBeacons omit self) or has no accepted tip — the
// self-vote is then inert. self only ever vouches for the block IT HAS ACCEPTED, so it can never name
// a forged tip (C1 untouched); the SOLE caller gates its use on FULL connectivity, so it can never
// tip a partial (eclipsed) view into caught-up.
func (b *blockHandler) withSelfVote(replies []BeaconReply, weights map[ids.NodeID]uint64, lastID ids.ID) []BeaconReply {
w, selfIsBeacon := weights[b.selfNodeID]
if !selfIsBeacon || lastID == ids.Empty {
return replies
}
out := make([]BeaconReply, 0, len(replies)+1)
out = append(out, replies...)
out = append(out, BeaconReply{NodeID: b.selfNodeID, Tip: lastID, Weight: w})
return out
}
// repliedCovers reports whether EVERY connected beacon answered THIS frontier
// round — set containment {reply.NodeID} ⊇ connected, NOT a count (frontier
// replies are not yet round/connected-filtered, so a stale/non-connected reply
// could pad a length check). The self-vote caught-up shortcut requires this:
// without it the gate keys on CONNECTIVITY (fullyConnectedBeacons) while CaughtUp
// tallies REPLIES, and the two diverge for a beacon that is CONNECTED but SILENT
// — TCP up, its frontier reply delayed or withheld. That is a capability strictly
// WEAKER than a full eclipse (no TCP drop needed) and it occurs NATURALLY when an
// ahead beacon replays state on a mass co-restart. Such a beacon counts as "fully
// connected" yet is absent from the tally, so self's (heavy) weight backfills the
// floor and the node goes live at a forged STALE height — the precise failure the
// frontier-quorum gate exists to prevent (RED CRITICAL). Requiring every connected
// beacon to have replied means a silent ahead-beacon blocks caught-up → the node
// waits / fails safe, while a genuine fresh net (every connected beacon answers
// genesis) still completes.
func repliedCovers(replies []BeaconReply, connected []ids.NodeID) bool {
replied := make(map[ids.NodeID]struct{}, len(replies))
for _, r := range replies {
replied[r.NodeID] = struct{}{}
}
for _, id := range connected {
if _, ok := replied[id]; !ok {
return false
}
}
return true
}
// FrontierTip implements chainbootstrap.BlockSource. It returns a FrontierStatus that
// decomplects the THREE reasons a tip may not be named — the fix for the mainnet canary
// where a freshly-booted STALE node, asking the frontier BEFORE any beacon had connected,
// concluded "caught up" at its local stale height (a single ok=false meant both "no beacon
// quorum reachable yet" and "nothing ahead — done"):
//
// - FrontierNoBeacons — no beacon set configured (single-node / dev). Nothing to sync to.
// - FrontierConnecting — beacons configured but not enough stake CONNECTED yet to even
// FORM a ⅔ quorum (the boot race). The node cannot ASK — the loop must WAIT, never
// conclude caught-up here.
// - FrontierNoQuorum — enough beacon stake IS connected to ask, but no ⅔-by-stake
// agreement is reachable (genuine eclipse / partition). The loop fails SAFE.
// - FrontierNamed — a ⅔-by-stake SUPERMAJORITY of the configured beacons agreed on
// the SAME accepted tip (the C1 forged-chain gate, unchanged). The tip is meaningful
// ONLY in this case.
//
// A non-beacon peer, a single peer, or any sub-⅔-stake set can NEVER reach FrontierNamed,
// so a forged chain can never be the thing an empty/behind node syncs to.
func (b *blockHandler) FrontierTip(ctx context.Context) (ids.ID, chainbootstrap.FrontierStatus) {
weights, _, haveBeacons := b.beaconWeights()
if !haveBeacons {
if b.expectsStakedBeacons {
// This chain syncs against the STAKED primary-network validator set, which the
// already-bootstrapped P-chain populates — an empty set here means it is not yet
// loaded (sequencing) or misconfigured, NOT a single-node network. WAIT (bounded by
// the loop's ConnectDeadline → fail safe). Critically we do NOT conclude caught-up
// at the local stale height, and we do NOT fall back to unweighted / endpoint trust:
// a connected peer can name the frontier ONLY through the ⅔-by-stake quorum below,
// which is empty here. The sequencing case resolves (initValidatorSets populates the
// set) and the node converges; a genuine empty/misconfigured set fails safe.
return ids.Empty, chainbootstrap.FrontierConnecting
}
// No beacon set expected (single-node / dev / --skip-bootstrap / P-chain endpoint-only
// bootstrappers). Nothing to sync to.
return ids.Empty, chainbootstrap.FrontierNoBeacons
}
if b.net == nil || b.msgCreator == nil {
// Beacons configured but no transport to ask them — treat as still connecting
// (bounded by the loop's connect deadline). In practice runBootstrapThenPoll
// short-circuits a transport-less handler before Run is ever called.
return ids.Empty, chainbootstrap.FrontierConnecting
}
// THE CANARY ROOT-CAUSE GATE. A native chain's beacon set is the STAKED primary-network
// validator set the P-chain populates as it replays its blocks (genesis stakers + every later
// AddValidatorTx). This bootstrap goroutine starts right after the chain's VM.Initialize — NOT
// after the P-chain has finished syncing — so without this gate FrontierTip can run while that
// set is PARTIAL. A partial set is a NON-REPRESENTATIVE stake denominator: the MinResponseWeight
// stake-majority floor (total/2+1) is fail-secure only when `total` is the TRUE full-set stake,
// and under a partial denominator a degenerate set of at-or-below responders (the genuinely-ahead
// producers not yet loaded / not yet connected) clears the floor and the decision below
// false-completes the node at its stale local height — the mainnet luxd-2 freeze. WAIT (→ the
// loop's ConnectDeadline → fail safe) until the staked set is fully loaded, so every branch below
// judges the TRUE full validator set. Deadlock-free: the P-chain converges independently from its
// own configured CustomBeacons; a native chain only waits the bounded extra moment for it.
if b.expectsStakedBeacons && b.primaryNetworkReady != nil && !b.primaryNetworkReady() {
b.logger.Debug("bootstrap frontier: staked validator set not yet fully loaded (P-chain still syncing) — waiting, NOT concluding caught-up",
log.Stringer("chainID", b.chainID),
log.Int("partialBeaconCount", len(weights)))
return ids.Empty, chainbootstrap.FrontierConnecting
}
// THE MASS-RECOVERY FIX. The acceptance decision is the BootstrapPolicy (a SEPARATE object
// with a SEPARATE threat model — bootstrap_trust.go), NOT the ⅔-of-CURRENT-total-stake
// consensus floor. The prior code required a ⅔-by-stake quorum of the WHOLE validator set to
// be CONNECTED before naming a frontier; when the recovery targets are themselves validators
// (a crashed node IS one of the 5), that floor is mathematically unsatisfiable during a mass
// outage — the deadlock. The policy instead requires MinResponses authenticated CONFIGURED
// beacons to respond and a ⅔-of-RESPONDERS agreement, so 3 of 5 reachable beacons all agreeing
// recover the network even though 3 of 5 STAKE is not a finalizing supermajority. Finality is
// untouched (it lives in consensus and still needs > ⅔ of current stake).
connected := b.connectedBeacons(weights)
policy := b.bootstrapPolicy(weights)
replies := b.collectFrontierReplies(ctx, connected, weights)
// INSTRUMENTATION (canary ground truth). Per attempt, record the FULL beacon-set size + total
// stake (the floor DENOMINATOR — a partial value here is the smoking gun), the connected count,
// and every reply's tip + LOCALLY-resolved height + held-or-not. Debug level so production is
// silent; set the chain's log level to Debug on the canary to capture the real decision data.
b.logFrontierInputs(weights, connected, replies)
lastID, lastH, lastErr := b.LastAccepted(ctx)
haveLast := lastErr == nil && lastID != ids.Empty
frontier, err := policy.AcceptsFrontier(ctx, replies)
switch {
case err == nil:
// A configured-beacon quorum named a safe sync anchor (NOT a finality cert — the loop
// re-executes the descent and re-enters consensus, where ConsensusQuorum alone governs).
// With the P-ready gate above, this judgement is over the TRUE FULL staked set, so a tip the
// quorum actively names is a real frontier (when it equals our own held tip, a ⅔-of-responders
// supermajority is AT our height, so we ARE at the network frontier — the loop's Accepted()-shortcut
// then correctly concludes caught-up). A Byzantine minority reporting an unheld forged sibling
// cannot reach the ⅔ agreement, so it is never named (C1) and — crucially — never BLOCKS the
// honest named tip either (we do NOT require holding every reported tip to accept the named
// one; that stricter rule belongs to the dual CaughtUp path below, which decides the
// no-quorum/own-height case).
b.logger.Debug("bootstrap frontier: NAMED",
log.Stringer("chainID", b.chainID),
log.Stringer("tip", frontier.ID),
log.Uint64("namedHeight", frontier.Height),
log.Int("responders", frontier.Responders),
// ACCEPTED, not merely held: a gossiped-but-unaccepted frontier (the freeze) logs
// accepted=false here, so the loop's Accepted()-shortcut DESCENDS instead of completing
// at the stale tip. The diagnostic now distinguishes "in the store" from "finalized".
log.Bool("accepted", b.Accepted(ctx, frontier.ID)))
return frontier.ID, chainbootstrap.FrontierNamed
case errors.Is(err, ErrInsufficientBootstrapResponses):
// Below the PEER-ONLY response floor. Before WAITING, apply the SELF-VOTE under FULL
// connectivity — THE FRESH-NET FIX. The node is itself a beacon and knows its OWN accepted
// tip with certainty. When it has reached its ENTIRE beacon set (fullyConnectedBeacons — so no
// eclipse can be hiding an ahead-tip) and that full set PLUS itself unanimously hold a tip the
// node has ALREADY ACCEPTED, the node IS at the network frontier — even though the peer-only
// responder weight could not reach the stake-majority NAMING floor. That floor is unsatisfiable
// by PEERS ALONE precisely when the node's own stake is large relative to the rest (a heavy
// validator, a small beacon set like the P-chain's CustomBeacons, or skewed stake): peers =
// total self, so peers < total/2+1 ⟺ self > total/21. On a fresh net every validator holds
// only genesis, so such a node would otherwise hang in FrontierConnecting forever with the
// WHOLE set connected. SAFE: self is counted ONLY under full connectivity, so an eclipse that
// suppresses ANY beacon breaks full-set and we fall through to the partition-capture floor
// (FrontierConnecting → fail safe) — self can never tip a PARTIAL (eclipsed) view into a false
// caught-up. self also only ever vouches for its OWN accepted tip, so C1 (forged-frontier
// naming) is untouched, and a genuinely behind node has an ahead peer in the full set → CaughtUp
// is false → it keeps waiting/syncing.
if haveLast && b.fullyConnectedBeacons(weights, connected) &&
repliedCovers(replies, connected) &&
policy.CaughtUp(b.withSelfVote(replies, weights, lastID), lastH, b.acceptedHeight) {
b.logger.Debug("bootstrap frontier: CAUGHT UP at own tip (full beacon set + self all at/below it, peer-only weight below the naming floor)",
log.Stringer("chainID", b.chainID),
log.Stringer("tip", lastID),
log.Uint64("height", lastH),
log.Int("beaconSetSize", len(weights)),
log.Int("connected", len(connected)))
return lastID, chainbootstrap.FrontierCaughtUp
}
// Fewer than MinResponses configured beacons have RESPONDED (and not provably caught-up under
// full connectivity) — not a partition-capture-safe quorum yet. More may connect: WAIT (bounded
// by the loop's ConnectDeadline, then fail safe). Never false-complete; never trust the few.
b.logger.Debug("bootstrap frontier: CONNECTING (below the MinResponses floor)",
log.Stringer("chainID", b.chainID),
log.Int("beaconSetSize", len(weights)),
log.Int("connected", len(connected)),
log.Int("replies", len(replies)))
return ids.Empty, chainbootstrap.FrontierConnecting
default:
// ErrNoBootstrapQuorum: enough beacons responded (the FLOOR is met) but no ⅔-of-responders
// agreement named a common committed block this round. Before failing safe, check whether we
// are CAUGHT UP. A TIP-HOLDER on a mixed-height co-restart sees the responders SPLIT below ⅔
// (the tip-holders are only half), so NOTHING is named, yet it is plainly not behind — and
// the ancestor-tolerant tally cannot name the ⅔-backed COMMON ancestor either, because that
// ancestor is AT OR BELOW the node's own last-accepted (MinFrontierHeight names only STRICTLY
// ABOVE it). That own-height exclusion is also the M1 eclipse-stale fix: a block at the node's
// height that accrues ⅔ purely as the shared ANCESTOR of ahead-tips an eclipse throttled below
// the naming threshold is NOT named — so this same CaughtUp gate, not nameFrontier, decides the
// at-own-height case, and it REFUSES when any responder is genuinely ahead (the node lacks that
// tip). Without this the tip-holder fails safe DOWN at its own tip — the opposite of the
// stale-go-live bug.
//
// policy.CaughtUp is true ONLY when the floor is met AND no responder reports an accepted tip
// above our height AND we hold every reported tip (its full safety argument lives on the
// method). When it holds, the network frontier IS our own accepted tip: return it NAMED so
// the loop's existing Accepted()-shortcut (bootstrapper "named a tip we have ACCEPTED → synced")
// concludes caught-up and we go Ready at our OWN height — never below it, never at a stale
// height. A genuinely stale node has an honest responder ahead (or lacks its tip) → CaughtUp
// is false → it keeps syncing; an eclipse hiding the ahead-nodes drops below the floor →
// CaughtUp is false → it fails safe. Distinct from the transient finality-skew retry the loop
// runs on a bare FrontierNoQuorum ("connected but momentarily split" — keep polling): this is
// "split AND I am at the top of every responder" — provably DONE, not waiting.
if haveLast && policy.CaughtUp(replies, lastH, b.acceptedHeight) {
b.logger.Debug("bootstrap frontier: CAUGHT UP at own tip (floor met, no responder ahead, hold every reported tip)",
log.Stringer("chainID", b.chainID),
log.Stringer("tip", lastID),
log.Uint64("height", lastH))
return lastID, chainbootstrap.FrontierNamed
}
b.logger.Debug("bootstrap frontier: NO QUORUM (responders split, not caught up) — retry/fail safe",
log.Stringer("chainID", b.chainID),
log.Uint64("lastAccepted", lastH),
log.Int("replies", len(replies)))
return ids.Empty, chainbootstrap.FrontierNoQuorum
}
}
// logFrontierInputs records, at Debug level, the raw inputs to one FrontierTip decision — the
// canary ground-truth instrumentation. The FULL beacon-set size + total stake is the floor's
// DENOMINATOR: if it is a PARTIAL value (the staked set still loading), the stake-majority floor
// is non-representative and a degenerate at-or-below responder subset can false-complete the node
// — so logging the denominator alongside every reply's tip + locally-resolved height + held-or-not
// is exactly what distinguishes "caught up" from "tricked by a partial set". Silent in production
// (Debug); enable Debug on the chain to capture it on the canary.
func (b *blockHandler) logFrontierInputs(weights map[ids.NodeID]uint64, connected []ids.NodeID, replies []BeaconReply) {
var total uint64
for _, w := range weights {
total += w
}
for _, r := range replies {
h, accepted := b.acceptedHeight(r.Tip)
b.logger.Debug("bootstrap frontier reply",
log.Stringer("chainID", b.chainID),
log.Stringer("from", r.NodeID),
log.Stringer("reportedTip", r.Tip),
log.Uint64("beaconWeight", r.Weight),
// ACCEPTED (on our finalized chain), not merely present in the store: a beacon tip we
// hold-but-have-not-accepted logs accepted=false with resolvedHeight 0 — the smoking gun
// that distinguishes a genuine catch-up from the stored-but-unaccepted freeze.
log.Bool("accepted", accepted),
log.Uint64("resolvedHeight", h))
}
b.logger.Debug("bootstrap frontier inputs",
log.Stringer("chainID", b.chainID),
log.Int("beaconSetSize", len(weights)),
log.Uint64("beaconSetTotalStake", total),
log.Int("connectedBeacons", len(connected)),
log.Int("replies", len(replies)))
}
// bootstrapPolicy builds the BootstrapTrust DECISION object for this round from the configured
// beacon set (the trust anchor — INVARIANT 1: configured/checkpoint/genesis, never peer
// self-report). MinResponses defaults to a MAJORITY of the configured beacons (the largest floor
// that still lets the node recover when a minority of validators is down); the agreement is ⅔ of
// the RESPONDERS; MinFrontierHeight is the node's last-accepted height (the ancestor-tolerant path
// names only a block STRICTLY ABOVE it — never at or beneath where the node already is, so an
// eclipse cannot make the node's own height a false frontier; that case routes to CaughtUp — fail
// safe, not false-complete). All are operator-overridable via the blockHandler fields; zero ⇒ the
// documented default.
func (b *blockHandler) bootstrapPolicy(weights map[ids.NodeID]uint64) *BootstrapPolicy {
minResp := b.bootstrapMinResponses
if minResp <= 0 {
minResp = len(weights)/2 + 1 // MAJORITY of the configured beacon set
}
if minResp > len(weights) {
minResp = len(weights)
}
var lastH uint64
if _, h, err := b.LastAccepted(context.Background()); err == nil {
lastH = h
}
// STAKE-MAJORITY FLOOR (closes the skewed-weight partition-capture forgery). MinResponses is a
// COUNT of beacons; AgreementThreshold is over responder WEIGHT. Under SKEWED validator stake
// these diverge: an attacker who eclipses the HEAVY honest beacons but lets enough LIGHT honest
// beacons through to satisfy the count floor can shrink the responder-weight denominator until
// his < ⅓-of-total Byzantine stake clears the ⅔-OF-RESPONDERS agreement and names a forged
// frontier. Requiring the responders to also carry > ½ of TOTAL configured beacon stake bounds
// that denominator from below, so a < ⅓-stake adversary can never reach a responder-weight ⅔
// majority. Proven safe AND deadlock-free: equal-weight recovery needs only > ½ (e.g. 3/5 =
// 0.6·total ≥ 0.5), NOT the > ⅔ that caused the original mass-recovery deadlock. Disabled only
// when no weights are known (degenerate single-node / pre-P-chain).
var minRespWeight uint64
var total uint64
for _, w := range weights {
total += w
}
if total > 0 {
minRespWeight = total/2 + 1 // ⌈total/2⌉ stake-majority floor
}
return &BootstrapPolicy{
TrustedBeacons: weights,
AgreementThreshold: b.bootstrapAgreement, // zero ⇒ policy default ⅔
MinResponses: minResp,
MinResponseWeight: minRespWeight, // stake-majority floor (anti skewed-weight partition-capture)
MinResponders: bootstrapMinAgreeingBeacons,
MinFrontierHeight: lastH,
Checkpoint: b.bootstrapCheckpoint,
NamingWindow: bootstrapNamingWindow,
MaxAnchors: maxNamingAnchors,
NamingTimeout: bootstrapNamingTimeout,
Source: b,
}
}
// collectFrontierReplies sends GetAcceptedFrontier to the connected beacons and gathers ONE
// reply per beacon within the window, returning them as []BeaconReply for the BootstrapPolicy to
// JUDGE. This is pure TRANSPORT — it does not decide. The DECISION (which beacons count, the
// MinResponses floor, the ⅔-of-responders agreement, the ancestor-tolerant tally) is the
// policy's (bootstrap_trust.go), keeping the trust object separate from the wire.
//
// It early-returns the instant every connected beacon has answered (no need to wait out the
// window on the common fully-connected path), bounding a slow/silent minority by the window.
// Non-beacon and empty replies are dropped here too (the policy re-filters — defense in depth).
func (b *blockHandler) collectFrontierReplies(ctx context.Context, connected []ids.NodeID, weights map[ids.NodeID]uint64) []BeaconReply {
if len(connected) == 0 || b.net == nil || b.msgCreator == nil {
return nil
}
ch := make(chan bsFrontierReply, len(connected))
b.bsMu.Lock()
b.bsFrontierCh = ch
b.bsMu.Unlock()
defer func() {
b.bsMu.Lock()
b.bsFrontierCh = nil
b.bsMu.Unlock()
}()
b.contextRequestMu.Lock()
b.requestIDCounter++
requestID := b.requestIDCounter
b.contextRequestMu.Unlock()
msg, err := b.msgCreator.GetAcceptedFrontier(b.chainID, requestID, 10*time.Second)
if err != nil {
return nil
}
sample := set.NewSet[ids.NodeID](len(connected))
sample.Add(connected...)
b.net.Send(msg, sample, b.networkID, 0)
replies := make([]BeaconReply, 0, len(connected))
seen := make(map[ids.NodeID]struct{}, len(connected))
// ahead = beacons reporting a tip this node has NOT accepted (they hold blocks we lack, so
// they can SERVE the ancestry the descent needs). sampleAncestorBeacons prefers them so the
// GetAncestors sample targets peers that can serve — never a peer still at genesis (our own
// tip). Recorded per round; publishes into b.bsAheadBeacons before returning.
ahead := set.NewSet[ids.NodeID](len(connected))
record := func() []BeaconReply {
b.bsMu.Lock()
b.bsAheadBeacons = ahead
b.bsMu.Unlock()
return replies
}
deadline := time.After(bootstrapFrontierWindow)
for {
select {
case rep := <-ch:
w, isBeacon := weights[rep.nodeID]
if !isBeacon || rep.tip == ids.Empty {
continue // only a configured beacon's non-empty reply counts
}
if _, dup := seen[rep.nodeID]; dup {
continue // one reply per beacon
}
seen[rep.nodeID] = struct{}{}
replies = append(replies, BeaconReply{NodeID: rep.nodeID, Tip: rep.tip, Weight: w})
if _, accepted := b.acceptedHeight(rep.tip); !accepted {
ahead.Add(rep.nodeID) // reported a tip we have not accepted → genuinely ahead
}
if len(seen) >= len(connected) {
return record() // every connected beacon answered — resolve now, do not wait the window
}
case <-deadline:
return record()
case <-ctx.Done():
return record()
}
}
}
// Ancestors implements chainbootstrap.BlockSource: fetch up to maxBlocks blocks ending
// at blockID, OLDEST-FIRST, from a ROTATED sample of beacons (wire: GetAncestors ->
// Ancestors). An empty result (no error) means the sampled beacon did not serve — the
// loop re-samples. The fetched ancestry is made safe by the loop's content-addressed
// descent (off-path blocks ignored), not by trusting the serving peer.
func (b *blockHandler) Ancestors(ctx context.Context, blockID ids.ID, maxBlocks int) ([][]byte, error) {
if b.net == nil || b.msgCreator == nil {
return nil, nil
}
sample, ok := b.sampleAncestorBeacons()
if !ok {
return nil, nil
}
b.contextRequestMu.Lock()
b.requestIDCounter++
requestID := b.requestIDCounter
b.contextRequestMu.Unlock()
// Buffer the whole sample so EVERY sampled beacon's reply can queue — the loop
// then skips the EMPTY ones (a beacon that lacks the requested block, e.g. a peer
// still at genesis) and returns the first NON-EMPTY batch. With a size-1 channel a
// fast empty reply won the race and starved a slower peer that actually held the
// ancestry, so a re-bootstrapping node with ≥2 peers at genesis could keep drawing
// empties and stall. This mirrors the proven avalanchego contract: an empty Ancestors
// reply means "this peer can't serve — take another's," never "done" (getter serves an
// EXPLICIT empty batch when it lacks the block; see GetContext).
ch := make(chan [][]byte, bootstrapAncestorSample)
b.bsMu.Lock()
b.bsAncestorCh[requestID] = ch
b.bsMu.Unlock()
defer func() {
b.bsMu.Lock()
delete(b.bsAncestorCh, requestID)
b.bsMu.Unlock()
}()
msg, err := b.msgCreator.GetAncestors(b.chainID, requestID, 10*time.Second, blockID, p2p.EngineType_ENGINE_TYPE_CHAIN)
if err != nil {
return nil, err
}
b.net.Send(msg, sample, b.networkID, 0)
deadline := time.After(bootstrapAncestorsTimeout)
for {
select {
case blocks := <-ch:
if len(blocks) == 0 {
continue // this beacon can't serve the block — wait for a peer that can
}
return blocks, nil
case <-deadline:
return nil, nil // no beacon in the sample served — the loop re-samples (rotated)
case <-ctx.Done():
return nil, ctx.Err()
}
}
}
// Ancestry implements the BootstrapPolicy's AncestrySource: fetch a tip's ancestry over the wire
// and parse each block to its CONTENT-ADDRESSED (id, height, parent). It reuses the SAME Ancestors
// transport + ParseBlock the sync loop trusts, so a forging peer cannot fake the parent linkage
// the responder-agreement tally walks — and the trust DECISION (bootstrap_trust.go) stays free of
// any VM/block dependency, taking only []BlockRef. An empty fetch (peer did not serve) yields nil
// so that anchor simply contributes nothing; a malformed served block fails the whole anchor (it
// is not safe to trust a chain we cannot parse).
func (b *blockHandler) Ancestry(ctx context.Context, tip ids.ID, max int) ([]BlockRef, error) {
raw, err := b.Ancestors(ctx, tip, max)
if err != nil || len(raw) == 0 {
return nil, err
}
refs := make([]BlockRef, 0, len(raw))
for _, bz := range raw {
blk, perr := b.vm.ParseBlock(ctx, bz)
if perr != nil {
return nil, perr
}
refs = append(refs, BlockRef{ID: blk.ID(), Height: blk.Height(), Parent: blk.ParentID()})
}
return refs, nil
}
// ParseBlock implements chainbootstrap.Chain: decode block bytes through the SAME
// builder the engine parses through (identity-preserving), so the loop reads each
// block's height + parent for ordering and the descent.
func (b *blockHandler) ParseBlock(ctx context.Context, raw []byte) (cblock.Block, error) {
return b.vm.ParseBlock(ctx, raw)
}
// LastAccepted implements chainbootstrap.Chain: the node's ACCEPTED tip id + height, read
// from the IN-PROCESS consensus finalized ledger — the SINGLE advancing source of truth the
// loop drives convergence off (THE one-source decomplect). It is NOT read from the VM cache.
//
// THE FREEZE-STALL (red HIGH-1): the descent DOES accept the run-up (the consensus finalized
// ledger AND the coreth on-disk store both advance), but the ZAP VM client caches its
// LastAccepted at Initialize and a fire-and-forget Accept never refreshes it (client.go) —
// so VM.LastAccepted is FROZEN for the process life. Reading lastH off that frozen cache made
// every pass after the first re-descend from the stale height while AcceptBootstrapBlock
// no-oped each block (height ≤ the ADVANCED finalizedHeight) → advanced=false → ErrStalled:
// "unfreezes but stalls". AcceptBootstrapBlock's own contiguity guard already trusts the
// in-process ledger (consensus.GetFinalizedHeight); driving lastH/the caught-up oracle off the
// SAME ledger is the decomplect — the loop trusts the ledger it is building, not a VM cache.
func (b *blockHandler) LastAccepted(ctx context.Context) (ids.ID, uint64, error) {
return b.finalizedTip(ctx)
}
// finalizedTip returns the in-process consensus finalized ledger position (the ADVANCING
// source), falling back to the VM's last-accepted ONLY when the ledger is unset — the
// empty-genesis boot window before the first finalize (consensus SyncState seeds the ledger
// from a NON-empty last-accepted; an empty node leaves it unset until block 1 finalizes), or a
// degenerate/test handler with no engine. The VM fallback is correct there: at genesis the VM
// last-accepted IS the anchor and has not yet had a chance to go stale (it only diverges from
// the ledger once blocks finalize, at which point the ledger takes over). This mirrors the
// engine's own M2 first-block anchor (bootstrap_accept.go localLastAccepted), keeping ONE rule.
func (b *blockHandler) finalizedTip(ctx context.Context) (ids.ID, uint64, error) {
if b.engine != nil {
if tip, h, set := b.engine.FinalizedLedger(); set {
return tip, h, nil
}
}
return b.vmLastAccepted(ctx)
}
// vmLastAccepted reads the VM's last-accepted id + height. It is the genesis-anchor fallback
// for finalizedTip (used only before the consensus ledger is seeded), NOT a convergence
// signal — a converging node's height MUST come from the finalized ledger, never this cache.
func (b *blockHandler) vmLastAccepted(ctx context.Context) (ids.ID, uint64, error) {
if b.vm == nil {
return ids.Empty, 0, nil
}
id, err := b.vm.LastAccepted(ctx)
if err != nil {
return ids.Empty, 0, err
}
if id == ids.Empty {
return ids.Empty, 0, nil
}
blk, err := b.vm.GetBlock(ctx, id)
if err != nil {
return id, 0, nil // id known, height unknown — treat as 0 (genesis-ish)
}
return id, blk.Height(), nil
}
// Accepted implements chainbootstrap.Chain: reports whether id is on the node's ACCEPTED chain
// (finalized) — NOT merely PRESENT in the block store. This is the loop's caught-up predicate.
// THE FREEZE was a store-vs-acceptance conflation: the prior Has() returned true for a frontier
// GOSSIPED into the store but UNACCEPTED (height ABOVE last-accepted), short-circuiting the loop
// to caught-up at the stale last-accepted and never descending to accept it. A stored-but-
// unaccepted block returns false here, so the loop descends and DRIVES its acceptance.
func (b *blockHandler) Accepted(ctx context.Context, id ids.ID) bool {
_, ok := b.acceptedHeightCtx(ctx, id)
return ok
}
// acceptedHeight is the ACCEPTANCE oracle injected into BootstrapPolicy.CaughtUp (as heightOf):
// it returns id's height and TRUE iff the node has ACCEPTED id (id is on the node's finalized
// chain), and (0,false) when id is merely PRESENT IN THE STORE but NOT accepted — the store-vs-
// acceptance distinction the luxd-2 freeze hinged on. CaughtUp uses it to (c) require the node to
// have ACCEPTED every reported tip and (b) read those tips' heights to confirm none is above the
// node's accepted height; a stored-but-unaccepted tip now makes CaughtUp FALSE (the node is behind
// in ACCEPTANCE → it syncs). It NEVER fetches over the network — an unaccepted/unheld tip simply
// makes the node not-caught-up, the safe direction.
func (b *blockHandler) acceptedHeight(id ids.ID) (uint64, bool) {
return b.acceptedHeightCtx(context.Background(), id)
}
// acceptedHeightCtx is the ctx-honoring core of the acceptance oracle (shared by Accepted and
// acceptedHeight). Authoritative and VM-internals-light: the accepted head id+height come from
// the IN-PROCESS consensus finalized ledger (LastAccepted → finalizedTip — the ADVANCING source,
// never the frozen VM cache), the height-bound (id ABOVE finalizedHeight ⇒ not yet accepted —
// the gossiped-ahead freeze case, regardless of store presence) is the primary anchor, and the
// in-process per-height ledger is the fork-sibling oracle for a block at/below the finalized head.
func (b *blockHandler) acceptedHeightCtx(ctx context.Context, id ids.ID) (uint64, bool) {
if id == ids.Empty || b.vm == nil {
return 0, false
}
lastID, lastH, err := b.LastAccepted(ctx)
if err != nil {
return 0, false
}
if id == lastID {
return lastH, true // the finalized head itself — the authoritative anchor
}
blk, err := b.vm.GetBlock(ctx, id)
if err != nil {
return 0, false // not even in the store → certainly not accepted
}
h := blk.Height()
if h > lastH {
// ABOVE our finalized head: a block GOSSIPED into the store ahead of acceptance. THE FREEZE
// — present, but NOT accepted. NOT caught up; the node must descend and accept it.
return 0, false
}
// h <= lastH and id != lastID: id is at a height we have finalized PAST. It is accepted IFF it
// is the block our per-height ledger finalized at h. Ask the IN-PROCESS finalized ledger (the
// same source LastAccepted reads), which replaces the dead coreth height index — block.ChainVM.
// GetBlockIDAtHeight is unhandled over ZAP, so the old VM-index call returned nothing on the real
// C-Chain and this whole branch was dead there. The in-process ledger answers authoritatively for
// every height finalized THIS session.
if canonical, ok := b.finalizedBlockAtHeight(h); ok {
if canonical != id {
return 0, false // a stored sibling/fork at a finalized height — NOT on the accepted chain
}
return h, true
}
// The per-height ledger does not know h: it is a height BELOW the boot seed (consensus SyncState
// seeds only the boot height + advances upward; older heights are never re-seeded). We have
// finalized PAST h and we hold id, so on a BFT-final chain (one finalized block per height) id is
// on our accepted chain. A node CAN gossip-hold a non-finalized sibling at such a height with NO
// safety break (it received a losing fork via gossip) — but that costs nothing HERE: this oracle
// only feeds the caught-up decision, which acts on the ⅔-NAMED frontier, and C1 (⅔-by-stake
// frontier naming) guarantees a forged/losing sibling is NEVER the named frontier; the descent's
// content-addressing + the per-height accept guard reject any off-chain block during execution.
// So treating a held sub-boot-seed block as accepted is the safe over-approximation.
return h, true
}
// finalizedBlockAtHeight returns the block the IN-PROCESS consensus ledger finalized at height h
// (ok=false when the node has not finalized h this session — a height below the boot seed — or has
// no engine). It is the authoritative fork-sibling oracle that replaces the dead coreth height
// index; degrading to ok=false simply routes acceptedHeightCtx to its height-bound anchor.
func (b *blockHandler) finalizedBlockAtHeight(h uint64) (ids.ID, bool) {
if b.engine == nil {
return ids.Empty, false
}
return b.engine.FinalizedBlockAtHeight(h)
}
// AcceptBootstrapBlock implements chainbootstrap.Chain: re-execute + finalize a fetched
// block on frontier-trust via the engine's bootstrap accept authority.
func (b *blockHandler) AcceptBootstrapBlock(ctx context.Context, raw []byte) error {
return b.engine.AcceptBootstrapBlock(ctx, raw)
}
// BootstrapComplete reports whether initial sync has reached the frontier. This is the
// REAL bootstrap-ready signal monitorBootstrap gates the chain on — replacing the
// premature engine.IsBootstrapped() poll that returned true at the local last-accepted
// height (the bug: an empty node declared itself bootstrapped at genesis and never
// synced).
func (b *blockHandler) BootstrapComplete() bool { return b.bootstrapDone.Load() }
// bsFailure records the fail-safe reason initial sync returned (ErrBeaconsUnreachable /
// ErrNoBeaconQuorum / ErrGapTooLarge / ...). Stored behind an atomic pointer so the
// monitorBootstrap goroutine reads it race-free — the precise diagnostic for the health check.
type bsFailure struct{ err error }
// BootstrapFailed reports whether initial sync ended in a fail-SAFE error (eclipse / partition
// / deep gap) rather than reaching the frontier (F5). monitorBootstrap polls this so a fail-safe
// return STOPS the chain promptly — it does not sit in the dead window between Run returning and
// the 5-min no-progress watchdog. Distinct from BootstrapComplete: a chain is ready XOR failed
// XOR still-syncing.
func (b *blockHandler) BootstrapFailed() bool { return b.bootstrapFailed.Load() != nil }
// BootstrapFailure returns the fail-safe reason once BootstrapFailed (else nil) — the precise
// cause (eclipsed/partitioned/too-far-behind) the operator's health check surfaces.
func (b *blockHandler) BootstrapFailure() error {
if f := b.bootstrapFailed.Load(); f != nil {
return f.err
}
return nil
}
// BootstrapConnecting reports whether initial sync is in its bounded connectivity-RETRY wait — a
// transient beacon-quorum-unreachable fail-safe it is re-attempting (the SELF-HEAL path). The node
// is correctly failing safe DOWN (VM in Bootstrapping, serving nothing as head) and waiting for the
// quorum to return; the network cannot make progress without it. monitorBootstrap's no-progress
// watchdog polls this so it does NOT force-STOP a node that is deliberately waiting — which, given
// the K8s probes only poll the always-green /ext/health/liveness, would be a permanent brick. It is
// the discriminator between "stuck on a served gap" (a real stall → stop) and "waiting for the
// quorum" (self-heal → keep waiting). Distinct from BootstrapFailed (a terminal/structural fail).
func (b *blockHandler) BootstrapConnecting() bool { return b.bootstrapConnecting.Load() }
// BootstrapHeight reports the node's current ACCEPTED height — the PROGRESS signal
// monitorBootstrap uses (H2) to tell a slow-but-advancing sync (reset the stall timer) from a
// genuine no-progress stall (fail). It reads the IN-PROCESS finalized ledger (finalizedTip), the
// SAME advancing source as the convergence oracle — NOT the VM cache, which a fire-and-forget ZAP
// Accept leaves FROZEN: reading the frozen cache here made monitorBootstrap's progress probe see
// zero advance even while the descent finalized block after block, so a deep but healthy sync
// looked like a stall. Best-effort: 0 if unknown.
func (b *blockHandler) BootstrapHeight() uint64 {
_, h, err := b.finalizedTip(context.Background())
if err != nil {
return 0
}
return h
}
// deliverBootstrapFrontier routes a frontier reply (TAGGED with the responding beacon)
// to the waiting FrontierTip when the bootstrap loop is driving. Returns true iff
// consumed (the caller must then NOT run the live auto-fetch). The nodeID is what lets
// FrontierTip weight the reply by the responder's stake — the heart of the α-quorum.
// Non-blocking: if the buffered channel is momentarily full the reply is dropped (the
// window/loop re-samples).
func (b *blockHandler) deliverBootstrapFrontier(nodeID ids.NodeID, containerID ids.ID) bool {
if !b.bsActive.Load() {
return false
}
b.bsMu.Lock()
ch := b.bsFrontierCh
b.bsMu.Unlock()
if ch != nil {
select {
case ch <- bsFrontierReply{nodeID: nodeID, tip: containerID}:
default:
}
}
return true
}
// deliverBootstrapAncestors routes an Ancestors reply (framed block batch) to the
// waiting Ancestors call when the bootstrap loop is driving. Returns true iff consumed.
func (b *blockHandler) deliverBootstrapAncestors(requestID uint32, data []byte) bool {
if !b.bsActive.Load() {
return false
}
raw := decodeContextBlocks(data)
b.bsMu.Lock()
ch := b.bsAncestorCh[requestID]
b.bsMu.Unlock()
if ch != nil {
select {
case ch <- raw:
default:
}
}
return true
}
// runBootstrapThenPoll is the chain's startup sync driver (the goroutine buildChain
// launches). It runs INITIAL SYNC to the network frontier with the VM's normal-operation
// transition GATED on that completion (runInitialSync), then — ONLY if the chain actually
// went live — hands off to the live frontier poller (runtime cert-carry catch-up). On
// fail-safe runInitialSync returns false and the poller is NOT started: the chain stays
// not-ready for monitorBootstrap to surface, and the VM stays in Bootstrapping (it never
// serves a stale height). Exits when ctx is done (shutdown). The gating logic lives in
// runInitialSync so it is unit-testable without the blocking poller hand-off.
func (b *blockHandler) runBootstrapThenPoll(ctx context.Context) {
if b.runInitialSync(ctx) {
b.runFrontierPoller(ctx)
}
}
// runInitialSync drives the fetch+execute bootstrap loop to the network frontier and, ON
// SUCCESS, transitions the VM to normal operation (transitionVMReady → vm.Ready), ENDS the
// engine's bootstrap phase (FinishBootstrap — only the α-of-K cert-gate finalizes
// thereafter), and flips bootstrapDone — IN THAT ORDER, so "VM serves / builds" and "engine
// cert-gates finality" go live TOGETHER at the named frontier. Returns true iff the chain
// went live.
//
// THE ORDERING IS THE FIX. Previously buildChain put the VM into normal operation
// UNCONDITIONALLY right after Initialize — at the LOCAL last-accepted height — and ran this
// sync afterward as a detached, non-gating goroutine. A restarted STALE validator therefore
// went live (block building, mempool, validator dispatch via the EVM's
// onNormalOperationsStarted) at its stale height and never converged to the finalized
// frontier. Gating the normal-op transition on reaching the frontier makes "VM live at a
// stale height" structurally impossible: the VM is in Bootstrapping until it has converged.
//
// GO-LIVE includes the CAUGHT-UP path. A tip-holding producer on a mixed-height co-restart names
// no frontier (the responders split below ⅔) yet is at the top of every responder — FrontierTip
// returns its OWN held tip NAMED (BootstrapPolicy.CaughtUp), the loop's Accepted()-shortcut concludes
// caught-up, and bs.Run returns nil → it goes Ready at its own tip. Without that path the producer
// would fail safe DOWN at its own tip (the regression this round fixes), the opposite of the
// stale-go-live bug.
//
// FAIL-SAFE + SELF-HEAL (eclipse / isolation / majority outage). Each bs.Run attempt is internally
// BOUNDED: it WAITS for beacon connectivity (re-sampling every RetryInterval up to ConnectDeadline)
// then returns rather than hanging. A TRANSIENT connectivity fail-safe (ErrBeaconsUnreachable /
// ErrNoBeaconQuorum — the quorum was not reachable this attempt) is RE-ATTEMPTED (bootstrapMaxAttempts
// ≤ 0 ⇒ until the quorum returns or shutdown), with bootstrapConnecting set so monitorBootstrap's
// no-progress watchdog treats it as a deliberate WAIT, not a stall: the node stays in Bootstrapping
// (serving nothing as head, NEVER live at the stale height) and CONVERGES the instant the quorum
// returns — the in-process self-heal the K8s probes do NOT provide (they poll the always-green
// /ext/health/liveness, so a fail-safe-DOWN node is never restarted). A STRUCTURAL failure (deep gap
// → state-sync) or an exhausted attempt bound returns false WITHOUT going Ready, bootstrapFailed
// recording the reason so monitorBootstrap surfaces it. The node NEVER false-completes at its stale
// height, and a transient outage NEVER becomes a permanent brick.
func (b *blockHandler) runInitialSync(ctx context.Context) bool {
if b.engine == nil || b.net == nil || b.msgCreator == nil {
// Degenerate handler (no transport/engine to drive sync): nothing to converge to.
// Go live immediately so a single-node / transport-less chain is not pinned
// unbootstrapped — the same fast-path the no-beacon-set case takes.
if b.engine != nil {
b.engine.FinishBootstrap()
}
if err := b.transitionVMReady(ctx); err != nil {
b.logger.Error("degenerate chain: VM SetState(Ready) failed — NOT marking bootstrapped",
log.Stringer("chainID", b.chainID), log.Err(err))
b.bootstrapFailed.Store(&bsFailure{err: err})
return false
}
b.bootstrapDone.Store(true)
return true
}
bs := chainbootstrap.New(chainbootstrap.Config{
Source: b,
Chain: b,
Log: b.logger,
// Bounded beacon-connect WAIT + re-sample pause (zero ⇒ library defaults 3m / 1s).
// ConnectDeadline is the IN-ATTEMPT retry: bs.Run re-samples the beacon set every
// RetryInterval up to this deadline, converging the instant the beacons return.
ConnectDeadline: b.bootstrapConnectDeadline,
RetryInterval: b.bootstrapRetryInterval,
// Optional operator-pinned weak-subjectivity anchor: the α-agreed frontier must
// descend from this id at this height (defense-in-depth for empty-genesis). Zero
// ⇒ disabled (the beacon + ⅔-stake quorum is the primary anchor).
WeakSubjectivityID: b.wsCheckpointID,
WeakSubjectivityHeight: b.wsCheckpointHeight,
})
// OUTER SELF-HEAL RETRY. A bs.Run attempt that fails because the beacon quorum was UNREACHABLE
// (eclipse / partition / a majority co-restart still in flight) is RE-ATTEMPTED — the node stays
// in Bootstrapping (engine alive, VM serving nothing as head, never live at the stale height)
// and CONVERGES the instant the quorum returns. This is the recovery the K8s probes do NOT
// provide (they all poll the always-green /ext/health/liveness, so a fail-safe-DOWN node is
// never restarted). bootstrapMaxAttempts ≤ 0 ⇒ retry until the quorum returns or shutdown; a
// test pins it to 1 to assert the single-attempt terminal fail-safe. A STRUCTURAL failure (deep
// gap → state-sync) is NOT retried — a retry cannot fix it; it is surfaced for the operator.
var lastErr error
for attempt := 1; ; attempt++ {
b.bsActive.Store(true)
err := bs.Run(ctx)
b.bsActive.Store(false)
if err == nil {
// Reached the frontier (or no beacon set / already at / above the tip — caught up): go
// live. CLOSE the no-cert bootstrap-accept gate FIRST — FinishBootstrap ends
// InBootstrapPhase, so AcceptBootstrapBlock can no longer finalize a block without an
// α-of-K cert — THEN transition the VM to normal operation. Ordering them this way
// (matching the degenerate path above) leaves NO window where the live VM is
// building/serving while the cert-less accept path is still open. FinishBootstrap is
// idempotent and void; if the VM transition then fails we fail safe (record the reason,
// return false). A VM that REFUSES normal-op is a real failure: do NOT mark ready.
b.bootstrapConnecting.Store(false)
b.engine.FinishBootstrap()
if verr := b.transitionVMReady(ctx); verr != nil {
b.logger.Error("chain reached the frontier but VM SetState(Ready) failed — NOT marking bootstrapped",
log.Stringer("chainID", b.chainID), log.Err(verr))
b.bootstrapFailed.Store(&bsFailure{err: verr})
return false
}
b.bootstrapDone.Store(true)
b.logger.Info("chain initial sync complete — VM live (normal operation) at the network frontier",
log.Stringer("chainID", b.chainID))
return true
}
if ctx.Err() != nil {
b.bootstrapConnecting.Store(false)
return false // shutdown — not a bootstrap failure
}
lastErr = err
// Decide whether to RE-ATTEMPT. A transient connectivity fail-safe self-heals when the
// quorum returns (retry); a structural failure or an exhausted attempt bound does not (break
// → surface). While we wait between attempts, mark bootstrapConnecting so the monitor's
// no-progress watchdog treats this as a deliberate quorum WAIT, not a stall — a node failing
// safe DOWN and waiting must not be force-stopped (that is the brick this avoids).
boundReached := b.bootstrapMaxAttempts > 0 && attempt >= b.bootstrapMaxAttempts
if boundReached || !isRetryableBootstrapFailure(err) {
break
}
b.bootstrapConnecting.Store(true)
if perr := b.pauseBootstrapRetry(ctx); perr != nil {
b.bootstrapConnecting.Store(false)
return false // shutdown during the inter-attempt backoff
}
}
// Exhausted the attempt bound, or a structural failure (deep gap / disjoint peer). DO NOT
// transition to normal operation — leaving the VM in Bootstrapping is the fail-safe: it does not
// serve / build at the stale local height (that was the freeze defect). Record the reason so
// monitorBootstrap surfaces it (F5).
b.bootstrapConnecting.Store(false)
b.logger.Warn("chain initial sync did not reach the frontier — VM stays bootstrapping (NOT serving normal-op), failing safe",
log.Stringer("chainID", b.chainID),
log.Err(lastErr))
b.bootstrapFailed.Store(&bsFailure{err: lastErr})
return false
}
// isRetryableBootstrapFailure reports whether a bs.Run fail-safe is a TRANSIENT connectivity
// failure — the beacon quorum was not reachable this attempt — which RECOVERS when the quorum
// returns, so re-attempting bootstrap (the node staying in Bootstrapping meanwhile) self-heals it.
// A STRUCTURAL failure (ErrGapTooLarge needs state-sync; ErrCannotConnect / ErrStalled is a serving
// peer naming a disjoint chain or withholding ancestry) is NOT retried here — a retry cannot fix it;
// it is surfaced (bootstrapFailed → monitorBootstrap) for operator action.
func isRetryableBootstrapFailure(err error) bool {
return errors.Is(err, chainbootstrap.ErrBeaconsUnreachable) ||
errors.Is(err, chainbootstrap.ErrNoBeaconQuorum)
}
// pauseBootstrapRetry backs off one RetryInterval between bootstrap re-attempts (never a hot loop),
// or returns ctx.Err() on shutdown. Mirrors the bootstrapper's own pause so the OUTER self-heal
// retry has the same bounded, shutdown-terminable backoff as the inner connect wait.
func (b *blockHandler) pauseBootstrapRetry(ctx context.Context) error {
d := b.bootstrapRetryInterval
if d <= 0 {
d = time.Second
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(d):
return nil
}
}
// transitionVMReady moves the VM to NORMAL OPERATION (vm.Ready) through the gated callback
// buildChain wired from the VM's SetState. It is the SINGLE place the VM goes live, called
// by runInitialSync ONLY after initial sync has reached the named frontier. No-op when no
// SetState VM is wired (degenerate / test handlers — nothing to transition). Bounded by a 30s
// timeout DERIVED FROM the sync/shutdown ctx (not context.Background): a wedged VM transition
// cannot hang the goroutine past 30s, AND a shutdown that cancels ctx cancels the transition
// immediately instead of blocking the chain teardown for up to 30s.
func (b *blockHandler) transitionVMReady(ctx context.Context) error {
if b.vmReady == nil {
return nil
}
sctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
return b.vmReady(sctx)
}
// decodeContextBlocks extracts the raw block bytes (oldest-first) from a framed
// Ancestors payload — the inverse of GetContext's framing, shared by the bootstrap
// fetch path. Each outer [entryLen:4][entry] entry is either a v2 cert-carrying frame
// (we take just the block) or a legacy raw block (the entry IS the block). Strict: a
// malformed length stops the walk (returns what parsed cleanly so far).
func decodeContextBlocks(data []byte) [][]byte {
var out [][]byte
remaining := data
for len(remaining) >= 4 {
entryLen := int(binary.BigEndian.Uint32(remaining[:4]))
remaining = remaining[4:]
if entryLen <= 0 || entryLen > len(remaining) {
break
}
entry := remaining[:entryLen]
remaining = remaining[entryLen:]
if blockBytes, _, isV2 := decodeCatchupEntry(entry); isV2 {
out = append(out, blockBytes)
} else {
out = append(out, entry)
}
}
return out
}