fix(chains): FrontierTip reports beacon-connectivity status — wait for beacons before concluding caught-up (mainnet canary)

Node side of the stale-node convergence fix. blockHandler.FrontierTip now returns
a chainbootstrap.FrontierStatus instead of a bare ok bool, decomplecting the THREE
reasons a frontier may not be named so the bootstrap loop can WAIT for beacon
connectivity instead of false-completing at the local stale height:

  - no beacon set configured            -> FrontierNoBeacons  (single-node: complete)
  - beacons configured, < ⅔ stake up    -> FrontierConnecting (the boot race: WAIT)
  - ⅔ stake connected, no agreement      -> FrontierNoQuorum   (eclipse: fail safe)
  - ⅔-by-stake quorum agrees on a tip    -> FrontierNamed      (C1 gate: sync)

The discriminator is the SAME ⅔ floor (config.TwoThirdsStakeFloor over the FULL
beacon stake) used by the quorum tally: if even unanimous CONNECTED beacon stake
could not clear the floor (or too few distinct beacons are up), connectivity is
still coming up -> FrontierConnecting (the canary boot race, where FrontierTip ran
before any beacon handshaked). Only once enough beacon stake is connected do we
query + tally; a split that clears none -> FrontierNoQuorum.

The C1 forged-chain gate is UNCHANGED: the query + weighted ⅔-stake tally is
extracted verbatim into queryFrontierQuorum (a non-beacon peer, a single peer, or
any sub-⅔ set can still NEVER reach FrontierNamed). connectedBeaconsTrackingChain
is replaced by the smaller connectedBeacons helper (shared with
sampleAncestorBeacons — DRY). monitorBootstrap/watchBootstrapProgress need no
change: they already gate the chain-ready signal on BootstrapComplete (Run
success), which now returns only on genuine caught-up.

Tests: reproduces the canary over the REAL GetAcceptedFrontier/GetAncestors
transport (TestNodeBootstrap_StaleNodeWaitsForBeaconConnect — beacons connect after
a delay; the stale node WAITS through the connecting passes, then names the ⅔
frontier and converges, never false-completing at the stale height); the
forged-frontier-from-non-beacon case now FAILS SAFE (ErrBeaconsUnreachable) instead
of false-completing while still finalizing ZERO forged blocks; split-beacons report
FrontierNoQuorum; a no-beacon-set node reports FrontierNoBeacons (single-node
completes). All existing C1 quorum, sub-⅔, invalid-halt, framing, deliver-gating,
and H2 watchdog tests stay green.

Dev integration: node go.mod stays pinned consensus v1.25.34 (NO committed
replace); build/test against the consensus fix branch via
`GOWORK=off go mod edit -replace github.com/luxfi/consensus=../consensus`
(the go.work path is blocked by an unrelated ./compliance go.sum mismatch). Tag
consensus + bump node go.mod at the coordinated merge.
This commit is contained in:
zeekay
2026-06-26 21:00:59 -07:00
parent ce10acd10a
commit da8f57e6e9
2 changed files with 223 additions and 56 deletions
+81 -37
View File
@@ -83,29 +83,26 @@ func (b *blockHandler) beaconWeights() (weights map[ids.NodeID]uint64, total uin
return weights, total, true
}
// connectedBeaconsTrackingChain intersects the beacon set with the peers actually
// CONNECTED and tracking this chain. Only these beacons' frontier replies count toward
// the quorum, but the FULL beacon stake (total) stays the quorum DENOMINATOR — so a node
// must hear a ⅔-stake supermajority. An eclipse that reaches < ⅔ of beacon stake fails
// CLOSED (it cannot name a frontier) instead of syncing an attacker's chain.
func (b *blockHandler) connectedBeaconsTrackingChain() (connected []ids.NodeID, weights map[ids.NodeID]uint64, total uint64, ok bool) {
weights, total, ok = b.beaconWeights()
if !ok || b.net == nil {
return nil, nil, 0, false
// connectedBeacons returns the beacons from `weights` currently CONNECTED and tracking
// this chain. The frontier quorum is tallied over these, but the FULL beacon stake (the
// `total` from beaconWeights) stays the quorum DENOMINATOR — so a node must hear a ⅔-stake
// supermajority. An eclipse that reaches < ⅔ of beacon stake cannot name a frontier.
// Returns nil when none are connected (or there is no transport).
func (b *blockHandler) connectedBeacons(weights map[ids.NodeID]uint64) []ids.NodeID {
if b.net == nil || len(weights) == 0 {
return nil
}
beaconIDs := make([]ids.NodeID, 0, len(weights))
for id := range weights {
beaconIDs = append(beaconIDs, id)
}
var connected []ids.NodeID
for _, p := range b.net.PeerInfo(beaconIDs) {
if _, isBeacon := weights[p.ID]; isBeacon && p.TrackedChains.Contains(b.chainID) {
connected = append(connected, p.ID)
}
}
if len(connected) == 0 {
return nil, weights, total, false
}
return connected, weights, total, true
return connected
}
// sampleAncestorBeacons returns a small ROTATED sample of connected beacons to fetch
@@ -114,10 +111,14 @@ func (b *blockHandler) connectedBeaconsTrackingChain() (connected []ids.NodeID,
// serves: the loop's content-addressed descent rejects any block off the agreed
// frontier's parent chain, so a withholding/forging beacon costs only a re-sample.
func (b *blockHandler) sampleAncestorBeacons() (set.Set[ids.NodeID], bool) {
connected, _, _, ok := b.connectedBeaconsTrackingChain()
weights, _, ok := b.beaconWeights()
if !ok {
return nil, false
}
connected := b.connectedBeacons(weights)
if len(connected) == 0 {
return nil, false
}
start := int(b.bsRotor.Add(1)-1) % len(connected)
sample := set.NewSet[ids.NodeID](bootstrapAncestorSample)
for i := 0; i < len(connected) && sample.Len() < bootstrapAncestorSample; i++ {
@@ -126,25 +127,75 @@ func (b *blockHandler) sampleAncestorBeacons() (set.Set[ids.NodeID], bool) {
return sample, sample.Len() > 0
}
// FrontierTip implements chainbootstrap.BlockSource. It names the network frontier ONLY
// when a ⅔-BY-STAKE SUPERMAJORITY of the configured beacons agree on the SAME accepted
// tip — the C1 forged-chain gate. It queries every connected beacon, tallies their
// replies weighted by stake, and returns a tip only once its agreeing stake clears the
// shared live floor (config.TwoThirdsStakeFloor over the FULL beacon stake) with at
// least the minimum distinct beacons. ok=false (fail-closed) when no such quorum forms —
// a non-beacon peer, a single peer, or any sub-⅔-stake set can NEVER name the frontier,
// so a forged chain cannot be the thing an empty/behind node syncs to.
func (b *blockHandler) FrontierTip(ctx context.Context) (ids.ID, bool) {
if b.net == nil || b.msgCreator == nil {
return ids.Empty, false
// FrontierTip implements chainbootstrap.BlockSource. It returns a FrontierStatus that
// decomplects the THREE reasons a tip may not be named — the fix for the mainnet canary
// where a freshly-booted STALE node, asking the frontier BEFORE any beacon had connected,
// concluded "caught up" at its local stale height (a single ok=false meant both "no beacon
// quorum reachable yet" and "nothing ahead — done"):
//
// - FrontierNoBeacons — no beacon set configured (single-node / dev). Nothing to sync to.
// - FrontierConnecting — beacons configured but not enough stake CONNECTED yet to even
// FORM a ⅔ quorum (the boot race). The node cannot ASK — the loop must WAIT, never
// conclude caught-up here.
// - FrontierNoQuorum — enough beacon stake IS connected to ask, but no ⅔-by-stake
// agreement is reachable (genuine eclipse / partition). The loop fails SAFE.
// - FrontierNamed — a ⅔-by-stake SUPERMAJORITY of the configured beacons agreed on
// the SAME accepted tip (the C1 forged-chain gate, unchanged). The tip is meaningful
// ONLY in this case.
//
// A non-beacon peer, a single peer, or any sub-⅔-stake set can NEVER reach FrontierNamed,
// so a forged chain can never be the thing an empty/behind node syncs to.
func (b *blockHandler) FrontierTip(ctx context.Context) (ids.ID, chainbootstrap.FrontierStatus) {
weights, total, haveBeacons := b.beaconWeights()
if !haveBeacons {
// No beacon set configured (single-node / dev / --skip-bootstrap). Nothing to sync to.
return ids.Empty, chainbootstrap.FrontierNoBeacons
}
connected, weights, total, ok := b.connectedBeaconsTrackingChain()
if !ok {
// No reachable beacon quorum — fail closed (re-sampled by the loop). Once ⅔ of
// beacon stake is reachable and agrees, the quorum forms.
return ids.Empty, false
if b.net == nil || b.msgCreator == nil {
// Beacons configured but no transport to ask them — treat as still connecting
// (bounded by the loop's connect deadline). In practice runBootstrapThenPoll
// short-circuits a transport-less handler before Run is ever called.
return ids.Empty, chainbootstrap.FrontierConnecting
}
required := bootstrapMinAgreeingBeacons
if n := len(weights); n < required {
required = n
}
floor := consensusconfig.TwoThirdsStakeFloor(total)
// DECOMPLECT the canary boot race from a real eclipse: is enough beacon stake CONNECTED
// that a ⅔-by-stake quorum is even POSSIBLE right now? The most stake any single tip
// could gather is the connected stake; if that cannot clear the floor (or too few
// distinct beacons are up), connectivity is still coming up — report Connecting so the
// loop WAITS rather than false-completing at the local stale height. This is consistent
// with the named-quorum check below (agree > floor): if even unanimous connected beacons
// could not clear it, we have not yet connected enough to ask.
connected := b.connectedBeacons(weights)
var connectedStake uint64
for _, id := range connected {
connectedStake += weights[id]
}
if connectedStake <= floor || len(connected) < required {
return ids.Empty, chainbootstrap.FrontierConnecting
}
// Enough beacon stake is connected to ASK — query + tally the ⅔-by-stake quorum.
if tip, ok := b.queryFrontierQuorum(ctx, connected, weights, floor, required); ok {
return tip, chainbootstrap.FrontierNamed
}
// Connected enough to ask, but no ⅔-by-stake agreement on a single tip: a genuine
// eclipse / partition. Fail safe — the loop must NOT conclude caught-up at the stale tip.
return ids.Empty, chainbootstrap.FrontierNoQuorum
}
// queryFrontierQuorum sends GetAcceptedFrontier to the connected beacons and tallies their
// replies WEIGHTED by stake, returning a tip only once some tip's agreeing stake clears
// `floor` (⅔ of the FULL beacon stake) with ≥ `required` distinct beacons — the C1
// forged-chain gate (UNCHANGED from the original FrontierTip body; only the
// connecting/no-quorum discrimination above is new). ok=false when no such quorum forms
// within the collection window. ONE reply per beacon counts.
func (b *blockHandler) queryFrontierQuorum(ctx context.Context, connected []ids.NodeID, weights map[ids.NodeID]uint64, floor uint64, required int) (ids.ID, bool) {
ch := make(chan bsFrontierReply, len(connected))
b.bsMu.Lock()
b.bsFrontierCh = ch
@@ -168,13 +219,6 @@ func (b *blockHandler) FrontierTip(ctx context.Context) (ids.ID, bool) {
sample.Add(connected...)
b.net.Send(msg, sample, b.networkID, 0)
// Tally weighted replies until a tip clears the ⅔-by-stake floor with ≥ the minimum
// distinct beacons, or the window closes. ONE reply per beacon counts.
required := bootstrapMinAgreeingBeacons
if n := len(weights); n < required {
required = n
}
floor := consensusconfig.TwoThirdsStakeFloor(total)
agree := make(map[ids.ID]uint64)
voters := make(map[ids.ID]map[ids.NodeID]struct{})
seen := make(map[ids.NodeID]struct{})
+142 -19
View File
@@ -98,6 +98,18 @@ func newBSVM(chain []*bsTestBlock) *bsTestVM {
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.lastAcc = chain[m].id
return vm
}
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] {
@@ -156,6 +168,16 @@ type bsBeaconNet struct {
tip *bsTestBlock // the honest tip the beacons report
serveAncestors bool // beacons serve ancestry (false models name-only beacons)
// 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
// Optional malicious NON-beacon peer: connected and tracking, reports a forged tip.
malicious ids.NodeID
forgedTip ids.ID
@@ -165,15 +187,21 @@ type bsBeaconNet struct {
// 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.
func (n *bsBeaconNet) PeerInfo(nodeIDs []ids.NodeID) []peer.Info {
n.peerInfoCalls++
beaconsUp := n.connectAfterCalls == 0 || n.peerInfoCalls > n.connectAfterCalls
want := map[ids.NodeID]bool{}
for _, id := range nodeIDs {
want[id] = true
}
var out []peer.Info
for _, b := range n.connected {
if len(nodeIDs) == 0 || want[b] {
out = append(out, peer.Info{ID: b, TrackedChains: set.Of(n.chainID)})
if beaconsUp {
for _, b := range n.connected {
if len(nodeIDs) == 0 || want[b] {
out = append(out, peer.Info{ID: b, TrackedChains: set.Of(n.chainID)})
}
}
}
if n.malicious != ids.EmptyNodeID && (len(nodeIDs) == 0 || want[n.malicious]) {
@@ -189,9 +217,16 @@ func (n *bsBeaconNet) Send(msg message.OutboundMessage, nodeIDs set.Set[ids.Node
}
switch m.op {
case "frontier":
// Each queried beacon answers with the honest tip.
// Each queried beacon answers with the honest tip (or its tipFor override, modeling
// disagreement).
for id := range nodeIDs {
n.bh.deliverBootstrapFrontier(id, n.tip.id)
tip := n.tip.id
if n.tipFor != nil {
if t, ok := n.tipFor[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).
@@ -308,7 +343,13 @@ 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()})
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)
@@ -345,8 +386,10 @@ func TestNodeBootstrap_EmptyNodeConvergesViaTransport(t *testing.T) {
// 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 fails closed (no beacon quorum), the
// loop has "nothing to sync to", and ZERO forged blocks are finalized.
// 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)
@@ -365,17 +408,21 @@ func TestRED_ForgedFrontierFromNonBeaconRejected(t *testing.T) {
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
// FrontierTip must refuse to name a frontier (no beacon connected → no quorum). Drive
// it with bsActive so a reply WOULD be routed — proving it is the QUORUM, not an inert
// 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, ok := bh.FrontierTip(ctx)
tip, status := bh.FrontierTip(ctx)
bh.bsActive.Store(false)
require.False(t, ok, "C1: a non-beacon peer must NOT be able to name the frontier")
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: it converges (nothing to sync to) WITHOUT finalizing anything.
require.NoError(t, runBS(t, bh))
// 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.Has(ctx, forged[N].id), "C1: node must NOT hold the forged tip")
@@ -423,19 +470,95 @@ func TestRED_SubAlphaStakeCannotNameFrontier(t *testing.T) {
defer bh.bsActive.Store(false)
ctx := context.Background()
// Only 4 of 6 beacons connected (4*100 = 400, NOT > 400) → sub-quorum → no frontier.
// Only 4 of 6 beacons connected (4*100 = 400, NOT > 400 floor) → cannot even FORM a
// quorum → still CONNECTING (the loop waits for the rest; it never names a frontier
// here, and it certainly never concludes caught-up at the stale height).
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons[:4], byID: byID, tip: chain[N]}
tip, ok := bh.FrontierTip(ctx)
require.False(t, ok, "4/6 beacons (≤ ⅔ stake) must NOT name the frontier")
tip, status := bh.FrontierTip(ctx)
require.Equal(t, chainbootstrap.FrontierConnecting, status, "4/6 beacons (≤ ⅔ stake) cannot name the frontier — still connecting")
require.Equal(t, ids.Empty, tip)
// 5 of 6 connected (5*100 = 500 > 400) → quorum → the tip is named.
bh.net = &bsBeaconNet{bh: bh, chainID: chainID, connected: beacons[:5], byID: byID, tip: chain[N]}
tip, ok = bh.FrontierTip(ctx)
require.True(t, ok, "5/6 beacons (> ⅔ stake) must name the frontier")
tip, status = bh.FrontierTip(ctx)
require.Equal(t, chainbootstrap.FrontierNamed, status, "5/6 beacons (> ⅔ stake) 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.Has(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 DISAGREE — split across two tips so neither clears the ⅔ floor. FrontierTip reports
// FrontierNoQuorum (a genuine eclipse/partition signal), NOT a named/caught-up tip — the
// loop then fails safe (proven instantly at the consensus layer by
// TestLoop_BeaconsConnectedNoQuorum_FailsSafe).
func TestNodeBootstrap_BeaconsSplitNoQuorum(t *testing.T) {
const N = 30
chain, byID := buildBSChain(N, -1)
other, _ := buildBSChain(N, -1) // the dissenting half names this chain's tip
vm := newBSVMAt(chain, 10)
bh, chainID, beacons := newBSHandlerAndEngine(t, vm, 6)
// 6 connected (600 > 400 floor → enough to ASK), split 3/3 so neither tip clears 400.
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}
bh.msgCreator = bsMsgBuilder{}
bh.bsActive.Store(true)
tip, status := bh.FrontierTip(context.Background())
bh.bsActive.Store(false)
require.Equal(t, chainbootstrap.FrontierNoQuorum, status, "split beacons (no ⅔ agreement) → 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_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).