Files
node/chains/bootstrap_sync_test.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

2035 lines
106 KiB
Go

// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// bootstrap_sync_test.go — node-side proof that the blockHandler's bootstrap wiring
// (BlockSource transport adapter + Chain execute sink) converges a node over the real
// GetAcceptedFrontier/GetAncestors message path, AND — the C1 forged-chain gate — that it
// names the frontier ONLY from a ⅔-by-stake quorum of the configured BEACONS. A
// non-beacon peer (or any sub-⅔-stake set) can never name the frontier, so an
// empty/behind node can only ever sync the canonical (beacon-agreed) chain. The
// fetch+execute and content-addressed-descent ALGORITHMS are proven in consensus
// (engine/chain/bootstrap); here we prove the node TRANSPORT + the frontier QUORUM.
package chains
import (
"context"
"encoding/binary"
"strconv"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
consensusconfig "github.com/luxfi/consensus/config"
consensuschain "github.com/luxfi/consensus/engine/chain"
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/message"
"github.com/luxfi/node/network"
"github.com/luxfi/node/network/peer"
"github.com/luxfi/node/proto/p2p"
"github.com/luxfi/validators"
)
// ----- mock block + VM ------------------------------------------------------
type bsTestBlock struct {
id, parent ids.ID
height uint64
bytes []byte
valid bool
accepts int
}
func (b *bsTestBlock) ID() ids.ID { return b.id }
func (b *bsTestBlock) Parent() ids.ID { return b.parent }
func (b *bsTestBlock) ParentID() ids.ID { return b.parent }
func (b *bsTestBlock) Height() uint64 { return b.height }
func (b *bsTestBlock) Timestamp() time.Time { return time.Unix(int64(b.height), 0) }
func (b *bsTestBlock) Status() uint8 { return 0 }
func (b *bsTestBlock) Verify(context.Context) error {
if !b.valid {
return errBSInvalid
}
return nil
}
func (b *bsTestBlock) Accept(context.Context) error { b.accepts++; return nil }
func (b *bsTestBlock) Reject(context.Context) error { return nil }
func (b *bsTestBlock) Bytes() []byte { return b.bytes }
type bsErr string
func (e bsErr) Error() string { return string(e) }
const (
errBSInvalid = bsErr("invalid block")
errBSNoBuild = bsErr("no build")
errBSNotStored = bsErr("not accepted")
errBSUnknown = bsErr("unknown bytes")
)
// bsTestVM models a node VM with the REAL store-vs-acceptance distinction (the prior mock
// conflated them — GetBlock returned only accepted blocks). It can PARSE any block on the chain
// (the wire codec is deterministic). It GETS a block that is either ACCEPTED or merely STORED (a
// block GOSSIPED into the store ahead of acceptance — the luxd-2 freeze precondition). `accepted`
// is the FINALIZED chain; `acceptedByHeight` is the canonical height→id index the real coreth VM
// maintains (GetBlockIDAtHeight); `stored` holds present-but-unaccepted blocks. SetPreference
// marks a block accepted — the engine calls it right after Accept, so LastAccepted/the index
// advance exactly as the node syncs.
type bsTestVM struct {
all map[ids.ID]*bsTestBlock
byBytes map[string]*bsTestBlock
accepted map[ids.ID]bool // FINALIZED chain
acceptedByHeight map[uint64]ids.ID // canonical height→id index (coreth GetBlockIDAtHeight)
stored map[ids.ID]bool // present in the store but NOT (yet) accepted (gossiped-ahead)
lastAcc ids.ID
// frozenLastAccepted models the REAL ZAP VM client (vms/rpcchainvm/zap/client.go before the
// option-b refresh): a fire-and-forget Accept advances the plugin's on-disk store (so GetBlock /
// the accepted map still advance) but NEVER the cached LastAccepted id, which stays frozen at the
// boot snapshot for the process life. When true, SetPreference advances the store but does NOT
// move lastAcc — the exact freeze red HIGH-1 traced. A node whose bootstrap height/caught-up
// signals read this frozen cache "unfreezes but stalls"; reading the consensus finalized ledger
// instead converges. The mock bsTestVM otherwise advanced lastAcc on SetPreference, which HID the
// bug (units passed while the real client did not advance).
frozenLastAccepted bool
}
func newBSVM(chain []*bsTestBlock) *bsTestVM {
vm := &bsTestVM{
all: map[ids.ID]*bsTestBlock{},
byBytes: map[string]*bsTestBlock{},
accepted: map[ids.ID]bool{},
acceptedByHeight: map[uint64]ids.ID{},
stored: map[ids.ID]bool{},
}
for _, b := range chain {
vm.all[b.id] = b
vm.byBytes[string(b.bytes)] = b
}
// Empty node: only genesis is accepted.
vm.accepted[chain[0].id] = true
vm.acceptedByHeight[chain[0].height] = chain[0].id
vm.lastAcc = chain[0].id
return vm
}
// newBSVMAt seeds the VM with blocks 0..m ACCEPTED (a node already at height m) so a test
// can model a STALE node rather than an empty one. SyncStateFromVM then seeds consensus at
// height m, and the first fetched gap block extends chain[m].
func newBSVMAt(chain []*bsTestBlock, m int) *bsTestVM {
vm := newBSVM(chain)
for i := 1; i <= m; i++ {
vm.accepted[chain[i].id] = true
vm.acceptedByHeight[chain[i].height] = chain[i].id
}
vm.lastAcc = chain[m].id
return vm
}
// store marks blocks PRESENT in the block store WITHOUT accepting them — models blocks GOSSIPED
// into the store ahead of acceptance (the luxd-2 freeze precondition: GetBlock returns them, but
// they are not on the accepted chain and not in the canonical height index).
func (m *bsTestVM) store(blocks ...*bsTestBlock) {
for _, b := range blocks {
m.stored[b.id] = true
}
}
func (m *bsTestVM) BuildBlock(context.Context) (cblock.Block, error) { return nil, errBSNoBuild }
func (m *bsTestVM) GetBlock(_ context.Context, id ids.ID) (cblock.Block, error) {
if m.accepted[id] || m.stored[id] {
return m.all[id], nil
}
return nil, errBSNotStored
}
// GetBlockIDAtHeight is the canonical ACCEPTED height→id index (coreth's authoritative oracle): it
// returns the FINALIZED block at a height, never a stored-but-unaccepted one. ids.Empty when no
// block is accepted at h — exactly how the real VM reports "nothing finalized there".
func (m *bsTestVM) GetBlockIDAtHeight(_ context.Context, h uint64) (ids.ID, error) {
if id, ok := m.acceptedByHeight[h]; ok {
return id, nil
}
return ids.Empty, nil
}
func (m *bsTestVM) ParseBlock(_ context.Context, b []byte) (cblock.Block, error) {
if blk, ok := m.byBytes[string(b)]; ok {
return blk, nil
}
return nil, errBSUnknown
}
func (m *bsTestVM) LastAccepted(context.Context) (ids.ID, error) { return m.lastAcc, nil }
func (m *bsTestVM) SetPreference(_ context.Context, id ids.ID) error {
m.accepted[id] = true
if blk, ok := m.all[id]; ok {
m.acceptedByHeight[blk.height] = id
}
delete(m.stored, id) // accepting a stored-ahead block promotes it onto the finalized chain
if !m.frozenLastAccepted {
m.lastAcc = id
}
// else: model the ZAP client — the plugin (store/accepted above) advances, but the cached
// LastAccepted id stays FROZEN at the boot snapshot for the process life.
return nil
}
// ----- mock outbound message + builder --------------------------------------
type bsOutMsg struct {
op string
blockID ids.ID
requestID uint32
}
func (*bsOutMsg) BypassThrottling() bool { return true }
func (*bsOutMsg) Op() message.Op { return 0 }
func (*bsOutMsg) Bytes() []byte { return nil }
func (*bsOutMsg) BytesSavedCompression() int { return 0 }
type bsMsgBuilder struct{ message.OutboundMsgBuilder }
func (bsMsgBuilder) GetAcceptedFrontier(_ ids.ID, requestID uint32, _ time.Duration) (message.OutboundMessage, error) {
return &bsOutMsg{op: "frontier", requestID: requestID}, nil
}
func (bsMsgBuilder) GetAncestors(_ ids.ID, requestID uint32, _ time.Duration, blockID ids.ID, _ p2p.EngineType) (message.OutboundMessage, error) {
return &bsOutMsg{op: "ancestors", blockID: blockID, requestID: requestID}, nil
}
// ----- beacon-aware mock peer net -------------------------------------------
// bsBeaconNet models a network of BEACONS (each with stake) that hold the chain, plus an
// optional MALICIOUS non-beacon peer that reports/serves a forged chain. The bootstrap
// frontier quorum must name the tip ONLY from the connected beacons, ignoring the
// malicious peer entirely. `connected` is the subset of beacons currently reachable (so a
// test can model "beacons offline, only the attacker is up").
type bsBeaconNet struct {
network.Network
bh *blockHandler
chainID ids.ID
connected []ids.NodeID // beacons that are connected + tracking the chain
byID map[ids.ID]*bsTestBlock // honest chain by id (for ancestry serving)
tip *bsTestBlock // the honest tip the beacons report
serveAncestors bool // beacons serve ancestry (false models name-only beacons)
ancestorsEmpty bool // beacons REPLY to GetAncestors but serve an EMPTY batch (cross-version / withholding)
// emptyResponders models a MIXED fleet on a WIPE-path recovery: the named beacons REPLY to
// GetAncestors with an EMPTY batch (they lack the block — e.g. still at genesis after a
// simultaneous wipe), while the rest serve the real ancestry. When set, the mock delivers the
// empties FIRST, proving the fetcher (blockHandler.Ancestors) skips them and still returns the
// non-empty batch — never starved by a peer that can't serve. Requires serveAncestors=true.
emptyResponders set.Set[ids.NodeID]
// tipFor optionally overrides the tip a specific beacon reports (models DISAGREEMENT —
// beacons connected but split across tips, so no ⅔ quorum forms → FrontierNoQuorum).
tipFor map[ids.NodeID]ids.ID
// connectAfterCalls models the CANARY boot race over the REAL transport: while
// peerInfoCalls ≤ connectAfterCalls the beacons are reported as NOT yet connected (so
// FrontierTip must return FrontierConnecting and the loop WAITS); afterward they connect.
connectAfterCalls int
peerInfoCalls int
// gate models a DYNAMIC eclipse a concurrent test goroutine flips (atomic: PeerInfo runs on the
// bootstrap goroutine). gate==true ⇒ NO beacon is reported connected (the quorum is unreachable),
// the self-heal driver re-attempts; gate==false ⇒ beacons connect and the node converges. Zero
// value (false) is the default (connected), so existing tests are unaffected.
gate atomic.Bool
// Optional malicious NON-beacon peer: connected and tracking, reports a forged tip.
malicious ids.NodeID
forgedTip ids.ID
// tipFor2 + propagateAtHeight model finalization PROPAGATION across the fleet, the faithful
// analog of the connectivity evolution above. A beacon reports tipFor[id] UNTIL the syncing
// node has accepted through propagateAtHeight, then reports tipFor2[id]. This is what
// GetAcceptedFrontier returns in production (chains/manager.go: vm.LastAccepted — the
// last-ACCEPTED block, NEVER an un-finalized preferred tip): on a live chain the laggards'
// last-accepted frontier RATCHETS UP as finalization completes, so by the time a behind node
// has synced to the ⅔-common height the bleeding-edge block is finalized fleet-wide. Modeling
// it this way lets a full-loop test exercise the ancestor-tolerant ⅔-common naming for a BEHIND
// node AND still reach caught-up at the TRUE finalized tip — instead of a STATIC minority-above
// that (post the M1 own-height-exclusion fix) correctly refuses to go-live stale. Zero value
// (nil / 0) disables it, so existing tests are unaffected.
tipFor2 map[ids.NodeID]ids.ID
propagateAtHeight uint64
}
// nodeAcceptedHeight reports the syncing node's current last-ACCEPTED height (0 if unknown) — the
// signal the propagation model keys on, exactly the value chains/manager.go answers GetAcceptedFrontier with.
func (n *bsBeaconNet) nodeAcceptedHeight() uint64 {
if n.bh == nil || n.bh.vm == nil {
return 0
}
id, err := n.bh.vm.LastAccepted(context.Background())
if err != nil || id == ids.Empty {
return 0
}
blk, err := n.bh.vm.GetBlock(context.Background(), id)
if err != nil {
return 0
}
return blk.Height()
}
// PeerInfo returns the connected beacons (and the malicious peer, when present) that
// match the requested filter. The bootstrap path queries PeerInfo(beaconIDs), so only
// beacons in `connected` are returned for it; the malicious peer surfaces only on an
// unfiltered (nil) query — i.e. it is never in the beacon-restricted frontier sample.
// When connectAfterCalls > 0 the beacons are withheld for the first that many calls,
// modeling a beacon set that has not finished its handshake at boot.
//
// TrackedChains models REALITY (network/peer/peer.go): a peer advertises the NETS it tracks
// — always constants.PrimaryNetworkID plus any tracked L1 net IDs — NEVER an individual
// native chain ID. So beacons report the handler's networkID (the NET the beacon set is
// anchored to), the value connectedBeacons now filters on. The pre-fix code filtered on the
// chain ID, which no real peer advertises; the old mock reported set.Of(n.chainID), masking
// that bug. Reporting set.Of(networkID) is what makes the canary-convergence tests meaningful.
func (n *bsBeaconNet) PeerInfo(nodeIDs []ids.NodeID) []peer.Info {
n.peerInfoCalls++
// gate (atomic) is the dynamic eclipse: when set, NO beacon is reported connected this round.
beaconsUp := (n.connectAfterCalls == 0 || n.peerInfoCalls > n.connectAfterCalls) && !n.gate.Load()
trackedNet := n.bh.networkID // the NET real peers advertise (incl. PrimaryNetworkID)
want := map[ids.NodeID]bool{}
for _, id := range nodeIDs {
want[id] = true
}
var out []peer.Info
if beaconsUp {
for _, b := range n.connected {
if len(nodeIDs) == 0 || want[b] {
out = append(out, peer.Info{ID: b, TrackedChains: set.Of(trackedNet)})
}
}
}
if n.malicious != ids.EmptyNodeID && (len(nodeIDs) == 0 || want[n.malicious]) {
out = append(out, peer.Info{ID: n.malicious, TrackedChains: set.Of(trackedNet)})
}
return out
}
func (n *bsBeaconNet) Send(msg message.OutboundMessage, nodeIDs set.Set[ids.NodeID], _ ids.ID, _ uint32) set.Set[ids.NodeID] {
m, ok := msg.(*bsOutMsg)
if !ok {
return nil
}
switch m.op {
case "frontier":
// Each queried beacon answers with the honest tip (or its tipFor override, modeling
// disagreement) — and, once the node has accepted through propagateAtHeight, its tipFor2
// (modeling finalization propagating across the fleet so the bleeding edge resolves).
propagated := n.tipFor2 != nil && n.nodeAcceptedHeight() >= n.propagateAtHeight
for id := range nodeIDs {
tip := n.tip.id
if t, ok := n.tipFor[id]; ok {
tip = t
}
if propagated {
if t, ok := n.tipFor2[id]; ok {
tip = t
}
}
n.bh.deliverBootstrapFrontier(id, tip)
}
// The malicious peer ALSO tries to inject its forged tip (it spams the channel),
// modeling an attacker shouting a frontier. It must be IGNORED (not a beacon).
if n.forgedTip != ids.Empty {
n.bh.deliverBootstrapFrontier(n.malicious, n.forgedTip)
}
case "ancestors":
if n.emptyResponders != nil {
// Mixed fleet: emptyResponders reply EMPTY (they lack the block), the rest serve.
// Deliver the empties FIRST so the test proves Ancestors skips them and returns the
// slower non-empty batch (the WIPE-path ≥2-at-genesis case).
var servers []ids.NodeID
for id := range nodeIDs {
if n.emptyResponders.Contains(id) {
n.bh.deliverBootstrapAncestors(m.requestID, nil)
} else {
servers = append(servers, id)
}
}
for range servers {
n.bh.deliverBootstrapAncestors(m.requestID, n.frame(m.blockID))
}
return nil
}
if n.serveAncestors {
n.bh.deliverBootstrapAncestors(m.requestID, n.frame(m.blockID))
} else if n.ancestorsEmpty {
// The peer REPLIES but serves NOTHING — the cross-version / withholding case the canary
// risked. The descent gets an empty batch and must fail safe (ErrStalled), never silently
// conclude caught-up at the stale height.
n.bh.deliverBootstrapAncestors(m.requestID, nil)
}
}
return nil
}
// frame serves up to 256 blocks ending at blockID, OLDEST-FIRST, in the same outer
// [entryLen:4][entry] framing GetContext produces (entry = encodeCatchupEntry).
func (n *bsBeaconNet) frame(blockID ids.ID) []byte {
tip, ok := n.byID[blockID]
if !ok {
return nil
}
var rev []*bsTestBlock
cur := tip
for i := 0; i < 256; i++ {
rev = append(rev, cur)
if cur.parent == ids.Empty {
break
}
cur = n.byID[cur.parent]
if cur == nil {
break
}
}
var data []byte
for i := len(rev) - 1; i >= 0; i-- { // oldest-first
entry := encodeCatchupEntry(rev[i].bytes, nil)
var lp [4]byte
binary.BigEndian.PutUint32(lp[:], uint32(len(entry)))
data = append(data, lp[:]...)
data = append(data, entry...)
}
return data
}
// buildBSChain builds genesis..N. invalidAt (≥0) marks that height's block invalid.
func buildBSChain(n int, invalidAt int) ([]*bsTestBlock, map[ids.ID]*bsTestBlock) {
chain := make([]*bsTestBlock, 0, n+1)
byID := map[ids.ID]*bsTestBlock{}
var parent ids.ID
for h := 0; h <= n; h++ {
b := &bsTestBlock{
id: ids.GenerateTestID(),
parent: parent,
height: uint64(h),
bytes: []byte("n-blk@" + strconv.Itoa(h) + ":" + ids.GenerateTestID().String()),
valid: h != invalidAt,
}
chain = append(chain, b)
byID[b.id] = b
parent = b.id
}
return chain, byID
}
// newBeaconManager builds a real validators.Manager registering `ids` as equal-weight
// beacons under networkID — the beacon set the frontier quorum is anchored to.
func newBeaconManager(networkID ids.ID, beaconIDs []ids.NodeID, weight uint64) validators.Manager {
mgr := validators.NewManager()
for _, id := range beaconIDs {
_ = mgr.AddStaker(networkID, id, nil, ids.GenerateTestID(), weight)
}
return mgr
}
// newBSEngine builds a fresh K=1 (no-verifier) consensus engine over the mock VM, seeded at
// the VM's genesis (height 0) via SyncStateFromVM — the shared core of the bootstrap test
// handlers. Returns the engine plus fresh chain + network IDs.
func newBSEngine(t *testing.T, vm *bsTestVM) (*consensuschain.Runtime, ids.ID, ids.ID) {
t.Helper()
chainID := ids.GenerateTestID()
networkID := ids.GenerateTestID()
eng := consensuschain.NewRuntime(consensuschain.NetworkConfig{
ChainID: chainID,
NetworkID: networkID,
NodeID: ids.GenerateTestNodeID(),
Logger: log.NewNoOpLogger(),
Params: &consensusconfig.Parameters{K: 1, AlphaPreference: 1, AlphaConfidence: 1, Beta: 1},
VM: vm,
})
if _, _, err := consensuschain.SyncStateFromVM(context.Background(), vm, eng.Transitive); err != nil {
t.Fatalf("seed: %v", err)
}
return eng, chainID, networkID
}
// newBSHandlerAndEngine wires a blockHandler over a fresh engine and the mock VM, seeded at
// genesis — the node-side of an EMPTY node — with `numBeacons` equal-weight beacons registered
// under networkID. Returns the handler, the chainID, and the beacon node IDs.
func newBSHandlerAndEngine(t *testing.T, vm *bsTestVM, numBeacons int) (*blockHandler, ids.ID, []ids.NodeID) {
t.Helper()
eng, chainID, networkID := newBSEngine(t, vm)
beaconIDs := make([]ids.NodeID, numBeacons)
for i := range beaconIDs {
beaconIDs[i] = ids.GenerateTestNodeID()
}
bh := &blockHandler{
logger: log.NewNoOpLogger(),
engine: eng,
vm: vm,
chainID: chainID,
networkID: networkID,
beacons: newBeaconManager(networkID, beaconIDs, 100),
pendingContext: make(map[ids.ID]contextRequest),
bsAncestorCh: make(map[uint32]chan [][]byte),
}
return bh, chainID, beaconIDs
}
// newBSHandlerWeighted wires a blockHandler (as newBSHandlerAndEngine) but with a caller-
// supplied STAKE-WEIGHTED beacon set, modeling a real primary-network validator set where
// producers hold the majority of stake. expectsStakedBeacons is true (these are native-chain
// staked beacons). Returns the handler and its chainID.
func newBSHandlerWeighted(t *testing.T, vm *bsTestVM, weights map[ids.NodeID]uint64) (*blockHandler, ids.ID) {
t.Helper()
eng, chainID, networkID := newBSEngine(t, vm)
mgr := validators.NewManager()
for id, w := range weights {
require.NoError(t, mgr.AddStaker(networkID, id, nil, ids.GenerateTestID(), w))
}
bh := &blockHandler{
logger: log.NewNoOpLogger(),
engine: eng,
vm: vm,
chainID: chainID,
networkID: networkID,
beacons: mgr,
expectsStakedBeacons: true,
pendingContext: make(map[ids.ID]contextRequest),
bsAncestorCh: make(map[uint32]chan [][]byte),
}
return bh, chainID
}
func runBS(t *testing.T, bh *blockHandler) error {
t.Helper()
bh.bsActive.Store(true)
defer bh.bsActive.Store(false)
bs := chainbootstrap.New(chainbootstrap.Config{
Source: bh,
Chain: bh,
Log: log.NewNoOpLogger(),
RetryInterval: 2 * time.Millisecond, // fast re-sample/connect polling in tests
ConnectDeadline: 500 * time.Millisecond, // bound the beacon-connectivity wait in tests
})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return bs.Run(ctx)
}
// TestNodeBootstrap_EmptyNodeConvergesViaTransport: a fresh/empty node (only genesis)
// FETCHES blocks 1..N from a quorum of beacons over the real
// GetAcceptedFrontier/GetAncestors path, re-EXECUTES them, and ACCEPTS up to N.
func TestNodeBootstrap_EmptyNodeConvergesViaTransport(t *testing.T) {
const N = 50
chain, byID := buildBSChain(N, -1)
vm := newBSVM(chain)
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 4)
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons, byID: byID, tip: chain[N], serveAncestors: true}
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
require.NoError(t, runBS(t, bh), "bootstrap loop must converge over the transport")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[N].id, last, "empty node must sync to the beacon-agreed tip (height %d)", N)
require.Equal(t, 1, chain[N].accepts, "tip block must be VM-accepted exactly once")
require.True(t, bh.Accepted(ctx, chain[N].id), "node must hold the tip after sync")
}
// TestNodeBootstrap_WipePath_MixedGenesisPeers_ObtainsAncestry is the regression guard for the
// WIPE-path ≥2-at-genesis stall (deliverable 3). A re-bootstrapping node samples a fleet where
// SOME beacons reply to GetAncestors with an EMPTY batch (they lack the block — still at genesis
// after a simultaneous wipe) while the rest serve the real ancestry, and the empties arrive FIRST.
// With the pre-fix size-1 reply channel + first-reply-wins, the empty reply won the race and the
// good peer's non-empty batch was dropped, so the descent got nothing every round and stalled. The
// fix buffers the whole sample and SKIPS empty batches, returning the first non-empty one — so the
// node obtains ancestry from the peers that CAN serve, even when ≥2 peers are at genesis.
func TestNodeBootstrap_WipePath_MixedGenesisPeers_ObtainsAncestry(t *testing.T) {
const N = 30
chain, byID := buildBSChain(N, -1)
vm := newBSVM(chain)
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 5)
// 2 of 5 beacons are "still at genesis" — they reply EMPTY to GetAncestors. The mock
// delivers those empties FIRST so a first-reply-wins fetcher would be starved.
empties := set.NewSet[ids.NodeID](2)
empties.Add(beacons[0], beacons[1])
bh.net = &bsBeaconNet{
bh: bh, chainID: chainID, connected: beacons, byID: byID, tip: chain[N],
serveAncestors: true, emptyResponders: empties,
}
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
require.NoError(t, runBS(t, bh), "node must converge despite ≥2 peers serving empty ancestry")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[N].id, last, "node must sync to the tip via the peers that CAN serve")
require.True(t, bh.Accepted(ctx, chain[N].id))
}
// TestSampleAncestorBeacons_PrefersAheadBeacons proves change 2: the ancestry sample PREFERS
// beacons the frontier round found genuinely AHEAD (they hold the ancestry), so a re-bootstrapping
// node asks peers that can serve rather than wasting the bounded sample on genesis peers. With more
// connected beacons than the sample size, blind rotation would periodically EXCLUDE any given
// beacon; the preference guarantees the recorded ahead-beacon is ALWAYS sampled.
func TestSampleAncestorBeacons_PrefersAheadBeacons(t *testing.T) {
const numBeacons = 8 // > bootstrapAncestorSample (4), so rotation alone would sometimes miss one
chain, byID := buildBSChain(1, -1)
vm := newBSVM(chain)
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, numBeacons)
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons, byID: byID, tip: chain[1]}
bh.msgCreator = bsMsgBuilder{}
// Record ONE beacon as ahead (the frontier round's output). It must appear in EVERY sample.
ahead := beacons[numBeacons-1]
bh.bsMu.Lock()
bh.bsAheadBeacons = set.Of(ahead)
bh.bsMu.Unlock()
for i := 0; i < 2*numBeacons; i++ {
sample, ok := bh.sampleAncestorBeacons()
require.True(t, ok)
require.LessOrEqual(t, sample.Len(), bootstrapAncestorSample)
require.True(t, sample.Contains(ahead),
"the recorded ahead-beacon must be preferred into every ancestry sample (call %d)", i)
}
}
// TestRED_FrozenVMLastAccepted_ConvergesOffFinalizedLedger is the regression guard for red
// HIGH-1: the convergence-recognition must ride the IN-PROCESS consensus finalized ledger, NOT
// the VM's LastAccepted cache — which the real ZAP client FREEZES at the boot snapshot for the
// process life (a fire-and-forget Accept advances the plugin but never the cache).
//
// THE GAP THE PRIOR MOCK COULD NOT EXPRESS. bsTestVM.SetPreference advanced lastAcc, so every
// unit "passed" while the real client did NOT advance it. Here frozenLastAccepted models the real
// client faithfully: a STALE node boots at M, the descent finalizes M+1..N (the consensus
// finalized ledger AND the store both advance), but VM.LastAccepted stays pinned at M. With the
// pre-fix wiring (lastH / Accepted / BootstrapHeight read the frozen cache) the loop re-descends
// every pass while AcceptBootstrapBlock no-ops each already-finalized block (advanced=false) →
// ErrStalled: "unfreezes but stalls". The fix drives those signals off the finalized ledger (the
// same source AcceptBootstrapBlock's contiguity guard trusts), so the node converges in ONE Run.
//
// RED-before / GREEN-after: with LastAccepted/Accepted/BootstrapHeight reading the frozen cache
// this require.NoError FAILS (runBS returns ErrStalled); reading the finalized ledger it PASSES.
func TestRED_FrozenVMLastAccepted_ConvergesOffFinalizedLedger(t *testing.T) {
const N = 40
const M = 24 // a STALE node booted at height M; the descent must finalize M+1..N
chain, byID := buildBSChain(N, -1)
vm := newBSVMAt(chain, M)
vm.frozenLastAccepted = true // the ZAP freeze: VM.LastAccepted stays pinned at M for the run
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 4)
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons, byID: byID, tip: chain[N], serveAncestors: true}
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
require.NoError(t, runBS(t, bh),
"must converge off the advancing finalized ledger even though VM.LastAccepted is frozen")
// Test premise still holds: the VM cache NEVER moved (faithful to the real ZAP client).
frozen, err := vm.LastAccepted(ctx)
require.NoError(t, err)
require.Equal(t, chain[M].id, frozen,
"premise: VM.LastAccepted must stay frozen at the boot height M — the bug only matters because it does")
// Yet every bootstrap signal advanced to the true frontier N off the consensus finalized ledger.
tip, h, set := bh.engine.FinalizedLedger()
require.True(t, set)
require.Equal(t, chain[N].id, tip, "consensus finalized ledger must have advanced to the frontier")
require.Equal(t, uint64(N), h)
lastID, lastH, err := bh.LastAccepted(ctx)
require.NoError(t, err)
require.Equal(t, chain[N].id, lastID, "Chain.LastAccepted must read the advancing finalized tip, not the frozen cache")
require.Equal(t, uint64(N), lastH)
require.Equal(t, uint64(N), bh.BootstrapHeight(), "BootstrapHeight (monitorBootstrap progress) must advance off the ledger")
require.True(t, bh.Accepted(ctx, chain[N].id), "caught-up oracle must recognize the finalized frontier")
// And a block ABOVE the finalized head is still correctly NOT accepted (the freeze direction).
require.False(t, bh.Accepted(ctx, ids.GenerateTestID()), "an unknown/ahead id must never read as accepted")
}
// TestRED_ForgedFrontierFromNonBeaconRejected is the HEADLINE C1 proof at the node layer.
//
// THE ATTACK (red): an eclipse/malicious peer serves a forged-but-Verify-passing chain
// from genesis and names it as the accepted frontier; the victim, sampling the frontier
// from ANY connected peer and accepting the first reply, finalized the forged chain
// cert-lessly (red's PoC: 40 forged blocks finalized) and bricked against the real chain.
//
// THE FIX: the frontier is named ONLY by a ⅔-by-stake quorum of BEACONS. A non-beacon
// peer is never even queried (PeerInfo is beacon-restricted) and its weight is zero in the
// tally, so the forged tip can never be named. Here the honest beacons are OFFLINE and the
// only reachable peer is the attacker: FrontierTip reports FrontierConnecting (no beacon
// quorum reachable), and — folding in red's LOW — the loop FAILS SAFE
// (ErrBeaconsUnreachable) rather than false-completing at the stale height. ZERO forged
// blocks are finalized either way.
func TestRED_ForgedFrontierFromNonBeaconRejected(t *testing.T) {
const N = 40
honest, honestByID := buildBSChain(N, -1)
forged, _ := buildBSChain(N, -1) // a different valid chain from a fresh genesis
vm := newBSVM(honest)
bh, chainID, _ := newBSHandlerAndEngine(t, vm, 4)
// The 4 beacons exist in the validator set but are OFFLINE (connected = none). The
// only reachable peer is a malicious NON-beacon naming + serving the forged chain.
mal := ids.GenerateTestNodeID()
net := &bsBeaconNet{
bh: bh, chainID: chainID, connected: nil, byID: honestByID, tip: honest[N],
serveAncestors: true, malicious: mal, forgedTip: forged[N].id,
}
bh.net = net
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
// FrontierTip must refuse to name a frontier. With NO beacon connected the status is
// FrontierConnecting (not enough stake up to even ask) — NEVER FrontierNamed. Drive it
// with bsActive so a reply WOULD be routed — proving it is the QUORUM, not an inert
// channel, that rejects the forged frontier.
bh.bsActive.Store(true)
tip, status := bh.FrontierTip(ctx)
bh.bsActive.Store(false)
require.NotEqual(t, chainbootstrap.FrontierNamed, status, "C1: a non-beacon peer must NOT be able to name the frontier")
require.Equal(t, chainbootstrap.FrontierConnecting, status, "no beacon connected → still connecting, not a named frontier")
require.Equal(t, ids.Empty, tip)
// Drive the full loop: with the beacons offline it FAILS SAFE (red's LOW — an eclipsed
// node does NOT false-complete at its stale height) and finalizes NOTHING.
err := runBS(t, bh)
require.ErrorIs(t, err, chainbootstrap.ErrBeaconsUnreachable, "eclipsed node must fail safe, not false-complete")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, honest[0].id, last, "C1: node must stay at genesis — the forged chain was NOT synced")
require.False(t, bh.Accepted(ctx, forged[N].id), "C1: node must NOT hold the forged tip")
for _, f := range forged[1:] {
require.False(t, bh.Accepted(ctx, f.id), "C1: no forged block may be finalized")
}
}
// TestRED_HonestBeaconQuorumIgnoresMaliciousFrontier: with the honest beacons ONLINE, the
// node syncs the canonical chain AND ignores a connected malicious non-beacon that is
// simultaneously shouting a forged frontier — proving the node syncs ONLY the
// beacon-agreed chain even when an attacker is present.
func TestRED_HonestBeaconQuorumIgnoresMaliciousFrontier(t *testing.T) {
const N = 50
honest, honestByID := buildBSChain(N, -1)
forged, _ := buildBSChain(N, -1)
vm := newBSVM(honest)
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 4)
mal := ids.GenerateTestNodeID()
bh.net = &bsBeaconNet{
bh: bh, chainID: chainID, connected: beacons, byID: honestByID, tip: honest[N],
serveAncestors: true, malicious: mal, forgedTip: forged[N].id,
}
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
require.NoError(t, runBS(t, bh))
last, _ := vm.LastAccepted(ctx)
require.Equal(t, honest[N].id, last, "node must sync the beacon-agreed canonical tip")
require.False(t, bh.Accepted(ctx, forged[N].id), "node must ignore the malicious forged frontier")
}
// TestRED_SubQuorumCannotNameFrontier: a minority of configured beacons cannot name the
// frontier — the partition-capture floor (INVARIANT 2). This REPLACES the prior
// SubAlphaStakeCannotNameFrontier, which asserted the ⅔-of-CURRENT-TOTAL-STAKE connect gate that
// was THE MASS-RECOVERY BUG: it required ⅔ of the WHOLE validator set to be connected, which is
// unsatisfiable when the down validators ARE the recovery targets. Under the BootstrapTrust
// policy the floor is MinResponses (a count of authenticated CONFIGURED beacons that must
// respond), defaulting to a MAJORITY of the set — so an attacker who partitions the node down to
// a minority of beacons still cannot capture the frontier, while a recovering node only needs a
// majority (not ⅔ of total stake) of beacons reachable. The C1 "minority cannot name" property is
// preserved; only the floor changed from ⅔-of-total-stake to a response COUNT over RESPONDERS.
func TestRED_SubQuorumCannotNameFrontier(t *testing.T) {
const N = 30
chain, byID := buildBSChain(N, -1)
vm := newBSVM(chain)
// 6 beacons, equal weight. Default MinResponses = majority(6) = 4. Below 4 responders → no
// trusted quorum (capture-prevented); at/above 4 with ⅔-of-responders agreement → named.
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 6)
bh.msgCreator = bsMsgBuilder{}
bh.bsActive.Store(true) // so frontier replies route to the policy decision
defer bh.bsActive.Store(false)
ctx := context.Background()
// Only 3 of 6 connected (3 < the majority floor of 4) → fewer than MinResponses configured
// beacons responded → CONNECTING (wait for more; never name from the captured minority, never
// conclude caught-up at the stale height).
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons[:3], byID: byID, tip: chain[N]}
tip, status := bh.FrontierTip(ctx)
require.Equal(t, chainbootstrap.FrontierConnecting, status, "3/6 beacons (below the majority response floor) cannot name the frontier — still connecting")
require.Equal(t, ids.Empty, tip)
// 4 of 6 connected (= the majority floor) and all agreeing → the policy names the tip. Note
// the OLD ⅔-of-total gate would have REJECTED this (4*100 = 400 is NOT > the 400 floor) — that
// rejection was the deadlock; the response-floor model correctly accepts it.
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons[:4], byID: byID, tip: chain[N]}
tip, status = bh.FrontierTip(ctx)
require.Equal(t, chainbootstrap.FrontierNamed, status, "4/6 beacons (≥ the majority floor) all agreeing must name the frontier")
require.Equal(t, chain[N].id, tip)
}
// TestNodeBootstrap_StaleNodeWaitsForBeaconConnect REPRODUCES THE MAINNET CANARY over the
// REAL GetAcceptedFrontier/GetAncestors transport. A STALE node at height M boots while its
// beacons are still handshaking: the first PeerInfo polls report NO beacon connected, so
// FrontierTip returns FrontierConnecting and the loop WAITS — it must NOT conclude caught-up
// at M (the canary bug: luxd-2 declared "bootstrap complete" at its stale height 1082780
// BEFORE the beacons connected, then could never pull the gap). Once the beacons connect, a
// ⅔ quorum names tip N and the node descends, executes the gap, and converges to N.
func TestNodeBootstrap_StaleNodeWaitsForBeaconConnect(t *testing.T) {
const N = 40 // beacon-named frontier height (the producers)
const M = 23 // our STALE local height (gap N-M = 17 — the canary's gap-17, within window)
chain, byID := buildBSChain(N, -1)
vm := newBSVMAt(chain, M) // STALE: already accepted 0..M
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 4)
net := &bsBeaconNet{
bh: bh, chainID: chainID, connected: beacons, byID: byID, tip: chain[N],
serveAncestors: true,
connectAfterCalls: 4, // beacons finish handshaking only after the 4th PeerInfo poll
}
bh.net = net
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
require.NoError(t, runBS(t, bh), "stale node must converge once beacons connect")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[N].id, last, "CANARY: stale node must converge to the beacon frontier N=%d (NOT false-complete at the stale M=%d)", N, M)
require.True(t, bh.Accepted(ctx, chain[N].id), "node must hold the beacon tip after sync")
require.Greater(t, net.peerInfoCalls, net.connectAfterCalls, "the loop must have WAITED through the connecting passes before naming the frontier")
}
// TestNodeBootstrap_BeaconsSplitNoQuorum: the beacons ARE connected (enough stake to ASK) but
// are split 3/3 across two INCOMPATIBLE chains (separate geneses — a genuine partition, no
// shared ancestor), so neither half holds ⅔ and there is NO ⅔-backed COMMON committed block.
// FrontierTip reports FrontierNoQuorum and the loop fails safe (proven instantly at the
// consensus layer by TestLoop_BeaconsConnectedNoQuorum_FailsSafe).
//
// This is the genuine-partition guard for the ancestor-tolerant tally: ancestor tolerance names
// the highest block a ⅔-by-stake supermajority SHARES, so it must NOT manufacture a quorum here.
// The beacons serve their ancestry (serveAncestors), so the tally actually fetches and walks
// both halves — and STILL finds no block with ⅔ behind it, because the two halves share no
// ancestor. A ±1-block bleeding-edge split (one shared chain) converges; a real fork does not.
func TestNodeBootstrap_BeaconsSplitNoQuorum(t *testing.T) {
const N = 30
chain, byID := buildBSChain(N, -1)
other, _ := buildBSChain(N, -1) // a DISJOINT chain (fresh genesis) — no common ancestor
vm := newBSVMAt(chain, 10)
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 6)
// 6 connected (600 > 400 floor → enough to ASK), split 3/3 so neither chain holds ⅔ and the
// two halves share no ancestor: no ⅔-backed common block exists.
tipFor := map[ids.NodeID]ids.ID{}
for _, id := range beacons[3:] {
tipFor[id] = other[N].id
}
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons, byID: byID, tip: chain[N], tipFor: tipFor, serveAncestors: true}
bh.msgCreator = bsMsgBuilder{}
bh.bsActive.Store(true)
tip, status := bh.FrontierTip(context.Background())
bh.bsActive.Store(false)
require.Equal(t, chainbootstrap.FrontierNoQuorum, status, "disjoint split (no ⅔-backed common ancestor) → FrontierNoQuorum, never caught-up")
require.Equal(t, ids.Empty, tip)
}
// TestNodeBootstrap_NoBeaconSet_ReportsNoBeacons: a node with NO beacon set configured
// (single-node / dev / --skip-bootstrap) reports FrontierNoBeacons, so the loop completes
// rather than hanging on a quorum that can never form.
func TestNodeBootstrap_NoBeaconSet_ReportsNoBeacons(t *testing.T) {
const N = 10
chain, byID := buildBSChain(N, -1)
vm := newBSVM(chain)
bh, chainID, _ := newBSHandlerAndEngine(t, vm, 0) // zero beacons registered
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, byID: byID, tip: chain[N]}
bh.msgCreator = bsMsgBuilder{}
tip, status := bh.FrontierTip(context.Background())
require.Equal(t, chainbootstrap.FrontierNoBeacons, status, "no beacon set → single-node/dev → NoBeacons (the loop completes, no hang)")
require.Equal(t, ids.Empty, tip)
}
// TestNodeBootstrap_FreshNet_SelfVoteUnderFullConnectivity_CaughtUp is THE FRESH-NET FIX. The node
// is itself a beacon (selfNodeID) on a 2-validator set; on a fresh net both hold only genesis. The
// PEER-ONLY responder weight (100) is BELOW the stake-majority naming floor (200/2+1 = 101), so the
// node could NEVER name a frontier and hung in FrontierConnecting forever — "bootstrap waiting for
// beacon connectivity" — DESPITE the whole set being connected. With the self-vote, under FULL
// connectivity (the one other beacon reached) the full set PLUS the node itself unanimously hold
// genesis with nobody ahead → FrontierCaughtUp, and the loop completes at the node's own genesis tip.
func TestNodeBootstrap_FreshNet_SelfVoteUnderFullConnectivity_CaughtUp(t *testing.T) {
const N = 5
chain, byID := buildBSChain(N, -1)
vm := newBSVM(chain) // the node is at genesis (lastAccepted = genesis)
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 2) // 2 equal-weight (100) beacons
bh.selfNodeID = beacons[0] // THIS node is beacon 0 — it is itself a validator
bh.msgCreator = bsMsgBuilder{}
// FULL connectivity: the ONE other beacon is connected and reports GENESIS (a fresh net — every
// validator holds only genesis). connected EXCLUDES self (a node is never one of its own peers).
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons[1:], byID: byID, tip: chain[0]}
bh.bsActive.Store(true)
tip, status := bh.FrontierTip(context.Background())
bh.bsActive.Store(false)
require.Equal(t, chainbootstrap.FrontierCaughtUp, status,
"fresh net, FULL set + self all at genesis, peer-only weight below the naming floor → CAUGHT UP (the fix), never a FrontierConnecting hang")
require.Equal(t, chain[0].id, tip, "caught up at the node's OWN genesis tip")
}
// TestNodeBootstrap_SelfVote_PeerAheadDefeatsCaughtUp proves the self-vote NEVER false-completes a
// genuinely BEHIND node. Same 2-beacon set with the node a beacon and FULL connectivity, but the
// peer is AHEAD at N (an EXISTING net the empty node must SYNC to). A responder is above the node's
// height, so CaughtUp(self+peers) is false → NOT FrontierCaughtUp: the node keeps waiting/syncing,
// never goes live at its stale genesis. (The luxfi/consensus loop then descends once a frontier is
// named, or fails safe.)
func TestNodeBootstrap_SelfVote_PeerAheadDefeatsCaughtUp(t *testing.T) {
const N = 20
chain, byID := buildBSChain(N, -1)
vm := newBSVM(chain) // node at genesis
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 2)
bh.selfNodeID = beacons[0]
bh.msgCreator = bsMsgBuilder{}
// FULL connectivity, but the peer reports the AHEAD tip N — the node is genuinely behind.
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons[1:], byID: byID, tip: chain[N], serveAncestors: true}
bh.bsActive.Store(true)
_, status := bh.FrontierTip(context.Background())
bh.bsActive.Store(false)
require.NotEqual(t, chainbootstrap.FrontierCaughtUp, status,
"a peer AHEAD must defeat the self-vote — the node is behind and must sync, never false-complete caught-up at genesis")
}
// TestNodeBootstrap_SelfVote_PartialConnectivityFailsSafe proves the self-vote is SAFE against an
// eclipse. The node is a beacon on a 3-validator set, but only ONE of the two OTHER beacons is
// connected (NOT full connectivity). Even though that one + self report genesis, a SUPPRESSED beacon
// could be hiding an ahead-tip — so the self-vote must NOT apply. fullyConnectedBeacons is false →
// the node falls back to the partition-capture floor and reports FrontierConnecting (fail safe),
// exactly as before the fix. self can never tip a PARTIAL view into a false caught-up.
func TestNodeBootstrap_SelfVote_PartialConnectivityFailsSafe(t *testing.T) {
const N = 5
chain, byID := buildBSChain(N, -1)
vm := newBSVM(chain) // node at genesis
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 3) // self + 2 others
bh.selfNodeID = beacons[0]
bh.msgCreator = bsMsgBuilder{}
// ECLIPSE: only ONE of the two OTHER beacons is connected (beacons[1]); beacons[2] is suppressed.
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons[1:2], byID: byID, tip: chain[0]}
bh.bsActive.Store(true)
_, status := bh.FrontierTip(context.Background())
bh.bsActive.Store(false)
require.Equal(t, chainbootstrap.FrontierConnecting, status,
"partial connectivity (an eclipse could hide an ahead-tip) → the self-vote must NOT apply → fail safe, NOT caught up")
}
// TestRED_PeersTrackNetNotChain_StaleNodeConverges is THE MAINNET-CANARY (luxd-2) intended
// SUCCESS and the regression guard for the beacon-connectivity bug.
//
// THE BUG (canary): the beacon set IS the stake-weighted primary-network validator set, but
// connectedBeacons filtered connected peers by p.TrackedChains.Contains(chainID). Real peers
// advertise the NETS they track (always constants.PrimaryNetworkID + tracked L1 net IDs),
// NEVER an individual native chain ID — so the filter matched ZERO peers, connectedStake
// stayed 0, FrontierTip reported FrontierConnecting forever, and the healthy stale node failed
// safe at the connect deadline despite the ⅔-stake producers being connected and right there.
//
// THE FIX: filter on the NET (b.networkID). Here 3 producers hold > ⅔ of stake and — like
// every real peer — advertise the NET (the handler's networkID), not the chain ID. The stale
// node counts their stake, names the frontier, and converges. C1 is preserved: a peer is
// still counted only if it is in the staked set (weights), and the ⅔-by-stake threshold and
// content-addressed descent are unchanged.
func TestRED_PeersTrackNetNotChain_StaleNodeConverges(t *testing.T) {
const N = 40 // producer (beacon-named) frontier height
const M = 23 // our stale local height (gap-17, within the window) — the canary's gap
chain, byID := buildBSChain(N, -1)
vm := newBSVMAt(chain, M)
p1, p2, p3 := ids.GenerateTestNodeID(), ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
o1, o2 := ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
// Producers 100 each (300), two lighter validators 25 each (50): total 350, ⅔ floor = 233.
// The 3 producers (300) clear it; any 2 of them (200) do not — the quorum genuinely needs
// the producers' ⅔-stake supermajority, exactly the mainnet shape.
weights := map[ids.NodeID]uint64{p1: 100, p2: 100, p3: 100, o1: 25, o2: 25}
bh, chainID := newBSHandlerWeighted(t, vm, weights)
// Only the 3 producers are connected (the 2 lighter validators are offline). They track the
// NET (the handler's networkID), not the chain — modeling real peer.Info.TrackedChains.
bh.net = &bsBeaconNet{
bh: bh, chainID: chainID, connected: []ids.NodeID{p1, p2, p3},
byID: byID, tip: chain[N], serveAncestors: true,
}
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
// Pin the fix at the unit level: the producers ARE recognized as connected beacons because
// they track the NET. Pre-fix (filtering on chainID) this returned EMPTY → connectedStake 0.
w, _, ok := bh.beaconWeights()
require.True(t, ok, "staked beacon set must be present")
require.ElementsMatch(t, []ids.NodeID{p1, p2, p3}, bh.connectedBeacons(w),
"producers tracking the NET must be recognized as connected beacons (the canary fix)")
// FrontierTip names the frontier from the connected ⅔-stake producers.
bh.bsActive.Store(true)
tip, status := bh.FrontierTip(ctx)
bh.bsActive.Store(false)
require.Equal(t, chainbootstrap.FrontierNamed, status,
"3 producers holding > ⅔ stake, connected + tracking the NET, MUST name the frontier")
require.Equal(t, chain[N].id, tip)
// And the full loop converges to N (NOT false-complete at the stale M).
require.NoError(t, runBS(t, bh), "stake-weighted stale node must converge")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[N].id, last,
"CANARY SUCCESS: stale node converges to the producer frontier N=%d, not stuck at M=%d", N, M)
require.True(t, bh.Accepted(ctx, chain[N].id), "node must hold the producer tip after sync")
}
// TestRED_EmptyStakedSetFailsSafe covers EDGE (4): a native chain whose STAKED validator set
// is empty/unqueryable (not yet loaded by the P-chain, or misconfigured) must WAIT then FAIL
// SAFE — it must NOT conclude caught-up at its stale height, and must NOT fall back to trusting
// a connected non-staked endpoint peer (which would reopen C1). The expectsStakedBeacons flag
// is the discriminator: with it set, an empty set ⇒ FrontierConnecting (wait → fail safe);
// the SAME empty set WITHOUT the flag (single-node / P-chain endpoint-only) ⇒ FrontierNoBeacons
// (complete) — see TestNodeBootstrap_NoBeaconSet_ReportsNoBeacons.
func TestRED_EmptyStakedSetFailsSafe(t *testing.T) {
const N = 20
chain, byID := buildBSChain(N, -1)
vm := newBSVMAt(chain, 5) // stale at height 5
// EMPTY staked set + expectsStakedBeacons (native non-platform chain on a real network).
bh, chainID := newBSHandlerWeighted(t, vm, map[ids.NodeID]uint64{})
require.True(t, bh.expectsStakedBeacons)
// A connected NON-beacon endpoint peer that names + serves a tip (forged, in general). It is
// NOT in the staked set, so it must NEVER be trusted to name the frontier.
endpoint := ids.GenerateTestNodeID()
bh.net = &bsBeaconNet{
bh: bh, chainID: chainID, connected: nil, byID: byID, tip: chain[N],
serveAncestors: true, malicious: endpoint, forgedTip: chain[N].id,
}
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
bh.bsActive.Store(true)
tip, status := bh.FrontierTip(ctx)
bh.bsActive.Store(false)
require.Equal(t, chainbootstrap.FrontierConnecting, status,
"empty staked set (beacons expected) → Connecting (wait→fail safe), NOT NoBeacons (false-complete) or Named (endpoint trust)")
require.Equal(t, ids.Empty, tip)
// Discriminator check: the SAME empty set on a node that does NOT expect staked beacons
// (single-node / P-chain endpoint-only) reports NoBeacons (legitimately "nothing to sync to").
bh.expectsStakedBeacons = false
_, status = bh.FrontierTip(ctx)
require.Equal(t, chainbootstrap.FrontierNoBeacons, status,
"empty set WITHOUT a staked-beacon expectation → NoBeacons (single-node), proving the flag is the discriminator")
bh.expectsStakedBeacons = true
// The full loop fails safe — no endpoint trust, nothing finalized past the stale height.
err := runBS(t, bh)
require.ErrorIs(t, err, chainbootstrap.ErrBeaconsUnreachable,
"empty staked set must fail safe at the connect deadline, never trust endpoints")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[5].id, last, "node must stay at its stale height — no endpoint-named frontier adopted")
require.False(t, bh.Accepted(ctx, chain[N].id), "no frontier tip may be adopted from a non-staked endpoint")
}
// TestRED_TipSplitConvergesToCommonHeight is THE MAINNET CANARY (luxd-2 on v1.30.78) the
// ancestor-tolerant frontier quorum fixes — restated FAITHFULLY to the responder semantics
// (chains/manager.go answers GetAcceptedFrontier with vm.LastAccepted — the last-ACCEPTED block,
// NEVER an un-finalized preferred tip). The 3 producers hold > ⅔ stake but are SPLIT at the
// bleeding edge of ACTIVE finalization: producer luxd-3 finalized block TOP first, while luxd-1/2
// are momentarily one ACCEPTED block behind at N (TOP's parent). A behind node at M must (1) name
// the ⅔-COMMON committed height N via ancestor tolerance even though no single tip holds ⅔, and
// (2) RATCHET on to the true finalized tip TOP as finalization propagates across the fleet —
// ending caught-up at TOP, never stuck at M, never falsely caught-up below the network frontier.
//
// THE NAMING BUG this guards: the EXACT-tip tally required ⅔-by-stake to report the IDENTICAL id.
// N drew only luxd-1/2 (200 of 300; the floor is `> 200`, so 200 does NOT clear it) and TOP drew
// only luxd-3 (100) — neither cleared ⅔, so FrontierTip returned NoQuorum and the healthy stale
// node failed safe at its stale height. Ancestor tolerance fixes it: a beacon reporting TOP also
// has N in its accepted chain, so N draws ALL THREE producers (300 > 200) and is NAMED.
//
// WHY PROPAGATION, not a STATIC minority-above: a beacon never reports an UN-finalized tip
// (manager.go: vm.LastAccepted), so a PERMANENT "2 at N, 1 at N+1" split on an idle chain cannot
// occur — luxd-3 reporting TOP means TOP is FINALIZED for it, and on a live chain luxd-1/2's
// last-accepted ratchets to TOP too. Modeling it static would assert a node going Ready while a
// genuinely-finalized higher tip is reported — a gap-1 instance of the M1 stale-go-live the
// own-height-exclusion fix closes. The faithful model converges to the TRUE frontier TOP.
func TestRED_TipSplitConvergesToCommonHeight(t *testing.T) {
const M = 23 // our STALE local height (gap-17 — the canary's gap, within the window)
const N = 40 // the ⅔-agreed COMMON committed height (all 3 producers have it accepted)
const top = 41 // luxd-3 finalized this first; luxd-1/2 ratchet to it as finalization propagates
chain, byID := buildBSChain(top, -1)
vm := newBSVMAt(chain, M)
p1, p2, p3 := ids.GenerateTestNodeID(), ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
weights := map[ids.NodeID]uint64{p1: 100, p2: 100, p3: 100} // total 300, ⅔ floor = 200 (need > 200)
bh, chainID := newBSHandlerWeighted(t, vm, weights)
// luxd-3 reports the true finalized tip TOP throughout; luxd-1/2 report their last-accepted N
// UNTIL the node has accepted through N, then ratchet to TOP (finalization propagated). So while
// the node is BEHIND it sees {N, N, TOP} (ancestor tolerance names the ⅔-common N), and once it
// reaches N it sees {TOP, TOP, TOP} (names TOP, the true frontier).
bh.net = &bsBeaconNet{
bh: bh, chainID: chainID, connected: []ids.NodeID{p1, p2, p3},
byID: byID, tip: chain[top], serveAncestors: true,
tipFor: map[ids.NodeID]ids.ID{p1: chain[N].id, p2: chain[N].id},
tipFor2: map[ids.NodeID]ids.ID{p1: chain[top].id, p2: chain[top].id},
propagateAtHeight: N,
}
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
// Sanity: 2-of-3 producers (200) is exactly the floor, NOT above it — the exact-tip tally
// genuinely cannot name N. The fix must come from ancestor tolerance, not a looser floor.
require.Equal(t, uint64(200), consensusconfig.TwoThirdsStakeFloor(300))
// BEHIND-NODE NAMING (node at M, pre-propagation sees {N, N, TOP}): ancestor tolerance names the
// ⅔-COMMON N — NOT NoQuorum, NOT the lone-producer TOP (only 100, sub-⅔). This is the canary fix.
bh.bsActive.Store(true)
tip, status := bh.FrontierTip(ctx)
bh.bsActive.Store(false)
require.Equal(t, chainbootstrap.FrontierNamed, status,
"CANARY: a ±1-block tip split must name the ⅔-COMMON height, not return NoQuorum")
require.Equal(t, chain[N].id, tip, "must name N (the height all 3 producers share), NOT the minority TOP")
require.NotEqual(t, chain[top].id, tip, "the lone producer's bleeding-edge TOP must NOT be named while sub-⅔")
// FULL LOOP: the node ratchets M → N (ancestor-tolerant ⅔-common) → TOP (the true finalized tip,
// once finalization propagated to the fleet). It converges to TOP — never stuck at M, never
// falsely caught-up at the intermediate N while a genuinely-finalized TOP is the frontier.
require.NoError(t, runBS(t, bh), "tip-split stale node must converge to the true finalized frontier")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[top].id, last,
"CANARY SUCCESS: ratchet through the ⅔-common N to the true finalized tip TOP=%d (NOT stuck at M=%d)", top, M)
require.True(t, bh.Accepted(ctx, chain[N].id), "node must hold the ⅔-common N it passed through")
require.True(t, bh.Accepted(ctx, chain[top].id), "node must hold the true finalized tip TOP after ratcheting to it")
}
// TestRED_ForgedHighTipFromMinorityNotNamed is the C1 proof for the ancestor-tolerant path: a
// Byzantine BEACON (in the staked set, but < ⅓ stake) reports a FORGED block built directly on
// the real ⅔-backed block N — a forged sibling of the real bleeding-edge TOP. Ancestor tolerance
// must NOT name the forged block (it holds only the Byzantine minority's stake) through the ENTIRE
// ratchet: it names the real ⅔-common N while the node is behind, then the true finalized TOP as
// finalization propagates, and the node must never sync or hold the forged block at any point.
//
// This is the adversarial heart of C1 under the agreement relation: even a forged block that
// descends from a genuinely ⅔-backed block only RATIFIES that real ancestor (which the honest ⅔
// already back) — it can never name ITSELF, because naming requires > ⅔ of the TOTAL staked
// weight behind the block, and the forger holds < ⅓. (Faithful responder model — manager.go:
// vm.LastAccepted: the honest laggards' last-accepted ratchets to the real TOP as finalization
// propagates, while the Byzantine beacon keeps lying with its forged sibling throughout.)
func TestRED_ForgedHighTipFromMinorityNotNamed(t *testing.T) {
const M = 23 // stale local height
const N = 40 // the ⅔-common committed height the honest laggards share
const top = 41 // the real bleeding-edge tip luxd-3 finalized first; the laggards ratchet to it
chain, byID := buildBSChain(top, -1)
vm := newBSVMAt(chain, M)
// A forged block at height TOP whose parent is the REAL N (a forged sibling of the real TOP).
// The node can PARSE it (any structurally-valid block parses) but has NOT accepted it — model
// that by adding it to the VM's parse maps but not to `accepted`.
forged := &bsTestBlock{
id: ids.GenerateTestID(), parent: chain[N].id, height: top,
bytes: []byte("forged-sibling@" + strconv.Itoa(top) + ":" + ids.GenerateTestID().String()),
valid: true,
}
byID[forged.id] = forged
vm.all[forged.id] = forged
vm.byBytes[string(forged.bytes)] = forged
p1, p2, p3 := ids.GenerateTestNodeID(), ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
byz := ids.GenerateTestNodeID()
// 4 beacons @100 → total 400, ⅔ floor = 266. Honest laggards p1,p2 at N (200) ratcheting to
// TOP; honest p3 at the real TOP (100); Byzantine byz at the FORGED sibling (100), forever.
// While behind: no tip clears 266 → ancestor path names the ⅔-common N (forged only ratifies it).
weights := map[ids.NodeID]uint64{p1: 100, p2: 100, p3: 100, byz: 100}
bh, chainID := newBSHandlerWeighted(t, vm, weights)
bh.net = &bsBeaconNet{
bh: bh, chainID: chainID, connected: []ids.NodeID{p1, p2, p3, byz},
byID: byID, tip: chain[top], serveAncestors: true,
// p3 reports the real TOP (default tip); p1,p2 report N then ratchet to TOP; byz lies (forged) throughout.
tipFor: map[ids.NodeID]ids.ID{p1: chain[N].id, p2: chain[N].id, byz: forged.id},
tipFor2: map[ids.NodeID]ids.ID{p1: chain[top].id, p2: chain[top].id},
propagateAtHeight: N,
}
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
// BEHIND-NODE NAMING (node at M, pre-propagation sees {N, N, realTOP, forgedTOP}): ancestor
// tolerance names the ⅔-common N — the forged sibling (100) and the real TOP (100) are both
// sub-⅔ and NEITHER is named. C1 in the ancestor-tolerant tally.
bh.bsActive.Store(true)
tip, status := bh.FrontierTip(ctx)
bh.bsActive.Store(false)
require.Equal(t, chainbootstrap.FrontierNamed, status, "the honest ⅔-common N must still be named")
require.Equal(t, chain[N].id, tip, "C1: the named frontier is the real ⅔-common N")
require.NotEqual(t, forged.id, tip, "C1: the Byzantine-minority FORGED block must NEVER be named")
require.NotEqual(t, chain[top].id, tip, "the real bleeding-edge TOP is not named either while sub-⅔")
// FULL LOOP: the node ratchets M → N → real TOP; the forged sibling is NEVER named or held at any step.
require.NoError(t, runBS(t, bh), "node converges to the real finalized frontier, never the forged tip")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[top].id, last, "C1: node syncs to the real TOP (the true finalized frontier)")
require.False(t, bh.Accepted(ctx, forged.id), "C1: the forged block must NOT be finalized — at any point in the ratchet")
require.True(t, bh.Accepted(ctx, chain[N].id), "node holds the real ⅔-common N it passed through")
}
// TestNodeBootstrap_ExactQuorumUsesFastPath is the no-regression proof: when a ⅔-by-stake
// supermajority DOES report the IDENTICAL tip, FrontierTip names it via the EXACT fast path —
// with NO ancestry fetch. The beacons do NOT serve ancestry (serveAncestors=false); the tip is
// still named, which is only possible if the exact fast path returned before any fetch (a
// fetch would have found nothing and yielded NoQuorum). It also returns promptly.
func TestNodeBootstrap_ExactQuorumUsesFastPath(t *testing.T) {
const N = 30
chain, byID := buildBSChain(N, -1)
vm := newBSVMAt(chain, 10)
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 4) // 4 @100 = 400, floor 266; all agree → exact
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons, byID: byID, tip: chain[N], serveAncestors: false}
bh.msgCreator = bsMsgBuilder{}
bh.bsActive.Store(true)
start := time.Now()
tip, status := bh.FrontierTip(context.Background())
elapsed := time.Since(start)
bh.bsActive.Store(false)
require.Equal(t, chainbootstrap.FrontierNamed, status, "unanimous ⅔ must name the exact tip with no fetch")
require.Equal(t, chain[N].id, tip)
require.Less(t, elapsed, bootstrapNamingTimeout, "the exact fast path must not enter the ancestry-fetch resolution")
}
// TestNodeBootstrap_InvalidBlockHaltsTransport: beacons serve a corrupt block at height
// bad; the node accepts 1..bad-1 then STOPS — it never advances past the unverifiable
// block (the safety property, over the real transport, under a valid beacon frontier).
func TestNodeBootstrap_InvalidBlockHaltsTransport(t *testing.T) {
const N = 40
const bad = 25
chain, byID := buildBSChain(N, bad)
vm := newBSVM(chain)
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 4)
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons, byID: byID, tip: chain[N], serveAncestors: true}
bh.msgCreator = bsMsgBuilder{}
bh.bsActive.Store(true)
bs := chainbootstrap.New(chainbootstrap.Config{Source: bh, Chain: bh, Log: log.NewNoOpLogger(), RetryInterval: time.Millisecond})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = bs.Run(ctx) // stalls (cannot pass the invalid block)
bh.bsActive.Store(false)
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[bad-1].id, last, "sync must halt at the block below the invalid one (height %d)", bad-1)
}
// TestDecodeContextBlocks_RoundTrip: the bootstrap framing decode recovers exactly the
// block bytes GetContext framed, oldest-first, for both v2 (cert-carrying) and legacy
// entries.
func TestDecodeContextBlocks_RoundTrip(t *testing.T) {
want := [][]byte{[]byte("oldest"), []byte("mid"), []byte("newest")}
var data []byte
for i, bz := range want {
var entry []byte
if i == 1 {
entry = bz // legacy raw entry (no magic) — decode must treat the entry AS the block
} else {
entry = encodeCatchupEntry(bz, nil) // v2 frame, empty cert
}
var lp [4]byte
binary.BigEndian.PutUint32(lp[:], uint32(len(entry)))
data = append(data, lp[:]...)
data = append(data, entry...)
}
got := decodeContextBlocks(data)
require.Equal(t, want, got, "decodeContextBlocks must recover the framed blocks oldest-first")
}
// TestDeliverBootstrap_GatedByActive: the reply hooks deliver ONLY while the bootstrap
// loop is driving (bsActive). When inactive they return false so the live cert/vote
// path runs unchanged. The frontier reply now carries the responding beacon's nodeID.
func TestDeliverBootstrap_GatedByActive(t *testing.T) {
bh := &blockHandler{bsAncestorCh: make(map[uint32]chan [][]byte)}
// Inactive: both hooks are inert (return false → caller uses the live path).
require.False(t, bh.deliverBootstrapFrontier(ids.GenerateTestNodeID(), ids.GenerateTestID()))
require.False(t, bh.deliverBootstrapAncestors(1, nil))
// Active: a registered FrontierTip channel receives the tagged reply.
bh.bsActive.Store(true)
fch := make(chan bsFrontierReply, 1)
bh.bsFrontierCh = fch
node := ids.GenerateTestNodeID()
tip := ids.GenerateTestID()
require.True(t, bh.deliverBootstrapFrontier(node, tip))
got := <-fch
require.Equal(t, node, got.nodeID)
require.Equal(t, tip, got.tip)
// Active: a registered Ancestors channel receives the decoded blocks.
ach := make(chan [][]byte, 1)
bh.bsAncestorCh[7] = ach
entry := encodeCatchupEntry([]byte("blk"), nil)
var lp [4]byte
binary.BigEndian.PutUint32(lp[:], uint32(len(entry)))
require.True(t, bh.deliverBootstrapAncestors(7, append(lp[:], entry...)))
require.Equal(t, [][]byte{[]byte("blk")}, <-ach)
}
// ----- H2: progress-based bootstrap watchdog --------------------------------
// TestWatchBootstrapProgress_SlowButAdvancingNotKilled is the H2 proof: a sync that takes
// LONGER than the stall window but keeps ADVANCING its height is NEVER killed (the old
// once-set 5-minute hard timer would have killed it). ready() only reports done after far
// more polls than the stall window spans, yet because the height climbs every poll the
// stall clock keeps resetting.
func TestWatchBootstrapProgress_SlowButAdvancingNotKilled(t *testing.T) {
var calls int
heightOf := func() uint64 { calls++; return uint64(calls) } // advances every poll
ready := func() bool { return calls >= 120 } // done only after 120 polls (≫ the 60ms window)
outcome, _ := watchBootstrapProgress(ready, nil, nil, heightOf, time.Millisecond, 60*time.Millisecond, nil)
require.Equal(t, bootstrapReady, outcome, "a slow-but-ADVANCING sync must complete, not be timed out")
}
// TestWatchBootstrapProgress_GenuineStallFails: a sync whose height does NOT advance for
// the whole window is failed (a real stall is still surfaced, not masked).
func TestWatchBootstrapProgress_GenuineStallFails(t *testing.T) {
heightOf := func() uint64 { return 5 } // pinned — no progress
ready := func() bool { return false }
start := time.Now()
outcome, last := watchBootstrapProgress(ready, nil, nil, heightOf, time.Millisecond, 60*time.Millisecond, nil)
require.Equal(t, bootstrapStalled, outcome, "a no-progress sync must stall out")
require.Equal(t, uint64(5), last, "the stall diagnostic must report the height it stalled at")
require.GreaterOrEqual(t, time.Since(start), 60*time.Millisecond, "must wait the full stall window before failing")
}
// TestWatchBootstrapProgress_Shutdown: a shutdown signal returns promptly without
// declaring success or failure.
func TestWatchBootstrapProgress_Shutdown(t *testing.T) {
shutdown := make(chan struct{})
go func() { time.Sleep(20 * time.Millisecond); close(shutdown) }()
outcome, _ := watchBootstrapProgress(
func() bool { return false }, nil, nil, func() uint64 { return 0 },
time.Millisecond, time.Hour, shutdown,
)
require.Equal(t, bootstrapShutdown, outcome)
}
// TestWatchBootstrapProgress_FailSafeSurfacesPromptly is the F5 proof: when the sync loop
// RETURNS a fail-safe error, the failed() predicate flips and the watchdog returns
// bootstrapFailed on the NEXT tick — NOT after the (here, 1-hour) no-progress window. This is
// what removes the dead zone where the chain was neither syncing nor stopped: monitorBootstrap
// gets the signal to stop the chain the instant Run returns.
func TestWatchBootstrapProgress_FailSafeSurfacesPromptly(t *testing.T) {
failed := func() bool { return true } // the loop has already returned a fail-safe error
start := time.Now()
outcome, _ := watchBootstrapProgress(
func() bool { return false }, // not ready
failed,
nil, // not connecting
func() uint64 { return 7 }, // height pinned — would otherwise wait the full stall window
time.Millisecond, time.Hour, nil,
)
require.Equal(t, bootstrapFailed, outcome, "a fail-safe RETURN must surface as bootstrapFailed, not stall")
require.Less(t, time.Since(start), 500*time.Millisecond, "must surface PROMPTLY, not after the no-progress watchdog")
}
// TestWatchBootstrapProgress_ReadyBeatsFailed: if the sync completed in the SAME tick the
// predicates are evaluated, ready wins (success is never masked by a stale failure flag).
func TestWatchBootstrapProgress_ReadyBeatsFailed(t *testing.T) {
outcome, _ := watchBootstrapProgress(
func() bool { return true }, // ready
func() bool { return true }, // and failed — ready must take precedence
nil, // connecting (irrelevant — ready wins)
func() uint64 { return 0 },
time.Millisecond, time.Hour, nil,
)
require.Equal(t, bootstrapReady, outcome, "ready must take precedence over a failed flag")
}
// TestBootstrapFailure_AccessorSurfacesReason proves the F5 plumbing the driver↔monitor share:
// runBootstrapThenPoll stores the fail-safe reason, and the BootstrapFailed/BootstrapFailure
// accessors (which monitorBootstrap polls) surface it race-free. Before a failure both report
// "no failure"; after, they report the exact error (so the health check carries the real cause).
func TestBootstrapFailure_AccessorSurfacesReason(t *testing.T) {
bh := &blockHandler{}
require.False(t, bh.BootstrapFailed(), "a fresh handler has not failed")
require.NoError(t, bh.BootstrapFailure(), "no reason before a failure")
bh.bootstrapFailed.Store(&bsFailure{err: chainbootstrap.ErrNoBeaconQuorum})
require.True(t, bh.BootstrapFailed(), "after a fail-safe return BootstrapFailed must be true")
require.ErrorIs(t, bh.BootstrapFailure(), chainbootstrap.ErrNoBeaconQuorum, "the precise fail-safe reason must surface for the health check")
}
// ----- VM normal-operation GATING (the restart-freeze fix) -------------------
//
// These prove the ORDERING fix in runInitialSync: the VM transitions to NORMAL OPERATION
// (vm.Ready → the EVM's onNormalOperationsStarted: block building / mempool / validator
// dispatch) ONLY AFTER initial sync has fetch+executed up to the network frontier — never at
// the LOCAL (possibly stale) height. Before the fix buildChain put the VM into normal
// operation UNCONDITIONALLY right after Initialize, so a restarted STALE validator served /
// built at its stale height and never converged (the luxd-2 mainnet freeze: stuck at 1082780
// while producers finalized 1082796). vmHeightAtReady records the height the VM was at the
// instant it was told to go live; the gate makes that the frontier, not the stale tip.
// recordVMReady wires bh.vmReady to capture (a) how many times the VM was transitioned to
// normal operation and (b) the VM's accepted height at the FIRST such transition — the two
// facts the gating invariant turns on.
func recordVMReady(bh *blockHandler, vm *bsTestVM) (calls *int, heightAtReady *uint64) {
calls = new(int)
heightAtReady = new(uint64)
bh.vmReady = func(context.Context) error {
if *calls == 0 {
*heightAtReady = vm.all[vm.lastAcc].height
}
*calls++
return nil
}
return calls, heightAtReady
}
// TestRED_StaleValidatorGoesReadyOnlyAfterReachingFrontier reproduces the EXACT production
// scenario: a node with stale local state (height N) restarts against a live network whose
// finalized frontier is N+k, validator set loaded. It MUST fetch+execute to N+k BEFORE the VM
// is transitioned to normal operation — never go live at the stale N.
//
// FAILS before the fix (the VM was transitioned to normal operation at N, in buildChain,
// before this sync ran) and PASSES after (the transition is gated on reaching N+k).
func TestRED_StaleValidatorGoesReadyOnlyAfterReachingFrontier(t *testing.T) {
const N, K = 30, 16 // stale at 30; live frontier at 46 — the luxd-2 16-block gap shape
chain, byID := buildBSChain(N+K, -1)
vm := newBSVMAt(chain, N) // node ALREADY at height N (restarted spare), gap N+1..N+K ahead
// 5-validator equal-stake beacon set (the mainnet shape), all connected and serving — the
// "validator set IS loaded, 9 peers incl. producers" live condition.
bIDs := make([]ids.NodeID, 5)
weights := map[ids.NodeID]uint64{}
for i := range bIDs {
bIDs[i] = ids.GenerateTestNodeID()
weights[bIDs[i]] = 100
}
bh, chainID := newBSHandlerWeighted(t, vm, weights)
bh.bootstrapRetryInterval = 2 * time.Millisecond
bh.bootstrapConnectDeadline = 2 * time.Second
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: bIDs, byID: byID, tip: chain[N+K], serveAncestors: true}
bh.msgCreator = bsMsgBuilder{}
calls, heightAtReady := recordVMReady(bh, vm)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
live := bh.runInitialSync(ctx)
require.True(t, live, "node must reach the frontier and go live")
require.True(t, bh.bootstrapDone.Load(), "chain must be marked bootstrapped after reaching the frontier")
require.False(t, bh.BootstrapFailed(), "a converged sync must not record a fail-safe reason")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[N+K].id, last, "VM must fetch+execute up to the network frontier (height %d)", N+K)
require.Equal(t, 1, *calls, "VM must be transitioned to normal operation exactly once")
require.Equal(t, uint64(N+K), *heightAtReady,
"THE FIX: VM must go to normal operation ONLY at the frontier (height %d), never at the stale local height %d", N+K, N)
}
// TestNodeBootstrap_FreshGenesisNoBeaconsReadyImmediately is the NO-REGRESSION guard for a
// fresh network / single node: NO beacon set (FrontierNoBeacons) means there is nothing ahead
// to sync to, so the VM must go to normal operation PROMPTLY at genesis. The gate must not pin
// a legitimate fresh-genesis / dev node unbootstrapped.
func TestNodeBootstrap_FreshGenesisNoBeaconsReadyImmediately(t *testing.T) {
chain, byID := buildBSChain(0, -1) // genesis only
vm := newBSVM(chain)
bh, chainID, _ := newBSHandlerAndEngine(t, vm, 0) // 0 beacons, expectsStakedBeacons=false
bh.bootstrapRetryInterval = 2 * time.Millisecond
bh.bootstrapConnectDeadline = 2 * time.Second
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: nil, byID: byID, tip: chain[0]}
bh.msgCreator = bsMsgBuilder{}
calls, heightAtReady := recordVMReady(bh, vm)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
start := time.Now()
live := bh.runInitialSync(ctx)
require.True(t, live, "a no-beacon (fresh genesis / single node) chain must go live")
require.True(t, bh.bootstrapDone.Load(), "fresh-genesis chain must be marked bootstrapped")
require.Less(t, time.Since(start), 2*time.Second, "no-beacon fast path must not wait out the connect deadline")
require.Equal(t, 1, *calls, "VM must be transitioned to normal operation once")
require.Equal(t, uint64(0), *heightAtReady, "fresh-genesis node goes live at genesis (height 0)")
}
// TestRED_EclipsedValidatorNeverGoesReadyAtStaleHeight is the FAIL-SAFE proof: a stale node
// whose beacons are UNREACHABLE (eclipse / isolation) must NOT go to normal operation at its
// stale height (that was the freeze defect). Within the bounded connect deadline it fails safe
// — VM stays in Bootstrapping (vmReady never called), bootstrapFailed records the reason — and
// it does NOT hang (the deadline bounds it). monitorBootstrap then stops the chain; the
// orchestrator restarts and the node re-syncs.
func TestRED_EclipsedValidatorNeverGoesReadyAtStaleHeight(t *testing.T) {
const N, K = 30, 16
chain, byID := buildBSChain(N+K, -1)
vm := newBSVMAt(chain, N)
bIDs := make([]ids.NodeID, 5)
weights := map[ids.NodeID]uint64{}
for i := range bIDs {
bIDs[i] = ids.GenerateTestNodeID()
weights[bIDs[i]] = 100
}
bh, chainID := newBSHandlerWeighted(t, vm, weights)
bh.bootstrapRetryInterval = 2 * time.Millisecond
bh.bootstrapConnectDeadline = 300 * time.Millisecond // bounded fail-safe window for the test
// Pin a SINGLE attempt so this asserts the TERMINAL fail-safe in isolation (the connectivity
// self-heal RETRY — bootstrapMaxAttempts ≤ 0 ⇒ retry until the quorum returns — is proven
// separately in TestRED_MajorityOutageSelfHealsWhenQuorumReturns). The SAFETY property here (an
// eclipsed node NEVER goes live at its stale height) holds identically under one attempt or many.
bh.bootstrapMaxAttempts = 1
// connected: nil — the beacon set is configured (weights known) but NONE are reachable.
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: nil, byID: byID, tip: chain[N+K], serveAncestors: true}
bh.msgCreator = bsMsgBuilder{}
calls, _ := recordVMReady(bh, vm)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
start := time.Now()
live := bh.runInitialSync(ctx)
elapsed := time.Since(start)
require.False(t, live, "an eclipsed node must NOT go live")
require.Equal(t, 0, *calls, "THE FIX: VM must NEVER be transitioned to normal operation at the stale height when beacons are unreachable")
require.False(t, bh.bootstrapDone.Load(), "an eclipsed node must not be marked bootstrapped")
require.True(t, bh.BootstrapFailed(), "the fail-safe reason must be recorded for monitorBootstrap")
require.ErrorIs(t, bh.BootstrapFailure(), chainbootstrap.ErrBeaconsUnreachable, "eclipse must surface as ErrBeaconsUnreachable")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[N].id, last, "the VM must stay at its stale height (it served nothing, never advanced)")
require.Less(t, elapsed, 3*time.Second, "fail-safe must be BOUNDED — no unbounded hang")
}
// TestNodeBootstrap_ProducerAtTipReadyFast is the PRODUCER hard-constraint guard: a producer
// at (or at most one block from) the frontier restarts. It must re-bootstrap QUICKLY and go
// live — not hang waiting to sync blocks it already has. Here the node is at N and the beacon
// quorum names N (it already holds the tip): the loop is caught-up on the first round and the
// VM goes live at N promptly.
func TestNodeBootstrap_ProducerAtTipReadyFast(t *testing.T) {
const N = 40
chain, byID := buildBSChain(N, -1)
vm := newBSVMAt(chain, N) // already at the tip
bIDs := make([]ids.NodeID, 5)
weights := map[ids.NodeID]uint64{}
for i := range bIDs {
bIDs[i] = ids.GenerateTestNodeID()
weights[bIDs[i]] = 100
}
bh, chainID := newBSHandlerWeighted(t, vm, weights)
bh.bootstrapRetryInterval = 2 * time.Millisecond
bh.bootstrapConnectDeadline = 2 * time.Second
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: bIDs, byID: byID, tip: chain[N], serveAncestors: true}
bh.msgCreator = bsMsgBuilder{}
calls, heightAtReady := recordVMReady(bh, vm)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
start := time.Now()
live := bh.runInitialSync(ctx)
require.True(t, live, "a producer at the tip must go live")
require.Less(t, time.Since(start), 2*time.Second, "an at-the-tip restart must be FAST — not hang")
require.Equal(t, 1, *calls, "VM transitioned to normal operation once")
require.Equal(t, uint64(N), *heightAtReady, "producer goes live at the frontier (== its tip, height %d)", N)
require.True(t, bh.bootstrapDone.Load(), "producer chain marked bootstrapped")
}
// TestRED_TipHolderCoRestartGoesReadyAtOwnTip is THE CRITICAL regression RED found and the hinge of
// this round. A PRODUCER at the tip N, on a simultaneous co-restart, sees the production fleet SPLIT
// across heights: 2 other producers at N, one node at N-16, one at genesis. The tip-holders are only
// ½ of the 4 responders (< ⅔), so NO frontier is NAMED, and the ⅔-backed common ancestor N-16 is
// below the node's own last-accepted (MinFrontierHeight filters it). WITHOUT the caught-up
// determination this producer has no go-live path and fails safe DOWN at its own tip — the OPPOSITE
// of the stale-go-live bug, and just as wrong.
//
// FAILS on 5d3aaeb5a0 (FrontierTip → FrontierNoQuorum → ErrNoBeaconQuorum → live==false); PASSES
// after: CaughtUp proves the node is at the top of every responder and holds every reported tip, so
// the network frontier IS the node's own accepted tip → the loop's Has()-shortcut → Ready at N, once.
func TestRED_TipHolderCoRestartGoesReadyAtOwnTip(t *testing.T) {
const N = 80 // the producer's tip (models the mainnet 1082796 — the RELATIVE shape is the point)
const gap = 16 // luxd-2's lag (the canary's 16-block gap → N-16)
chain, byID := buildBSChain(N, -1)
vm := newBSVMAt(chain, N) // the PRODUCER holds its full accepted chain 0..N (a co-restart spare)
// 5 equal-weight beacons (the mainnet shape). The node is the 5th; its 4 PEERS report the SPLIT:
// two producers at the tip N, luxd-2 at N-16, luxd-0 at genesis.
bIDs := make([]ids.NodeID, 5)
weights := map[ids.NodeID]uint64{}
for i := range bIDs {
bIDs[i] = ids.GenerateTestNodeID()
weights[bIDs[i]] = 100
}
bh, chainID := newBSHandlerWeighted(t, vm, weights)
bh.bootstrapRetryInterval = 2 * time.Millisecond
bh.bootstrapConnectDeadline = 2 * time.Second
bh.net = &bsBeaconNet{
bh: bh, chainID: chainID, connected: bIDs[:4], byID: byID, tip: chain[N], serveAncestors: true,
tipFor: map[ids.NodeID]ids.ID{
bIDs[2]: chain[N-gap].id, // luxd-2 at N-16
bIDs[3]: chain[0].id, // luxd-0 at genesis
},
}
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
// UNIT: the tip-holders (b0,b1 = 200) do NOT clear ⅔ (266 of 400) and the ⅔-common N-16 is below
// MinFrontierHeight=N → no frontier is NAMED; the caught-up path names the node's OWN held tip.
bh.bsActive.Store(true)
tip, status := bh.FrontierTip(ctx)
bh.bsActive.Store(false)
require.Equal(t, chainbootstrap.FrontierNamed, status,
"CAUGHT-UP: a tip-holder that holds every reported tip and is at the top of all of them must go Ready, not NoQuorum")
require.Equal(t, chain[N].id, tip, "the caught-up frontier is the node's OWN accepted tip N")
// FULL LOOP: the producer goes live at its OWN tip N — exactly once, never below it, never hung.
calls, heightAtReady := recordVMReady(bh, vm)
rctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
start := time.Now()
live := bh.runInitialSync(rctx)
require.True(t, live, "THE FIX: a tip-holding producer on a mixed-height co-restart must go Ready at its own tip")
require.Less(t, time.Since(start), 2*time.Second, "a caught-up producer must go live PROMPTLY, not hang on the no-quorum retry")
require.Equal(t, 1, *calls, "VM transitioned to normal operation exactly once")
require.Equal(t, uint64(N), *heightAtReady, "went live at its OWN tip N=%d, never below it", N)
require.True(t, bh.bootstrapDone.Load(), "tip-holder chain marked bootstrapped")
require.False(t, bh.BootstrapFailed(), "a caught-up producer must NOT record a fail-safe reason")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[N].id, last, "the producer stays at its tip N — it does NOT sync backward to the ⅔-common N-16")
}
// TestRED_MajorityOutageSelfHealsWhenQuorumReturns is the RED MEDIUM #2 self-heal proof. A node boots
// into a MAJORITY OUTAGE (its beacon quorum unreachable — a co-restart where < MinResponses are up).
// It must NOT go live at its stale height (safety) and must NOT permanently give up (liveness): it
// stays in Bootstrapping, RE-ATTEMPTS (bootstrapMaxAttempts ≤ 0 ⇒ until the quorum returns), and
// CONVERGES the instant the quorum comes back — all IN-PROCESS, with no pod restart (the K8s probes
// poll the always-green /ext/health/liveness and would never restart it). This is the bounded
// re-bootstrap retry that closes the "permanent brick" RED flagged.
func TestRED_MajorityOutageSelfHealsWhenQuorumReturns(t *testing.T) {
const N, K = 30, 16 // stale at N; the live frontier (once the quorum returns) is N+K
chain, byID := buildBSChain(N+K, -1)
vm := newBSVMAt(chain, N)
bIDs := make([]ids.NodeID, 5)
weights := map[ids.NodeID]uint64{}
for i := range bIDs {
bIDs[i] = ids.GenerateTestNodeID()
weights[bIDs[i]] = 100
}
bh, chainID := newBSHandlerWeighted(t, vm, weights)
bh.bootstrapRetryInterval = 2 * time.Millisecond
bh.bootstrapConnectDeadline = 80 * time.Millisecond // short per-attempt connect wait (test bound)
// bootstrapMaxAttempts left 0 ⇒ UNLIMITED retry — the production self-heal.
net := &bsBeaconNet{bh: bh, chainID: chainID, connected: bIDs, byID: byID, tip: chain[N+K], serveAncestors: true}
net.gate.Store(true) // start in the OUTAGE: the quorum is unreachable (a majority co-restart)
bh.net = net
bh.msgCreator = bsMsgBuilder{}
calls, heightAtReady := recordVMReady(bh, vm)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
done := make(chan bool, 1)
go func() { done <- bh.runInitialSync(ctx) }()
// While the quorum is down the node must fail safe DOWN and ENTER its connectivity-retry wait —
// NOT go live at the stale height, NOT give up. (Asserted via the ATOMIC status — no non-atomic
// read of the bootstrap goroutine's state while it runs.)
require.Eventually(t, bh.BootstrapConnecting, 3*time.Second, 2*time.Millisecond,
"an outage must put the node into the connectivity-retry wait (failing safe DOWN), not live or given-up")
require.False(t, bh.bootstrapDone.Load(), "must NOT be bootstrapped while the quorum is unreachable")
require.False(t, bh.BootstrapFailed(), "a retryable connectivity outage is NOT a terminal fail-safe — it keeps trying")
// The quorum RETURNS. The node must SELF-HEAL in-process: converge to the frontier and go live.
net.gate.Store(false)
require.True(t, <-done, "node must self-heal and go live once the quorum returns") // happens-before for the reads below
require.True(t, bh.bootstrapDone.Load(), "chain marked bootstrapped after self-heal")
// calls==1 (exactly one go-live, TOTAL) proves the VM never went live during the outage — had it,
// the recovery go-live would make this 2. heightAtReady==N+K proves that single go-live was at the
// recovered frontier, never the stale N.
require.Equal(t, 1, *calls, "VM transitioned to normal operation exactly once — at the frontier, after recovery")
require.Equal(t, uint64(N+K), *heightAtReady, "self-healed node converges to the frontier N+K=%d, never the stale N=%d", N+K, N)
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[N+K].id, last, "self-healed node holds the frontier tip after recovery")
}
// TestRED_EclipseOwnHeightNotNamedRoutesToSync is the M1 fast-follow at the INTEGRATION level (the
// real FrontierTip routing + full-loop go-live decision). A node at accepted height N restarts while
// block N+5 is GENUINELY FINALIZED ahead, but a sustained eclipse THROTTLES the ahead responders below
// the ⅔ naming threshold (R_a=300 of a responder weight 500; ⅔ floor 333) while letting the at-height
// responders (R_b=200 at N) through. Block N then accrues R_a+R_b=500 > ⅔ purely as the shared
// ANCESTOR of the ahead N+5 tips.
//
// - BEFORE the own-height filter fix (`ref.Height < MinFrontierHeight`): nameFrontier NAMES N (own
// height) → FrontierNamed at chain[N] → the node goes Ready STALE, 5 blocks behind finality. M1.
// - AFTER (`ref.Height <= MinFrontierHeight`): own height is excluded → AcceptsFrontier names nothing
// → routes to CaughtUp → CaughtUp sees the un-held, above N+5 tips → REFUSES → FrontierNoQuorum.
// The full loop fails safe: the VM is NEVER transitioned to normal operation at the stale height,
// and the node stays at N (serving nothing) rather than going live 5 blocks behind finality.
//
// Then the ECLIPSE LIFTS (the ahead set returns to ≥⅔): N+5 becomes nameable and the node SYNCS UP to
// it — proving the refusal was a safe HOLD, not a permanent stall, and the node converges to the true
// finalized frontier, never having served the stale N.
func TestRED_EclipseOwnHeightNotNamedRoutesToSync(t *testing.T) {
const N, ahead = 40, 45 // node at N; block N+5 genuinely finalized ahead
chain, byID := buildBSChain(ahead, -1)
// 6 equal-weight beacons (total 600 → bootstrapPolicy wires MinResponseWeight ⌈600/2⌉=301,
// MinResponses majority=4, MinFrontierHeight=lastAccepted=N).
bIDs := make([]ids.NodeID, 6)
weights := map[ids.NodeID]uint64{}
for i := range bIDs {
bIDs[i] = ids.GenerateTestNodeID()
weights[bIDs[i]] = 100
}
// ---- PART A: the eclipse — ahead set throttled below ⅔ → must NOT go Ready at stale N ----
vmA := newBSVMAt(chain, N) // node already accepted 0..N, NOT N+1..N+5
bhA, chainIDA := newBSHandlerWeighted(t, vmA, weights)
bhA.bootstrapRetryInterval = 2 * time.Millisecond
bhA.bootstrapConnectDeadline = 300 * time.Millisecond
bhA.bootstrapMaxAttempts = 1 // assert the terminal fail-safe in isolation
// Connected responders: b0,b1 at N (R_b=200), b2,b3,b4 at N+5 (R_a=300, < ⅔·500=333); b5 eclipsed.
bhA.net = &bsBeaconNet{
bh: bhA, chainID: chainIDA, connected: bIDs[:5], byID: byID, tip: chain[N], serveAncestors: true,
tipFor: map[ids.NodeID]ids.ID{
bIDs[2]: chain[ahead].id, bIDs[3]: chain[ahead].id, bIDs[4]: chain[ahead].id,
},
}
bhA.msgCreator = bsMsgBuilder{}
ctx := context.Background()
// UNIT: FrontierTip must NOT name the stale own height N. Own height is excluded from nameFrontier,
// the ahead N+5 is sub-⅔, and CaughtUp refuses (N+5 un-held, above) → FrontierNoQuorum.
bhA.bsActive.Store(true)
tip, status := bhA.FrontierTip(ctx)
bhA.bsActive.Store(false)
require.Equal(t, chainbootstrap.FrontierNoQuorum, status,
"M1: an eclipse crediting the node's OWN height as a ⅔-ancestor of throttled ahead tips must NOT name it")
require.NotEqual(t, chain[N].id, tip, "M1: the stale own height N must NOT be named")
require.Equal(t, ids.Empty, tip)
// FULL LOOP: the node fails safe DOWN — VM never goes to normal operation at the stale height.
callsA, _ := recordVMReady(bhA, vmA)
rctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
live := bhA.runInitialSync(rctx)
require.False(t, live, "M1: an eclipse-stale node must NOT go live at its own height")
require.Equal(t, 0, *callsA, "M1: VM must NEVER be transitioned to normal operation at the stale height")
require.False(t, vmA.accepted[chain[ahead].id], "node did not (and could not) sync the throttled N+5 under eclipse")
lastA, _ := vmA.LastAccepted(ctx)
require.Equal(t, chain[N].id, lastA, "node stays at its stale height N (served nothing) — never live, never advanced")
// ---- PART B: the eclipse lifts — ahead set returns to ≥⅔ → node SYNCS UP to the true frontier ----
vmB := newBSVMAt(chain, N)
bhB, chainIDB := newBSHandlerWeighted(t, vmB, weights)
bhB.bootstrapRetryInterval = 2 * time.Millisecond
bhB.bootstrapConnectDeadline = 2 * time.Second
// Now 4 of 5 responders report N+5 (400 > ⅔·500=333 → nameable); one lags at N. The ahead set is
// no longer throttled, so N+5 is named and the node descends+executes N+1..N+5.
bhB.net = &bsBeaconNet{
bh: bhB, chainID: chainIDB, connected: bIDs[:5], byID: byID, tip: chain[ahead], serveAncestors: true,
tipFor: map[ids.NodeID]ids.ID{bIDs[0]: chain[N].id}, // one laggard at N; b1..b4 report N+5 (default tip)
}
bhB.msgCreator = bsMsgBuilder{}
callsB, heightAtReadyB := recordVMReady(bhB, vmB)
bctx, bcancel := context.WithTimeout(context.Background(), 5*time.Second)
defer bcancel()
require.True(t, bhB.runInitialSync(bctx), "eclipse lifted: the node must converge to the true finalized frontier")
require.Equal(t, 1, *callsB, "VM goes live exactly once — at the recovered frontier, never the stale N")
require.Equal(t, uint64(ahead), *heightAtReadyB, "went live at the true finalized frontier N+5=%d, never the stale N=%d", ahead, N)
lastB, _ := vmB.LastAccepted(ctx)
require.Equal(t, chain[ahead].id, lastB, "node synced UP to N+5 once the eclipse lifted (a safe HOLD, not a permanent stall)")
}
// TestWatchBootstrapProgress_ConnectingNotStalled is the RED MEDIUM #2 watchdog guard: a node in its
// connectivity-RETRY wait reports connecting()==true, so the no-progress watchdog must NOT declare it
// stalled — force-STOPPING a node that is correctly waiting for its quorum would be a permanent brick
// (the K8s probes never restart it). The clock is reset while connecting; only a NON-connecting
// no-progress node stalls (the converse is TestWatchBootstrapProgress_GenuineStallFails).
func TestWatchBootstrapProgress_ConnectingNotStalled(t *testing.T) {
shutdown := make(chan struct{})
go func() { time.Sleep(120 * time.Millisecond); close(shutdown) }() // ~6 stall windows later
outcome, _ := watchBootstrapProgress(
func() bool { return false }, // never ready
func() bool { return false }, // never a terminal fail
func() bool { return true }, // ALWAYS connecting (deliberate quorum wait)
func() uint64 { return 5 }, // height pinned — no progress
time.Millisecond, 20*time.Millisecond, shutdown,
)
require.Equal(t, bootstrapShutdown, outcome,
"a CONNECTING node must NEVER be stalled out (it is waiting for its quorum) — only shutdown ends it")
}
// ============================================================================
// PRODUCTION-MODELING REPRO (mainnet luxd-2 freeze, v1.30.84 → fix/bootstrap-vm-ready-ordering)
//
// The v1.30.84 fix passed 50 unit tests + a BFT proof but FAILED the real mainnet canary: a
// STALE spare (luxd-2) at C-Chain accepted height N went Ready at N instead of fetching the 16
// blocks to the producers' frontier Top. The mocks injected the FULL, STABLE validator set from
// t=0, masking the real trigger: a native chain's bootstrap goroutine starts right after its
// VM.Initialize — BEFORE the P-chain has finished replaying its blocks — so the staked beacon set
// (m.Validators) is a PARTIAL set at boot. The MinResponseWeight stake-majority floor (total/2+1)
// is fail-secure ONLY when `total` is the TRUE full-set stake; under a partial denominator a
// degenerate at-or-below responder subset (the genuinely-ahead producers not yet loaded) clears
// the floor and the frontier decision concludes "caught up" at the stale local height.
//
// These tests model the REAL conditions (not the ideal full set): not-held producer tips, an
// empty ancestry fetch, a peer-connect delay, and — the headline — a PARTIAL staked set at boot.
// ============================================================================
// TestRED_PartialStakedSet_BehindNodeMustNotGoLiveStale is THE production reproduction. It models
// the staked validator set being PARTIAL while the P-chain is still syncing (the producers not yet
// loaded), and proves:
// - WITHOUT the P-ready gate (the f882142c81 behavior): the partial at-or-below responder subset
// clears the floor and FrontierTip NAMES the node's OWN stale tip → go-live-at-stale (the freeze).
// - WITH the gate: a native chain WAITS (FrontierConnecting) for the staked set to load, never
// concluding caught-up on a non-representative partial set.
// - Once the P-chain finishes (full set, producers now loaded at Top), the node names Top and
// converges — never stopping at its stale N.
func TestRED_PartialStakedSet_BehindNodeMustNotGoLiveStale(t *testing.T) {
const M = 40 // our STALE local height (luxd-2's 1082780, scaled)
const Top = 56 // producers' real accepted frontier (gap 16 — the canary's 16 blocks)
chain, byID := buildBSChain(Top, -1)
vm := newBSVMAt(chain, M) // behind by 16
// The PARTIAL staked set visible while the P-chain is still replaying: only the two stale spares
// (at M) are loaded; the three genuinely-ahead producers (at Top) are NOT in the set yet. Both
// spares report the node's OWN tip chain[M] (they are at M too) — a stake-MAJORITY of the PARTIAL
// set, so the floor is met and the exact-fast-path would name chain[M], concluding caught-up STALE.
spareA, spareB := ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
bh, chainID := newBSHandlerWeighted(t, vm, map[ids.NodeID]uint64{spareA: 100, spareB: 100})
bh.net = &bsBeaconNet{
bh: bh, chainID: chainID, connected: []ids.NodeID{spareA, spareB},
byID: byID, tip: chain[M], serveAncestors: true, // both partial-set beacons report our OWN tip
}
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
// (1) NO GATE == f882142c81: the partial set false-names the stale own tip — the production freeze.
bh.bsActive.Store(true)
tip, status := bh.FrontierTip(ctx)
bh.bsActive.Store(false)
require.Equal(t, chainbootstrap.FrontierNamed, status,
"REPRO: without the P-ready gate a PARTIAL staked set false-names the stale own tip (go-live-at-stale)")
require.Equal(t, chain[M].id, tip, "REPRO: the named 'frontier' is the node's OWN stale tip — the luxd-2 freeze")
// (2) WITH THE GATE: staked set not yet fully loaded (P-chain still syncing) → WAIT, never caught-up.
var pReady atomic.Bool // false until the P-chain finishes its own initial sync
bh.primaryNetworkReady = pReady.Load
bh.bsActive.Store(true)
tip, status = bh.FrontierTip(ctx)
bh.bsActive.Store(false)
require.Equal(t, chainbootstrap.FrontierConnecting, status,
"FIX: a behind node must WAIT for the FULL staked set, not conclude caught-up on a partial one")
require.Equal(t, ids.Empty, tip)
// (3) P-chain finished → the FULL set is now visible (the three ahead producers loaded, only THEY
// connected). They report Top (NOT held by the node at M). The node must NAME Top and sync to it,
// never stop at its stale M.
p1, p2, p3 := ids.GenerateTestNodeID(), ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
full := validators.NewManager()
for id, w := range map[ids.NodeID]uint64{spareA: 100, spareB: 100, p1: 100, p2: 100, p3: 100} {
require.NoError(t, full.AddStaker(bh.networkID, id, nil, ids.GenerateTestID(), w))
}
bh.beacons = full
bh.net = &bsBeaconNet{
bh: bh, chainID: chainID, connected: []ids.NodeID{p1, p2, p3}, // the ⅔-stake producers, at Top
byID: byID, tip: chain[Top], serveAncestors: true,
}
pReady.Store(true)
require.NoError(t, runBS(t, bh), "with the full staked set the behind node must converge to the producers' frontier")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[Top].id, last,
"behind node must sync to the producers' real frontier Top=%d, NEVER stop at its stale M=%d", Top, M)
}
// TestNodeBootstrap_BehindNodeNotHeldTips_SyncsToFrontier (variant A). A behind node at M, the FULL
// staked set loaded (P-ready), the ⅔-stake producers reporting Top which the node does NOT hold,
// ancestry served. CaughtUp's held-check fails (the producer tip is not held), so the node names
// the real higher frontier and fetch+executes to it — Ready at Top, never at its stale M.
func TestNodeBootstrap_BehindNodeNotHeldTips_SyncsToFrontier(t *testing.T) {
const M = 40
const Top = 56 // gap 16, all NOT held by the node at M
chain, byID := buildBSChain(Top, -1)
vm := newBSVMAt(chain, M)
p1, p2, p3 := ids.GenerateTestNodeID(), ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
bh, chainID := newBSHandlerWeighted(t, vm, map[ids.NodeID]uint64{p1: 100, p2: 100, p3: 100})
var pReady atomic.Bool
pReady.Store(true) // P-chain synced: full staked set
bh.primaryNetworkReady = pReady.Load
bh.net = &bsBeaconNet{
bh: bh, chainID: chainID, connected: []ids.NodeID{p1, p2, p3},
byID: byID, tip: chain[Top], serveAncestors: true,
}
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
require.False(t, bh.Accepted(ctx, chain[Top].id), "precondition: the producers' tip Top is NOT held by the behind node")
require.NoError(t, runBS(t, bh), "behind node must descend the not-held frontier and converge")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[Top].id, last, "behind node must reach Top=%d, never stop at stale M=%d", Top, M)
require.True(t, bh.Accepted(ctx, chain[Top].id), "node must hold the producer tip after sync")
}
// TestNodeBootstrap_StoredButUnacceptedFrontier_DrivesAcceptance is the NODE-LAYER reproduction of
// THE mainnet luxd-2 freeze with production ground truth — the case the prior mocks could not even
// express (their VM conflated store and acceptance). A behind node at M holds blocks M+1..Top
// ALREADY IN ITS STORE (gossiped / a prior incomplete sync) but UNACCEPTED, and the ⅔-stake
// producers name the frontier at Top — a tip the node HOLDS but has NOT accepted. THE BUG: the
// node-side Has()/heldHeight reported "present" for those stored-but-unaccepted blocks, the loop's
// Has()-shortcut concluded caught-up at the stale M, and the node went Ready at M forever (self-
// reinforcing across restarts — every reboot re-concluded caught-up). THE FIX: Accepted(Top) is
// false (present ≠ accepted), so the node descends and ACCEPTS M+1..Top — through the per-height-
// guarded cert/Verify path, accepting the blocks already in the store — reaching Top, never Ready
// at the stale M.
func TestNodeBootstrap_StoredButUnacceptedFrontier_DrivesAcceptance(t *testing.T) {
const M = 40
const Top = 56 // gap 16, the exact ground-truth skew (1082780 → 1082796)
chain, byID := buildBSChain(Top, -1)
vm := newBSVMAt(chain, M)
// GROUND TRUTH: M+1..Top are PRESENT IN THE STORE (gossiped ahead) but UNACCEPTED.
vm.store(chain[M+1 : Top+1]...)
p1, p2, p3 := ids.GenerateTestNodeID(), ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
bh, chainID := newBSHandlerWeighted(t, vm, map[ids.NodeID]uint64{p1: 100, p2: 100, p3: 100})
var pReady atomic.Bool
pReady.Store(true) // P-chain synced: full staked set
bh.primaryNetworkReady = pReady.Load
bh.net = &bsBeaconNet{
bh: bh, chainID: chainID, connected: []ids.NodeID{p1, p2, p3},
byID: byID, tip: chain[Top], serveAncestors: true,
}
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
// Precondition — the EXACT freeze state: the node HOLDS the named frontier in its store yet has
// NOT accepted it (last-accepted is M). The store-vs-acceptance distinction the conflation missed.
require.True(t, vm.stored[chain[Top].id], "precondition: the frontier must be present in the store (gossiped ahead)")
require.False(t, bh.Accepted(ctx, chain[Top].id), "precondition: the held-in-store frontier must NOT be accepted (last-accepted is M)")
require.NoError(t, runBS(t, bh), "node must descend+accept the stored-but-unaccepted frontier, not freeze at M")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[Top].id, last,
"FREEZE: node must reach Top=%d, never go Ready at the stale M=%d while holding M+1..Top unaccepted", Top, M)
require.True(t, bh.Accepted(ctx, chain[Top].id), "the named frontier must be ACCEPTED after sync")
}
// TestBootstrap_AcceptedHeight_StoreVsAcceptance pins the ACCEPTANCE oracle the whole fix turns on:
// acceptedHeight / Accepted report a block ACCEPTED only when it is on the FINALIZED chain — never
// merely because it is PRESENT in the store. This is the store-vs-acceptance distinction the luxd-2
// freeze conflated, isolated from the network so the predicate itself is the unit under test.
func TestBootstrap_AcceptedHeight_StoreVsAcceptance(t *testing.T) {
const M = 30
const Top = 40
chain, _ := buildBSChain(Top, -1)
vm := newBSVMAt(chain, M) // accepted 0..M
vm.store(chain[M+1 : Top+1]...) // M+1..Top GOSSIPED into the store but UNACCEPTED
bh := &blockHandler{logger: log.NewNoOpLogger(), vm: vm}
ctx := context.Background()
// (a) the accepted head and a finalized ancestor resolve as ACCEPTED at their canonical heights.
if h, ok := bh.acceptedHeight(chain[M].id); !ok || h != uint64(M) {
t.Fatalf("accepted head must resolve accepted at height %d, got (%d,%v)", M, h, ok)
}
if h, ok := bh.acceptedHeight(chain[M-5].id); !ok || h != uint64(M-5) {
t.Fatalf("a finalized ancestor must resolve accepted at height %d, got (%d,%v)", M-5, h, ok)
}
// (b) stored-but-unaccepted blocks (ABOVE the accepted head) are present in the store yet NOT
// accepted — the freeze conflation. acceptedHeight AND Accepted must both report not-accepted.
for _, h := range []int{M + 1, Top} {
if _, err := vm.GetBlock(ctx, chain[h].id); err != nil {
t.Fatalf("precondition: chain[%d] must be present in the store", h)
}
if _, ok := bh.acceptedHeight(chain[h].id); ok {
t.Fatalf("FREEZE: stored-but-unaccepted chain[%d] must NOT resolve as accepted", h)
}
if bh.Accepted(ctx, chain[h].id) {
t.Fatalf("FREEZE: Accepted(stored-but-unaccepted chain[%d]) must be false", h)
}
}
// (c) an absent block is not accepted.
if bh.Accepted(ctx, ids.GenerateTestID()) {
t.Fatal("an absent block must not be accepted")
}
}
// TestNodeBootstrap_BehindNode_EmptyAncestry_StaysBootstrapping (variant B). A behind node names the
// real higher frontier Top (the producers report it), but the ancestry fetch returns EMPTY (the
// cross-version / unreachable case the canary risked). The node MUST stay un-synced (fail safe),
// NEVER concluding caught-up at its stale M: bs.Run returns a real error and the local height is
// unchanged — runInitialSync would leave the VM in Bootstrapping, not Ready.
func TestNodeBootstrap_BehindNode_EmptyAncestry_StaysBootstrapping(t *testing.T) {
const M = 40
const Top = 56
chain, byID := buildBSChain(Top, -1)
vm := newBSVMAt(chain, M)
p1, p2, p3 := ids.GenerateTestNodeID(), ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
bh, chainID := newBSHandlerWeighted(t, vm, map[ids.NodeID]uint64{p1: 100, p2: 100, p3: 100})
var pReady atomic.Bool
pReady.Store(true)
bh.primaryNetworkReady = pReady.Load
// ancestorsEmpty — the producers REPLY to GetAncestors but serve an EMPTY batch (the
// cross-version / withholding case). The frontier is named (producers report Top) but the gap
// can never be fetched, so the descent must fail safe (ErrStalled), never conclude caught-up.
bh.net = &bsBeaconNet{
bh: bh, chainID: chainID, connected: []ids.NodeID{p1, p2, p3},
byID: byID, tip: chain[Top], serveAncestors: false, ancestorsEmpty: true,
}
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
err := runBS(t, bh)
require.Error(t, err, "an empty ancestry fetch must FAIL SAFE (real error), never a nil 'caught up'")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[M].id, last,
"behind node must NOT advance and must NOT go Ready when the gap cannot be fetched — stays at M=%d (Bootstrapping)", M)
require.False(t, bh.Accepted(ctx, chain[Top].id), "node must NOT hold the unfetched frontier")
}
// TestRED_BehindNode_PeerConnectDelay_NeverFalseCompletesAtStale (variant C). A behind node boots
// with the staked set loaded (P-ready) but its producer beacons still handshaking: the first polls
// see ZERO connected beacons. The node must WAIT (never conclude caught-up at its stale M while it
// cannot even ask the network), then converge to Top once the producers connect.
func TestRED_BehindNode_PeerConnectDelay_NeverFalseCompletesAtStale(t *testing.T) {
const M = 40
const Top = 56
chain, byID := buildBSChain(Top, -1)
vm := newBSVMAt(chain, M)
p1, p2, p3 := ids.GenerateTestNodeID(), ids.GenerateTestNodeID(), ids.GenerateTestNodeID()
bh, chainID := newBSHandlerWeighted(t, vm, map[ids.NodeID]uint64{p1: 100, p2: 100, p3: 100})
var pReady atomic.Bool
pReady.Store(true)
bh.primaryNetworkReady = pReady.Load
net := &bsBeaconNet{
bh: bh, chainID: chainID, connected: []ids.NodeID{p1, p2, p3},
byID: byID, tip: chain[Top], serveAncestors: true,
connectAfterCalls: 4, // beacons finish handshaking only after the 4th PeerInfo poll
}
bh.net = net
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
require.NoError(t, runBS(t, bh), "behind node must converge once the producer beacons connect")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[Top].id, last,
"behind node must converge to Top=%d after the connect delay, NEVER false-complete at stale M=%d", Top, M)
require.Greater(t, net.peerInfoCalls, net.connectAfterCalls,
"the loop must have WAITED through the connecting passes before naming the frontier")
}