Files
2026-08-21 17:45:38 -07:00

964 lines
40 KiB
Go

// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// bootstrapper_test.go — drives the fetch+execute loop with an IN-MEMORY peer and
// node, proving:
// - an EMPTY node converges to a peer's tip in ONE batch (gap ≤ window) and across
// MANY batches (the descent: gap > window, fetched top-down, executed bottom-up);
// - a PARTIAL node converges from M to N;
// - an INVALID block from the peer HALTS the sync (the node never advances past it);
// - a node with NO peer ahead finishes immediately (nothing to sync to);
// - a peer that serves NOTHING stalls (a real failure is surfaced, not masked).
package bootstrap
import (
"context"
"strconv"
"testing"
"time"
"github.com/luxfi/consensus/engine/chain/block"
"github.com/luxfi/ids"
)
// tBlock is a minimal identity-preserving test block. Its bytes encode its id, so a
// ParseBlock round-trip recovers the same block from a shared registry.
type tBlock struct {
id, parent ids.ID
height uint64
bytes []byte
valid bool
}
func (b *tBlock) ID() ids.ID { return b.id }
func (b *tBlock) Parent() ids.ID { return b.parent }
func (b *tBlock) ParentID() ids.ID { return b.parent }
func (b *tBlock) Height() uint64 { return b.height }
func (b *tBlock) Timestamp() time.Time { return time.Unix(int64(b.height), 0) }
func (b *tBlock) Status() uint8 { return 0 }
func (b *tBlock) Verify(context.Context) error { return nil }
func (b *tBlock) Accept(context.Context) error { return nil }
func (b *tBlock) Reject(context.Context) error { return nil }
func (b *tBlock) Bytes() []byte { return b.bytes }
// chainOf builds a genesis→N chain. blocks[0] is genesis (height 0, parent Empty).
// invalidAt (if > 0) marks that height's block invalid (a corrupt/forged block).
func chainOf(n int, invalidAt uint64) ([]*tBlock, map[string]*tBlock) {
blocks := make([]*tBlock, 0, n+1)
reg := map[string]*tBlock{}
var parent ids.ID
for h := 0; h <= n; h++ {
blk := &tBlock{
id: ids.GenerateTestID(),
parent: parent,
height: uint64(h),
bytes: []byte("blk@" + strconv.Itoa(h) + ":" + ids.GenerateTestID().String()),
valid: uint64(h) != invalidAt,
}
reg[string(blk.bytes)] = blk
blocks = append(blocks, blk)
parent = blk.id
}
return blocks, reg
}
// testPeer is an in-memory BlockSource: a node holding the full chain genesis→tip. It
// reports FrontierNamed by default; `status` overrides that to model the no-beacon /
// connecting / no-quorum cases, and `connectAfter` models the CANARY boot race (the beacon
// set is still connecting for the first N frontier queries, then names the tip).
type testPeer struct {
byHeight []*tBlock
byID map[ids.ID]*tBlock
serveNothing bool // model a dead/withholding peer
status FrontierStatus // status to report once "connected" (default FrontierNamed)
connectAfter int // report FrontierConnecting for the first N queries, then `status`
noQuorumFor int // after connecting, report FrontierNoQuorum for the next N queries (a TRANSIENT split that settles), then `status`
frontierCalls int // observable: how many times the loop asked for the frontier
}
func newTestPeer(chain []*tBlock) *testPeer {
p := &testPeer{byHeight: chain, byID: map[ids.ID]*tBlock{}, status: FrontierNamed}
for _, b := range chain {
p.byID[b.id] = b
}
return p
}
func (p *testPeer) FrontierTip(context.Context) (ids.ID, FrontierStatus) {
p.frontierCalls++
// CANARY: beacons not connected yet for the first connectAfter queries.
if p.connectAfter > 0 && p.frontierCalls <= p.connectAfter {
return ids.Empty, FrontierConnecting
}
// TRANSIENT SPLIT (F2): connected enough to ASK, but the honest beacons are momentarily
// split across adjacent finalized tips for the next noQuorumFor queries (the live frontier
// is a moving target). A single one-shot tally here would mis-fire; the loop must RETRY and
// converge once the split settles.
if p.noQuorumFor > 0 && p.frontierCalls <= p.connectAfter+p.noQuorumFor {
return ids.Empty, FrontierNoQuorum
}
switch p.status {
case FrontierNamed:
if len(p.byHeight) == 0 {
return ids.Empty, FrontierNoBeacons
}
return p.byHeight[len(p.byHeight)-1].id, FrontierNamed
default: // FrontierNoBeacons / FrontierConnecting / FrontierNoQuorum
return ids.Empty, p.status
}
}
// Ancestors serves up to maxBlocks blocks ending at blockID, OLDEST-FIRST, walking
// down toward genesis — exactly the node's GetContext semantics.
func (p *testPeer) Ancestors(_ context.Context, blockID ids.ID, maxBlocks int) ([][]byte, error) {
if p.serveNothing {
return nil, nil
}
tip, ok := p.byID[blockID]
if !ok {
return nil, nil
}
var rev [][]byte
cur := tip
for i := 0; i < maxBlocks; i++ {
rev = append(rev, cur.bytes)
if cur.parent == ids.Empty {
break
}
cur = p.byID[cur.parent]
if cur == nil {
break
}
}
// reverse → oldest-first
out := make([][]byte, len(rev))
for i := range rev {
out[len(rev)-1-i] = rev[i]
}
return out, nil
}
// testNode is an in-memory Chain: it starts at some last-accepted height and accepts
// fetched blocks with the SAME contract as chain.Runtime.AcceptBootstrapBlock —
// contiguity (height == last+1, parent == tip) + re-execute (valid) — so the loop is
// exercised faithfully.
//
// It models the REAL VM's store-vs-acceptance distinction the prior mock missed: `have`
// is the BLOCK STORE (a block PRESENT — e.g. gossiped ahead of acceptance — which the real
// VM's GetBlock returns), and `accepted` is the ACCEPTED chain (finalized, what LastAccepted
// advances). A block can be in `have` but not `accepted` — a frontier and its run-up
// sitting in the store unaccepted. `Has` reads the store; `Accepted` reads the chain —
// and the loop's caught-up predicate uses `Accepted`, never `Has`.
type testNode struct {
reg map[string]*tBlock
have map[ids.ID]bool // BLOCK STORE: present (returnable by GetBlock), accepted-or-not
accepted map[ids.ID]bool // ACCEPTED chain: finalized blocks only
tipID ids.ID
height uint64
accepts int
}
func newTestNode(reg map[string]*tBlock, seed *tBlock) *testNode {
n := &testNode{reg: reg, have: map[ids.ID]bool{}, accepted: map[ids.ID]bool{}}
n.tipID = seed.id
n.height = seed.height
n.have[seed.id] = true
n.accepted[seed.id] = true
return n
}
func (n *testNode) ParseBlock(_ context.Context, b []byte) (block.Block, error) {
if blk, ok := n.reg[string(b)]; ok {
return blk, nil
}
return nil, errUnknownBytes
}
func (n *testNode) LastAccepted(context.Context) (ids.ID, uint64, error) {
return n.tipID, n.height, nil
}
// Has reports STORE presence (the block can be served by GetBlock) — accepted or not.
func (n *testNode) Has(_ context.Context, id ids.ID) bool { return n.have[id] }
// Accepted reports ACCEPTED-chain membership (finalized) — the loop's caught-up predicate.
// A block in the store but NOT accepted returns false (the freeze precondition), so the loop
// descends and accepts it instead of false-completing at the stale tip.
func (n *testNode) Accepted(_ context.Context, id ids.ID) bool { return n.accepted[id] }
func (n *testNode) AcceptBootstrapBlock(_ context.Context, b []byte) error {
blk, ok := n.reg[string(b)]
if !ok {
return errUnknownBytes
}
if blk.height <= n.height {
return nil // already accepted — responder overlap
}
if blk.height != n.height+1 || blk.parent != n.tipID {
return errOutOfOrder
}
if !blk.valid {
return errInvalidBlock // re-execute (Verify) failed
}
// Accepting a block already PRESENT in the store (gossiped-ahead) is the freeze case:
// it advances the ACCEPTED chain regardless of prior store presence.
n.height = blk.height
n.tipID = blk.id
n.have[blk.id] = true
n.accepted[blk.id] = true
n.accepts++
return nil
}
// wrapperAliasNode models the recovery boundary where this node accepted inner
// execution block H under local proposer envelope A, while the certified network
// path reaches H+1 through a different envelope B around that same inner H. B is
// not accepted as this node's outer tip, but it must be verified/stored before its
// child can resolve B as a parent.
type wrapperAliasNode struct {
reg map[string]*tBlock
held map[ids.ID]bool
accepted map[ids.ID]bool
tipID ids.ID
height uint64
aliases int
accepts int
}
func newWrapperAliasNode(reg map[string]*tBlock, local []*tBlock) *wrapperAliasNode {
n := &wrapperAliasNode{
reg: reg,
held: make(map[ids.ID]bool, len(local)),
accepted: make(map[ids.ID]bool, len(local)),
}
for _, blk := range local {
n.held[blk.id] = true
n.accepted[blk.id] = true
n.tipID = blk.id
n.height = blk.height
}
return n
}
func (n *wrapperAliasNode) ParseBlock(_ context.Context, b []byte) (block.Block, error) {
if blk, ok := n.reg[string(b)]; ok {
return blk, nil
}
return nil, errUnknownBytes
}
func (n *wrapperAliasNode) LastAccepted(context.Context) (ids.ID, uint64, error) {
return n.tipID, n.height, nil
}
func (n *wrapperAliasNode) Accepted(_ context.Context, id ids.ID) bool {
return n.accepted[id]
}
func (n *wrapperAliasNode) AcceptBootstrapBlock(_ context.Context, b []byte) error {
blk, ok := n.reg[string(b)]
if !ok {
return errUnknownBytes
}
if n.held[blk.id] {
return nil
}
if !blk.valid || !n.held[blk.parent] {
return errOutOfOrder
}
if blk.height <= n.height {
// The inner execution at this height is already accepted. Store the
// alternate outer envelope, but do not replace the local accepted tip.
n.held[blk.id] = true
n.aliases++
return nil
}
if blk.height != n.height+1 {
return errOutOfOrder
}
n.held[blk.id] = true
n.accepted[blk.id] = true
n.tipID = blk.id
n.height = blk.height
n.accepts++
return nil
}
type bootErr string
func (e bootErr) Error() string { return string(e) }
const (
errUnknownBytes = bootErr("unknown bytes")
errOutOfOrder = bootErr("out of order")
errInvalidBlock = bootErr("invalid block")
)
func runBootstrap(t *testing.T, peer BlockSource, node Chain) error {
t.Helper()
bs := New(Config{
Source: peer,
Chain: node,
MaxBlocksPerFetch: 256,
RetryInterval: time.Millisecond, // keep the stall/connect paths fast in tests
ConnectDeadline: 200 * time.Millisecond, // bound the beacon-connectivity wait in tests
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return bs.Run(ctx)
}
func TestLoop_EmptyNodeSyncsOneBatch(t *testing.T) {
const N = 50 // < window: one Ancestors batch reaches genesis
chain, reg := chainOf(N, 0)
peer := newTestPeer(chain)
node := newTestNode(reg, chain[0]) // empty: only genesis
if err := runBootstrap(t, peer, node); err != nil {
t.Fatalf("Run: %v", err)
}
if node.height != N {
t.Fatalf("empty node did not reach tip: height %d, want %d", node.height, N)
}
if node.accepts != N {
t.Fatalf("expected %d accepts (1..%d), got %d", N, N, node.accepts)
}
}
func TestLoop_EmptyNodeSyncsMultiBatchDescent(t *testing.T) {
const N = 600 // > window (256): forces the top-down descent + bottom-up execute
chain, reg := chainOf(N, 0)
peer := newTestPeer(chain)
node := newTestNode(reg, chain[0])
if err := runBootstrap(t, peer, node); err != nil {
t.Fatalf("Run: %v", err)
}
if node.height != N {
t.Fatalf("descent did not reach tip: height %d, want %d", node.height, N)
}
}
func TestLoop_PartialNodeConverges(t *testing.T) {
const N = 300
const M = 280
chain, reg := chainOf(N, 0)
peer := newTestPeer(chain)
node := newTestNode(reg, chain[M]) // partial: starts at height M
if err := runBootstrap(t, peer, node); err != nil {
t.Fatalf("Run: %v", err)
}
if node.height != N {
t.Fatalf("partial node did not converge: height %d, want %d", node.height, N)
}
if node.accepts != N-M {
t.Fatalf("expected %d accepts (M+1..N), got %d", N-M, node.accepts)
}
}
// TestLoop_DescendsThroughAlternateAcceptedHeightEnvelope reproduces the live
// 16,383 -> 16,384 recovery wedge. The local node accepted execution height H
// under outer envelope A, while the certified network child H+1 names alternate
// envelope B at height H. A height-only connection stops at H+1, never fetches B,
// and retries the child's missing parent forever. The descent must connect by an
// accepted parent ID, replay B as overlap, then verify and accept H+1.
func TestLoop_DescendsThroughAlternateAcceptedHeightEnvelope(t *testing.T) {
const H = 32
shared, reg := chainOf(H, 0)
local := shared
alias := &tBlock{
id: ids.GenerateTestID(),
parent: shared[H-1].id,
height: H,
bytes: []byte("alternate-wrapper@H"),
valid: true,
}
child := &tBlock{
id: ids.GenerateTestID(),
parent: alias.id,
height: H + 1,
bytes: []byte("certified-child@H+1"),
valid: true,
}
reg[string(alias.bytes)] = alias
reg[string(child.bytes)] = child
peerChain := append(append([]*tBlock{}, shared[:H]...), alias, child)
peer := newTestPeer(peerChain)
node := newWrapperAliasNode(reg, local)
if err := runBootstrap(t, peer, node); err != nil {
t.Fatalf("alternate-wrapper recovery must converge: %v", err)
}
if !node.held[alias.id] || node.aliases != 1 {
t.Fatalf("alternate height-H envelope must be stored exactly once before its child: held=%v aliases=%d", node.held[alias.id], node.aliases)
}
if node.tipID != child.id || node.height != H+1 || node.accepts != 1 {
t.Fatalf("node did not cross the wrapper boundary: tip=%s height=%d accepts=%d", node.tipID, node.height, node.accepts)
}
}
func TestLoop_OverlapEnvelopeDoesNotMaskRejectedChildAsProgress(t *testing.T) {
const H = 32
shared, reg := chainOf(H, 0)
alias := &tBlock{
id: ids.GenerateTestID(), parent: shared[H-1].id, height: H,
bytes: []byte("alternate-wrapper-before-invalid-child"), valid: true,
}
child := &tBlock{
id: ids.GenerateTestID(), parent: alias.id, height: H + 1,
bytes: []byte("invalid-certified-child"), valid: false,
}
reg[string(alias.bytes)] = alias
reg[string(child.bytes)] = child
peerChain := append(append([]*tBlock{}, shared[:H]...), alias, child)
node := newWrapperAliasNode(reg, shared)
if err := runBootstrap(t, newTestPeer(peerChain), node); err != ErrStalled {
t.Fatalf("a rejected child after overlap replay must stall, got %v", err)
}
if node.height != H || node.accepts != 0 || node.aliases != 1 {
t.Fatalf("overlap must be stored once without masking zero head progress: height=%d accepts=%d aliases=%d", node.height, node.accepts, node.aliases)
}
}
func TestLoop_InvalidBlockHaltsSync(t *testing.T) {
const N = 50
const bad = uint64(30) // the peer serves a corrupt block at height 30
chain, reg := chainOf(N, bad)
peer := newTestPeer(chain)
node := newTestNode(reg, chain[0])
// The invalid block at height 30 fails re-execution; the node accepts 1..29 then
// STOPS — it never advances past the unverifiable block, and the run surfaces a
// stall rather than reaching N.
err := runBootstrap(t, peer, node)
if err != ErrStalled {
t.Fatalf("expected ErrStalled (sync cannot pass the invalid block), got %v", err)
}
if node.height != bad-1 {
t.Fatalf("sync must halt at the block BELOW the invalid one (%d), got %d", bad-1, node.height)
}
}
// TestLoop_NoBeaconSet_SingleNodeFinishesImmediately: a node with NO beacon set configured
// (single-node / dev / --skip-bootstrap) has no network frontier to reach, so it completes
// at once — it must NOT hang waiting for a quorum that can never form. This is the
// single-node case the connectivity-wait must preserve.
func TestLoop_NoBeaconSet_SingleNodeFinishesImmediately(t *testing.T) {
const N = 10
chain, reg := chainOf(N, 0)
peer := newTestPeer(chain)
peer.status = FrontierNoBeacons // no beacon set — nothing to sync to
node := newTestNode(reg, chain[N])
if err := runBootstrap(t, peer, node); err != nil {
t.Fatalf("single-node (no beacons) bootstrap should finish cleanly, got %v", err)
}
if node.accepts != 0 {
t.Fatalf("no beacon set → nothing to accept, got %d accepts", node.accepts)
}
}
// TestLoop_StaleNodeConvergesAfterDelayedBeaconConnect REPRODUCES THE MAINNET CANARY and
// proves the fix. A STALE node sits at height M; the producers (beacons) are at N (gap
// within the window). At boot the beacons have NOT connected yet, so the FIRST few frontier
// queries return FrontierConnecting. THE BUG was: the loop read that as "nothing to sync to"
// and declared caughtUp at the stale height M (then entered live consensus, never to
// converge). THE FIX: the loop WAITS through the connecting passes — declaring caughtUp at M
// is impossible — and only once the beacons connect and a ⅔ quorum names tip N does it
// descend, execute the gap, and converge to N.
func TestLoop_StaleNodeConvergesAfterDelayedBeaconConnect(t *testing.T) {
const N = 40 // producers' (beacon-named) frontier height
const M = 23 // our STALE local height (gap N-M = 17 — the canary's gap-17, within window)
chain, reg := chainOf(N, 0)
peer := newTestPeer(chain)
peer.connectAfter = 5 // beacons connect only after the 5th frontier query (boot race)
node := newTestNode(reg, chain[M])
if err := runBootstrap(t, peer, node); err != nil {
t.Fatalf("stale node must converge once beacons connect, got %v", err)
}
// It must NOT have false-completed at the stale height M: it reached the beacon frontier N.
if node.height != N {
t.Fatalf("CANARY: node did not converge to the beacon frontier — height %d, want %d (false-complete at M=%d?)", node.height, N, M)
}
if node.accepts != N-M {
t.Fatalf("expected exactly the gap accepted (M+1..N = %d), got %d", N-M, node.accepts)
}
// And it provably WAITED for connectivity rather than concluding caught-up immediately:
// at minimum it polled through the connecting passes before naming the frontier.
if peer.frontierCalls <= peer.connectAfter {
t.Fatalf("loop must have WAITED through the connecting passes (got %d frontier calls, want > %d)", peer.frontierCalls, peer.connectAfter)
}
}
// TestLoop_GenuinelyCaughtUp_CompletesPromptly: beacons ARE connected and name a tip equal
// to our height (no peer ahead). The node completes at once with nothing fetched — the
// connectivity-wait must not delay a node that is actually synced.
func TestLoop_GenuinelyCaughtUp_CompletesPromptly(t *testing.T) {
const N = 25
chain, reg := chainOf(N, 0)
peer := newTestPeer(chain) // FrontierNamed, tip = N
node := newTestNode(reg, chain[N]) // already at N — holds the frontier tip
if err := runBootstrap(t, peer, node); err != nil {
t.Fatalf("a genuinely caught-up node must complete promptly, got %v", err)
}
if node.accepts != 0 {
t.Fatalf("caught-up node fetches nothing, got %d accepts", node.accepts)
}
}
// TestLoop_FreshNet_FullSetConnected_CaughtUpAtGenesis_Completes proves THE FRESH-NET FIX at the
// loop level. On a fresh network the node holds ONLY genesis and so does every connected validator.
// A node whose own stake makes the PEER-ONLY responder weight fall below the stake-majority NAMING
// floor (a heavy validator, a small beacon set, or skewed stake) can NEVER name a frontier even with
// the WHOLE validator set connected — the fresh-net hang (it sat in FrontierConnecting forever). The
// fixed BlockSource instead PROVES caught-up under FULL connectivity (the entire beacon set plus the
// node itself unanimously hold a tip the node already accepted — nothing ahead) and reports
// FrontierCaughtUp. The loop MUST complete at the node's own genesis height, fetching nothing — the
// decomplected caught-up signal, distinct from a named-tip descent.
func TestLoop_FreshNet_FullSetConnected_CaughtUpAtGenesis_Completes(t *testing.T) {
chain, reg := chainOf(0, 0) // a FRESH chain: genesis only
peer := newTestPeer(chain)
peer.status = FrontierCaughtUp // the source PROVED caught-up (full set + self all at genesis)
node := newTestNode(reg, chain[0]) // the node is at genesis
if err := runBootstrap(t, peer, node); err != nil {
t.Fatalf("a fresh node the source proved caught-up must complete, got %v", err)
}
if node.accepts != 0 {
t.Fatalf("caught-up at genesis fetches nothing, got %d accepts", node.accepts)
}
if node.height != 0 {
t.Fatalf("fresh node must stay at its genesis height (caught up, not synced ahead), got %d", node.height)
}
}
// TestLoop_FreshNet_ConnectingThenCaughtUp_WaitsThenCompletes proves the loop WAITS through the
// boot-race connecting passes — it must NOT false-complete while the beacon set is still forming —
// and then completes the INSTANT the source PROVES caught-up. This is the fresh-net analogue of the
// stale-node canary (TestLoop_StaleNodeConvergesAfterDelayedBeaconConnect), but it ends in
// FrontierCaughtUp (nothing to sync, the node IS the frontier) rather than a named-tip descent — so
// it guards the decomplecting: FrontierConnecting still means WAIT, only FrontierCaughtUp means DONE.
func TestLoop_FreshNet_ConnectingThenCaughtUp_WaitsThenCompletes(t *testing.T) {
chain, reg := chainOf(0, 0)
peer := newTestPeer(chain)
peer.connectAfter = 4 // the full beacon set is still connecting for the first 4 queries
peer.status = FrontierCaughtUp // then it has the full set + self and PROVES caught-up at genesis
node := newTestNode(reg, chain[0])
if err := runBootstrap(t, peer, node); err != nil {
t.Fatalf("fresh node must complete once the source proves caught-up, got %v", err)
}
if node.accepts != 0 {
t.Fatalf("nothing to fetch on a fresh net, got %d accepts", node.accepts)
}
// It provably WAITED through the connecting passes rather than concluding caught-up immediately.
if peer.frontierCalls <= peer.connectAfter {
t.Fatalf("loop must have WAITED through the connecting passes (got %d frontier calls, want > %d)", peer.frontierCalls, peer.connectAfter)
}
}
// TestLoop_StoredButUnacceptedFrontierDrivesAcceptance pins the acceptance-vs-store predicate
// on the exact state that traps a presence test. The node is at
// last-accepted N, but blocks N+1..N+K are ALREADY IN ITS STORE (gossiped / a prior incomplete
// sync) WITHOUT being accepted — and the beacons name the frontier at N+K, a tip the node HOLDS
// but has NOT accepted. THE BUG: Chain.Has(tip) returned true for that stored-but-unaccepted
// block, short-circuiting the loop to caught-up at the stale N (self-reinforcing — every restart
// re-concluded caught-up). THE FIX: Chain.Accepted(tip) is false (present ≠ accepted), so the
// loop DESCENDS and ACCEPTS N+1..N+K — through the per-height-guarded re-execute path, accepting
// the blocks already in the store — reaching N+K, never going Ready at the stale N.
func TestLoop_StoredButUnacceptedFrontierDrivesAcceptance(t *testing.T) {
const N, K = 100, 16 // a frontier 16 above last-accepted, every block of it already stored
chain, reg := chainOf(N+K, 0)
peer := newTestPeer(chain) // beacons hold + name the real frontier (tip N+K)
node := newTestNode(reg, chain[N]) // ACCEPTED only up to N
// N+1..N+K are PRESENT IN THE STORE (gossiped ahead) but UNACCEPTED.
for h := N + 1; h <= N+K; h++ {
node.have[chain[h].id] = true
}
ctx := context.Background()
// Precondition — the trapping state: the node HOLDS the named frontier yet has NOT
// accepted it (last-accepted is N).
if !node.Has(ctx, chain[N+K].id) {
t.Fatal("precondition: the frontier must be present in the store (gossiped ahead)")
}
if node.Accepted(ctx, chain[N+K].id) {
t.Fatal("precondition: the frontier must NOT be accepted (last-accepted is N)")
}
if err := runBootstrap(t, peer, node); err != nil {
t.Fatalf("Run: %v", err)
}
// THE FREEZE ASSERTION: the node must ACCEPT the stored run-up and reach N+K — not freeze at N.
if node.height != N+K {
t.Fatalf("FREEZE: node concluded caught-up at the stale height %d — want %d (it held N+1..N+K stored-but-unaccepted)", node.height, N+K)
}
if !node.Accepted(ctx, chain[N+K].id) {
t.Fatal("the named frontier must be ACCEPTED after sync")
}
if node.accepts != K {
t.Fatalf("expected exactly the stored run-up accepted (N+1..N+K = %d), got %d", K, node.accepts)
}
}
// frozenLedgerNode models a frozen-LastAccepted node at the Chain-interface level (red HIGH-1):
// LastAccepted is FROZEN at the boot height for the process life (the ZAP client caches its
// last-accepted id at Initialize and a fire-and-forget Accept never refreshes it), while the
// consensus finalized ledger (finalizedHeight/finalizedTip — what AcceptBootstrapBlock advances
// and what Accepted reads) DOES advance. This is the separation the unified testNode could not
// express (it advanced n.height for BOTH). It proves the loop drives convergence off the ADVANCING
// source it queries through Accepted — NOT off the frozen LastAccepted: a Chain whose Accepted
// advances converges in ONE Run even with LastAccepted pinned. The node side guarantees Accepted
// reads the advancing ledger (bootstrap_sync.go) — here we lock in that the LOOP keeps trusting it.
type frozenLedgerNode struct {
reg map[string]*tBlock
frozenID ids.ID // LastAccepted id — FROZEN at boot for the run
frozenHeight uint64 // LastAccepted height — FROZEN
finalizedTip ids.ID // advancing finalized ledger tip
finalizedHeight uint64 // advancing finalized ledger height
finalized map[ids.ID]bool // advancing accepted set (what Accepted reads)
accepts int
}
func newFrozenLedgerNode(reg map[string]*tBlock, seed *tBlock) *frozenLedgerNode {
return &frozenLedgerNode{
reg: reg,
frozenID: seed.id,
frozenHeight: seed.height,
finalizedTip: seed.id,
finalizedHeight: seed.height,
finalized: map[ids.ID]bool{seed.id: true},
}
}
func (n *frozenLedgerNode) ParseBlock(_ context.Context, b []byte) (block.Block, error) {
if blk, ok := n.reg[string(b)]; ok {
return blk, nil
}
return nil, errUnknownBytes
}
// LastAccepted returns the FROZEN boot position — never advances, modeling the ZAP cache.
func (n *frozenLedgerNode) LastAccepted(context.Context) (ids.ID, uint64, error) {
return n.frozenID, n.frozenHeight, nil
}
// Accepted reads the ADVANCING finalized ledger (the node side wires this to the in-process
// consensus ledger, never the frozen cache).
func (n *frozenLedgerNode) Accepted(_ context.Context, id ids.ID) bool { return n.finalized[id] }
// AcceptBootstrapBlock advances the finalized ledger and no-ops blocks at/below the ADVANCING
// finalizedHeight — exactly the real engine guard (consensus.GetFinalizedHeight), NOT LastAccepted.
func (n *frozenLedgerNode) AcceptBootstrapBlock(_ context.Context, b []byte) error {
blk, ok := n.reg[string(b)]
if !ok {
return errUnknownBytes
}
if blk.height <= n.finalizedHeight {
return nil // already finalized — responder overlap (reads the LEDGER, not the frozen cache)
}
if blk.height != n.finalizedHeight+1 || blk.parent != n.finalizedTip {
return errOutOfOrder
}
if !blk.valid {
return errInvalidBlock
}
n.finalizedHeight = blk.height
n.finalizedTip = blk.id
n.finalized[blk.id] = true
n.accepts++
return nil
}
// TestLoop_FrozenLastAcceptedConvergesViaAdvancingLedger is the loop-contract guard for red HIGH-1:
// the caught-up predicate must ride the ADVANCING acceptance oracle (Accepted), so a Chain whose
// LastAccepted is FROZEN but whose finalized ledger ADVANCES still converges in ONE Run. If a
// refactor ever made the loop conclude caught-up off LastAccepted alone, this node — frozen at M —
// would re-descend every pass while AcceptBootstrapBlock no-ops the already-finalized run-up and
// the run would ErrStalled instead of returning nil.
func TestLoop_FrozenLastAcceptedConvergesViaAdvancingLedger(t *testing.T) {
const N, M = 60, 24 // stale node booted at M; the descent must finalize M+1..N
chain, reg := chainOf(N, 0)
peer := newTestPeer(chain)
node := newFrozenLedgerNode(reg, chain[M])
if err := runBootstrap(t, peer, node); err != nil {
t.Fatalf("Run must converge off the advancing finalized ledger despite a frozen LastAccepted: %v", err)
}
if node.finalizedHeight != N {
t.Fatalf("finalized ledger must reach the frontier: height %d, want %d", node.finalizedHeight, N)
}
if node.accepts != N-M {
t.Fatalf("expected exactly M+1..N accepted (%d), got %d", N-M, node.accepts)
}
// PREMISE: LastAccepted never moved — the bug only matters because the cache is frozen.
if _, h, _ := node.LastAccepted(context.Background()); h != M {
t.Fatalf("premise: LastAccepted must stay frozen at M=%d, got %d", M, h)
}
}
// TestLoop_ForgedStoredBlockNeverAccepted is the SAFETY control for the acceptance-vs-store fix
// (C1 preserved): a block sitting in the node's STORE that is NOT on the beacon-named frontier's
// content-addressed chain (a forged / uncertified block gossiped in) must NEVER be accepted
// merely because it is present. The honest beacons name the REAL tip N; the content-addressed
// descent accepts ONLY the real chain, and the forged stored block is finalized at no point —
// the fix drives acceptance of stored blocks ON the frontier path, never of stored junk off it.
func TestLoop_ForgedStoredBlockNeverAccepted(t *testing.T) {
const N = 40
chain, reg := chainOf(N, 0)
peer := newTestPeer(chain) // honest beacons: name + serve the real chain, tip N
node := newTestNode(reg, chain[N/2]) // accepted up to N/2
// A FORGED block gossiped into the store at a height the node has NOT reached: present in the
// store (`have`) and decodable (registered), but OFF the honest chain (random parent), and
// even Verify-passing — to prove presence alone never finalizes it off the frontier path.
forged := &tBlock{
id: ids.GenerateTestID(),
parent: ids.GenerateTestID(), // not the honest chain's link
height: uint64(N/2 + 1),
bytes: []byte("forged@" + strconv.Itoa(N/2+1) + ":" + ids.GenerateTestID().String()),
valid: true,
}
reg[string(forged.bytes)] = forged
node.have[forged.id] = true
ctx := context.Background()
if node.Accepted(ctx, forged.id) {
t.Fatal("precondition: the forged block must NOT be accepted")
}
if err := runBootstrap(t, peer, node); err != nil {
t.Fatalf("Run: %v", err)
}
if node.height != N {
t.Fatalf("node must converge to the honest frontier N=%d, got %d", N, node.height)
}
if node.Accepted(ctx, forged.id) {
t.Fatal("C1: a forged block in the store must NEVER be accepted just because it is present")
}
}
// TestLoop_BeaconsNeverConnect_FailsSafe (red's LOW, folded in): a node with beacons
// configured but that NEVER reach the connectivity to form a ⅔ quorum must FAIL SAFE — it
// stays un-bootstrapped (ErrBeaconsUnreachable), NEVER false-completing at its stale height.
func TestLoop_BeaconsNeverConnect_FailsSafe(t *testing.T) {
const N = 30
const M = 10
chain, reg := chainOf(N, 0)
peer := newTestPeer(chain)
peer.status = FrontierConnecting // beacons configured but never enough connected
node := newTestNode(reg, chain[M])
err := runBootstrap(t, peer, node)
if err != ErrBeaconsUnreachable {
t.Fatalf("eclipsed-at-boot node must fail safe with ErrBeaconsUnreachable, got %v", err)
}
if node.height != M || node.accepts != 0 {
t.Fatalf("FALSE-COMPLETE: node advanced past its stale height (height %d, %d accepts) — must finalize nothing", node.height, node.accepts)
}
}
// TestLoop_BeaconsConnectedNoQuorum_FailsSafe: beacons ARE connected (enough to ask) but no
// ⅔-by-stake supermajority agrees on a single frontier (eclipse/partition/disagreement).
// The node must FAIL SAFE — not conclude caught-up at the stale height.
func TestLoop_BeaconsConnectedNoQuorum_FailsSafe(t *testing.T) {
const N = 30
const M = 10
chain, reg := chainOf(N, 0)
peer := newTestPeer(chain)
peer.status = FrontierNoQuorum // connected, but no ⅔ agreement
node := newTestNode(reg, chain[M])
err := runBootstrap(t, peer, node)
if err != ErrNoBeaconQuorum {
t.Fatalf("no-⅔-quorum node must fail safe with ErrNoBeaconQuorum, got %v", err)
}
if node.height != M || node.accepts != 0 {
t.Fatalf("FALSE-COMPLETE: node advanced past its stale height (height %d, %d accepts) — must finalize nothing", node.height, node.accepts)
}
}
// TestRED_TransientNoQuorumOnHealthyNodeMustConverge is red's F2 PoC — the fix that makes the
// mainnet roll deterministic. A HEALTHY STALE node (height M, gap within the window) boots into
// a LIVE chain whose frontier is a MOVING TARGET. The beacons connect (a few FrontierConnecting
// passes), then on the very first tally — at the instant connectivity crosses the ⅔ floor, with
// the freshest least-settled beacons — they are momentarily SPLIT across adjacent finalized tips
// (FrontierNoQuorum). One more poll clears it (the split settles → FrontierNamed). The PRE-FIX
// loop returned ErrNoBeaconQuorum on that single transient NoQuorum and the node stuck at M,
// never reaching N (then the watchdog → restart). THE FIX: the loop RETRIES the transient
// no-quorum, the next tally names tip N, and the node descends + converges to N.
func TestRED_TransientNoQuorumOnHealthyNodeMustConverge(t *testing.T) {
const N = 40 // producers' (beacon-named) frontier height
const M = 23 // our STALE local height (gap N-M = 17 — the canary's gap-17, within window)
chain, reg := chainOf(N, 0)
peer := newTestPeer(chain)
peer.connectAfter = 2 // boot race: 2 FrontierConnecting passes
peer.noQuorumFor = 1 // then exactly ONE transient split (FrontierNoQuorum) before it settles
node := newTestNode(reg, chain[M])
if err := runBootstrap(t, peer, node); err != nil {
t.Fatalf("a healthy node hitting a TRANSIENT no-quorum split must converge, not fail terminally, got %v", err)
}
// It must NOT have failed terminally at M on the transient: it reached the beacon frontier N.
if node.height != N {
t.Fatalf("F2: node did not converge to the beacon frontier — height %d, want %d (terminal-NoQuorum at M=%d?)", node.height, N, M)
}
if node.accepts != N-M {
t.Fatalf("expected exactly the gap accepted (M+1..N = %d), got %d", N-M, node.accepts)
}
// And it provably RETRIED past the connecting AND the transient no-quorum passes.
if peer.frontierCalls <= peer.connectAfter+peer.noQuorumFor {
t.Fatalf("loop must have RETRIED through the connecting + transient no-quorum passes (got %d frontier calls, want > %d)",
peer.frontierCalls, peer.connectAfter+peer.noQuorumFor)
}
}
// TestRED_PersistentNoQuorumStillFailsSafe is the F2 control: a PERSISTENT no-quorum (genuine
// eclipse / partition / ≥⅓ Byzantine beacon stake) must STILL fail safe after the bounded retry
// — the retry must not turn a real liveness stall into an infinite spin or, worse, a
// false-complete. The node finalizes NOTHING and surfaces ErrNoBeaconQuorum.
func TestRED_PersistentNoQuorumStillFailsSafe(t *testing.T) {
const N = 30
const M = 10
chain, reg := chainOf(N, 0)
peer := newTestPeer(chain)
peer.status = FrontierNoQuorum // connected, but no ⅔ agreement EVER (persistent)
node := newTestNode(reg, chain[M])
err := runBootstrap(t, peer, node)
if err != ErrNoBeaconQuorum {
t.Fatalf("a PERSISTENT no-quorum must fail safe with ErrNoBeaconQuorum after the bound, got %v", err)
}
if node.height != M || node.accepts != 0 {
t.Fatalf("FALSE-COMPLETE: persistent no-quorum advanced the node (height %d, %d accepts) — must finalize nothing", node.height, node.accepts)
}
// It must have actually RETRIED (polled the frontier more than once) before giving up — a
// persistent no-quorum is bounded, not immediate (default MaxNoQuorumRounds = 10).
if peer.frontierCalls != defaultMaxNoQuorumRounds {
t.Fatalf("persistent no-quorum must fail at exactly the bound (%d rounds), got %d frontier calls", defaultMaxNoQuorumRounds, peer.frontierCalls)
}
}
// TestRED_NoQuorumBoundedRetryCount pins the bounded-retry COUNT precisely: with the bound set
// to N, a no-quorum that CLEARS in N-1 rounds CONVERGES, while one that PERSISTS to round N
// fails safe with ErrNoBeaconQuorum at exactly that bound (it never retries unboundedly and
// never false-completes). This is the transient/persistent boundary the whole F2 fix turns on.
func TestRED_NoQuorumBoundedRetryCount(t *testing.T) {
const N = 40
const M = 20
const bound = 5
runWithBound := func(peer *testPeer, node *testNode) error {
bs := New(Config{
Source: peer, Chain: node,
RetryInterval: time.Millisecond,
ConnectDeadline: time.Second,
MaxNoQuorumRounds: bound,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return bs.Run(ctx)
}
// CLEARS within the bound (bound-1 transient rounds) → converges.
chainOK, regOK := chainOf(N, 0)
peerOK := newTestPeer(chainOK)
peerOK.noQuorumFor = bound - 1 // 4 transient rounds, then named — under the bound of 5
nodeOK := newTestNode(regOK, chainOK[M])
if err := runWithBound(peerOK, nodeOK); err != nil {
t.Fatalf("a no-quorum clearing in %d rounds (< bound %d) must converge, got %v", bound-1, bound, err)
}
if nodeOK.height != N {
t.Fatalf("transient (cleared in %d rounds) must converge to N=%d, got %d", bound-1, N, nodeOK.height)
}
// PERSISTS past the bound → fails safe at exactly `bound` no-quorum rounds.
chainBad, regBad := chainOf(N, 0)
peerBad := newTestPeer(chainBad)
peerBad.noQuorumFor = bound + 3 // still NoQuorum when the bound is hit
nodeBad := newTestNode(regBad, chainBad[M])
if err := runWithBound(peerBad, nodeBad); err != ErrNoBeaconQuorum {
t.Fatalf("a no-quorum persisting past the bound (%d rounds) must fail safe with ErrNoBeaconQuorum, got %v", bound, err)
}
if nodeBad.height != M || nodeBad.accepts != 0 {
t.Fatalf("FALSE-COMPLETE: persistent no-quorum advanced the node (height %d, %d accepts) — must finalize nothing", nodeBad.height, nodeBad.accepts)
}
if peerBad.frontierCalls != bound {
t.Fatalf("bounded retry must fail at EXACTLY %d no-quorum rounds, got %d frontier calls", bound, peerBad.frontierCalls)
}
}
// invalidStatusSource returns an UNDEFINED FrontierStatus (the zero value / out-of-range) every
// call — modeling a future FrontierTip bug. The loop must FAIL SAFE (never read it as caught-up).
type invalidStatusSource struct{ raw FrontierStatus }
func (s invalidStatusSource) FrontierTip(context.Context) (ids.ID, FrontierStatus) {
return ids.Empty, s.raw
}
func (invalidStatusSource) Ancestors(context.Context, ids.ID, int) ([][]byte, error) {
return nil, nil
}
// TestRED_UndefinedFrontierStatusFailsSafe is the F4 default-case proof: an out-of-range or
// zero-value FrontierStatus (FrontierInvalid) — which the type now defines as meaningless — must
// fall through syncOnce's fail-safe default to a bounded-retry-then-ErrNoBeaconQuorum, NOT to a
// false-complete at the stale height. Defense-in-depth: even a status the type does not define
// can never finalize a node short of the frontier.
func TestRED_UndefinedFrontierStatusFailsSafe(t *testing.T) {
const N = 30
const M = 10
for _, raw := range []FrontierStatus{FrontierInvalid, FrontierStatus(99)} {
chain, reg := chainOf(N, 0)
node := newTestNode(reg, chain[M])
bs := New(Config{
Source: invalidStatusSource{raw: raw}, Chain: node,
RetryInterval: time.Millisecond,
ConnectDeadline: 100 * time.Millisecond,
MaxNoQuorumRounds: 3,
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
err := bs.Run(ctx)
cancel()
if err != ErrNoBeaconQuorum {
t.Fatalf("undefined status %d must fail safe (bounded retry → ErrNoBeaconQuorum), got %v", raw, err)
}
if node.height != M || node.accepts != 0 {
t.Fatalf("FALSE-COMPLETE on undefined status %d: node advanced (height %d, %d accepts) — must finalize nothing", raw, node.height, node.accepts)
}
}
}
func TestLoop_AlreadyAtFrontier_FinishesImmediately(t *testing.T) {
const N = 10
chain, reg := chainOf(N, 0)
peer := newTestPeer(chain)
node := newTestNode(reg, chain[N]) // already holds the frontier tip
if err := runBootstrap(t, peer, node); err != nil {
t.Fatalf("already-synced bootstrap should finish cleanly, got %v", err)
}
if node.accepts != 0 {
t.Fatalf("already at frontier → nothing to accept, got %d accepts", node.accepts)
}
}
func TestLoop_DeadPeerStalls(t *testing.T) {
const N = 50
chain, reg := chainOf(N, 0)
peer := newTestPeer(chain)
peer.serveNothing = true // advertises a frontier but never serves blocks
node := newTestNode(reg, chain[0])
if err := runBootstrap(t, peer, node); err != ErrStalled {
t.Fatalf("a peer that serves nothing must stall, got %v", err)
}
if node.height != 0 {
t.Fatalf("no block should have been accepted from a dead peer, height %d", node.height)
}
}