Files
node/chains/bootstrap_trust_test.go
zeekayandHanzo Dev 6a87c69010 node: signed-checkpoint bootstrap anchor (INVARIANT 4) + consensus v1.35.16
Harden the bootstrap checkpoint — the one path that bypasses the beacon quorum —
into a cryptographically SIGNED weak-subjectivity anchor.

- Checkpoint carries an authority Signature over a domain-separated canonical
  (id,height); CheckpointVerifier (injected, backed by a proven primitive —
  Ed25519/BLS, never custom crypto) authenticates it. The policy stays crypto-free
  exactly like AncestrySource/heightOf.
- AcceptsFrontier trusts a pinned checkpoint ONLY when the configured authority
  signed that exact (id,height). Present-but-unsigned, signed-by-a-non-authority,
  signature-transplanted-to-another-(id,height), and no-verifier-wired all FAIL
  CLOSED. A compromised flag cannot inject a false sync anchor without also forging
  the authority's signature. It is a cryptographic vouch, NEVER a ⅔-live-stake tally.
- No production path pins a checkpoint yet (opt-in operator escape hatch), so the
  invariant is enforced for when it is activated; nothing to wire now.

Folds consensus v1.35.16 (single ⅔ formula + leaf-lock doctrine) into the node.

Co-authored-by: Hanzo Dev <dev@hanzo.ai>
2026-07-02 17:49:54 -07:00

919 lines
45 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// bootstrap_trust_test.go — the AG proof matrix for the BootstrapTrust policy: the SEPARATE
// trust object (distinct from consensus finality) that lets a node recover when validators are
// down (mass recovery) while refusing partition-capture and never weakening finality.
//
// Most cases test the POLICY decision (AcceptsFrontier) directly — deterministic, no network
// timing — since that IS the acceptance gate the owner specified. The mass-recovery success (A)
// and the global-tally height-floor guard also run the FULL fetch+execute loop over the real
// transport to prove the node converges (or fails safe) end to end. Each is load-bearing: revert
// the response-floor policy to the prior ⅔-of-current-total-stake gate and A deadlocks; drop the
// configured-beacon filter and D/E capture; drop the MinFrontierHeight floor and the shared-
// genesis fork false-completes.
package chains
import (
"context"
"crypto/ed25519"
"testing"
"github.com/stretchr/testify/require"
consensusconfig "github.com/luxfi/consensus/config"
chainbootstrap "github.com/luxfi/consensus/engine/chain/bootstrap"
"github.com/luxfi/ids"
)
// ----- policy test helpers --------------------------------------------------
// stubAncestry is an in-memory AncestrySource: it walks a parent-linked BlockRef map down from a
// tip, exactly as the real wire transport would serve content-addressed ancestry. Modeling the
// transport this way keeps the policy unit tests deterministic while exercising the real ancestor-
// tolerant tally. `withhold` models a beacon that names a tip but does NOT serve its ancestry.
type stubAncestry struct {
byID map[ids.ID]BlockRef
withhold map[ids.ID]bool
}
func (s *stubAncestry) Ancestry(_ context.Context, tip ids.ID, max int) ([]BlockRef, error) {
if s.withhold[tip] {
return nil, nil
}
var out []BlockRef
cur := tip
for i := 0; i < max; i++ {
ref, ok := s.byID[cur]
if !ok {
break
}
out = append(out, ref)
if ref.Parent == ids.Empty {
break
}
cur = ref.Parent
}
return out, nil
}
// refChain builds genesis..n as content-addressed BlockRefs (parent-linked), returning the slice
// and an id→ref index for the stub AncestrySource.
func refChain(n int) ([]BlockRef, map[ids.ID]BlockRef) {
refs := make([]BlockRef, 0, n+1)
byID := map[ids.ID]BlockRef{}
var parent ids.ID
for h := 0; h <= n; h++ {
r := BlockRef{ID: ids.GenerateTestID(), Height: uint64(h), Parent: parent}
refs = append(refs, r)
byID[r.ID] = r
parent = r.ID
}
return refs, byID
}
// childRef makes a block extending `parent` at height parentHeight+1 — used to forge a "higher"
// sibling tip built on a real block.
func childRef(parent BlockRef) BlockRef {
return BlockRef{ID: ids.GenerateTestID(), Height: parent.Height + 1, Parent: parent.ID}
}
func nodeIDs(n int) []ids.NodeID {
out := make([]ids.NodeID, n)
for i := range out {
out[i] = ids.GenerateTestNodeID()
}
return out
}
// equalBeacons builds a TrustedBeacons map of equal-weight validators.
func equalBeacons(beacons []ids.NodeID, w uint64) map[ids.NodeID]StakeWeight {
m := make(map[ids.NodeID]StakeWeight, len(beacons))
for _, id := range beacons {
m[id] = w
}
return m
}
func reply(id ids.NodeID, tip ids.ID, w uint64) BeaconReply {
return BeaconReply{NodeID: id, Tip: tip, Weight: w}
}
// equalStake is the owner's mainnet shape: 5 validators each 0.5e18, total 2.5e18.
const equalStake uint64 = 500_000_000_000_000_000
// ----- A: MASS RECOVERY SUCCESS ---------------------------------------------
// TestBootstrapTrust_A_MassRecoverySucceeds is THE deadlock fix. 5 EQUAL-weight validators; the 2
// stranded recovery targets are down, so only 3 are reachable; the 3 reachable agree on the
// frontier. With MinResponses=3 the policy ACCEPTS — even though 3 of 5 stake (1.5e18) is BELOW
// the ⅔-of-current-total floor (1.667e18) that the prior code required to be CONNECTED. That old
// floor was mathematically unsatisfiable here (the down nodes ARE validators), which is exactly
// why no node could recover. This test pins both: the policy accepts, AND the old gate would have
// rejected (the deadlock), AND the full loop converges over the real transport.
func TestBootstrapTrust_A_MassRecoverySucceeds(t *testing.T) {
// The deadlock the fix escapes: 3-of-5 connected stake does NOT clear ⅔ of the total set.
require.LessOrEqual(t, 3*equalStake, consensusconfig.TwoThirdsStakeFloor(5*equalStake),
"precondition: 3 of 5 equal validators is BELOW ⅔ of total — the prior connect gate's deadlock")
// Policy decision: 5 configured, 3 reachable agree on the frontier (mainnet analog 1082796).
beacons := nodeIDs(5)
frontier := ids.GenerateTestID()
policy := &BootstrapPolicy{
TrustedBeacons: equalBeacons(beacons, equalStake),
MinResponses: 3,
}
replies := []BeaconReply{
reply(beacons[0], frontier, equalStake),
reply(beacons[1], frontier, equalStake),
reply(beacons[2], frontier, equalStake),
// beacons[3], beacons[4] are down/stranded — no reply.
}
f, err := policy.AcceptsFrontier(context.Background(), replies)
require.NoError(t, err, "3 of 5 reachable beacons agreeing MUST be accepted — the mass-recovery case")
require.Equal(t, frontier, f.ID)
require.Equal(t, 3, f.Responders)
require.False(t, f.FromCheckpoint)
// End to end over the real GetAcceptedFrontier/GetAncestors transport: a STALE node with only
// 3 of its 5 equal-weight validators reachable converges to the frontier N (not stuck at M).
const N = 40
const M = 23
chain, byID := buildBSChain(N, -1)
vm := newBSVMAt(chain, M)
v := nodeIDs(5)
weights := equalBeacons(v, equalStake)
bh, chainID := newBSHandlerWeighted(t, vm, weights)
bh.bootstrapMinResponses = 3 // the owner's MinBootstrapResponses=3
bh.net = &bsBeaconNet{
bh: bh, chainID: chainID, connected: []ids.NodeID{v[0], v[1], v[2]}, // 2 stranded down
byID: byID, tip: chain[N], serveAncestors: true,
}
bh.msgCreator = bsMsgBuilder{}
ctx := context.Background()
bh.bsActive.Store(true)
tip, status := bh.FrontierTip(ctx)
bh.bsActive.Store(false)
require.Equal(t, chainbootstrap.FrontierNamed, status,
"MASS RECOVERY: 3 of 5 equal validators reachable + agreeing must NAME the frontier (no deadlock)")
require.Equal(t, chain[N].id, tip)
require.NoError(t, runBS(t, bh), "mass-recovery node must converge")
last, _ := vm.LastAccepted(ctx)
require.Equal(t, chain[N].id, last, "RECOVERED: converged to the frontier N=%d despite 2 of 5 validators down", N)
require.True(t, bh.Accepted(ctx, chain[N].id))
}
// ----- B: ONE-BEACON CAPTURE REJECTED ---------------------------------------
// TestBootstrapTrust_B_OneBeaconCaptureRejected: 5 configured, only 1 reachable. A single beacon —
// even an authentic configured one — cannot name the frontier (it could be the attacker's lone
// peer in an eclipse). The response FLOOR rejects it.
func TestBootstrapTrust_B_OneBeaconCaptureRejected(t *testing.T) {
beacons := nodeIDs(5)
policy := &BootstrapPolicy{TrustedBeacons: equalBeacons(beacons, equalStake), MinResponses: 3}
replies := []BeaconReply{reply(beacons[0], ids.GenerateTestID(), equalStake)}
f, err := policy.AcceptsFrontier(context.Background(), replies)
require.Nil(t, f)
require.ErrorIs(t, err, ErrInsufficientBootstrapResponses,
"1 of 5 reachable must be REJECTED (capture) — below the MinResponses floor")
}
// ----- C: TWO-BEACON PARTITION REJECTED -------------------------------------
// TestBootstrapTrust_C_TwoBeaconPartitionRejected: 5 configured, 2 reachable AGREEING. Two beacons
// is still below MinResponses=3, so the policy rejects by default — an attacker who partitions the
// node down to 2 beacons cannot capture the frontier even if both agree.
func TestBootstrapTrust_C_TwoBeaconPartitionRejected(t *testing.T) {
beacons := nodeIDs(5)
frontier := ids.GenerateTestID()
policy := &BootstrapPolicy{TrustedBeacons: equalBeacons(beacons, equalStake), MinResponses: 3}
replies := []BeaconReply{
reply(beacons[0], frontier, equalStake),
reply(beacons[1], frontier, equalStake),
}
f, err := policy.AcceptsFrontier(context.Background(), replies)
require.Nil(t, f)
require.ErrorIs(t, err, ErrInsufficientBootstrapResponses,
"2 of 5 reachable + agreeing must be REJECTED by default — the partition-capture floor is MinResponses=3")
}
// ----- D: NON-CONFIGURED PEER IGNORED ---------------------------------------
// TestBootstrapTrust_D_NonConfiguredPeerIgnored: an attacker peer that is NOT in the configured
// beacon set reports a higher forged tip. INVARIANT 1 (non-circular eligibility): peers never
// define who is a beacon, so the forged reply is dropped entirely and the configured beacons name
// the real frontier.
func TestBootstrapTrust_D_NonConfiguredPeerIgnored(t *testing.T) {
beacons := nodeIDs(5)
real := ids.GenerateTestID()
forgedHigher := ids.GenerateTestID()
attacker := ids.GenerateTestNodeID() // NOT in TrustedBeacons
policy := &BootstrapPolicy{TrustedBeacons: equalBeacons(beacons, equalStake), MinResponses: 3}
replies := []BeaconReply{
reply(beacons[0], real, equalStake),
reply(beacons[1], real, equalStake),
reply(beacons[2], real, equalStake),
reply(attacker, forgedHigher, 9_000_000_000_000_000_000), // huge self-reported weight, ignored
}
f, err := policy.AcceptsFrontier(context.Background(), replies)
require.NoError(t, err)
require.Equal(t, real, f.ID, "the non-configured attacker's forged tip must be IGNORED")
require.NotEqual(t, forgedHigher, f.ID)
require.Equal(t, 3, f.Responders, "only the 3 configured beacons count toward the quorum")
}
// ----- E: MINORITY CONFIGURED FORGERY REJECTED ------------------------------
// TestBootstrapTrust_E_MinorityConfiguredForgeryRejected: 3 honest configured beacons report
// frontier A; 2 configured beacons report a FORGED tip B built directly on A (a forged higher
// sibling). C1: the forgers can only RATIFY A (the real block they built on); B itself holds only
// the Byzantine minority's stake and is NEVER named. The policy selects A.
func TestBootstrapTrust_E_MinorityConfiguredForgeryRejected(t *testing.T) {
const w uint64 = 100
refs, byID := refChain(30) // genesis..30; A := refs[30]
A := refs[30]
forgedB := childRef(A) // forged sibling at height 31, parent = real A
byID[forgedB.ID] = forgedB
beacons := nodeIDs(5)
policy := &BootstrapPolicy{
TrustedBeacons: equalBeacons(beacons, w),
MinResponses: 3,
Source: &stubAncestry{byID: byID},
}
replies := []BeaconReply{
reply(beacons[0], A.ID, w),
reply(beacons[1], A.ID, w),
reply(beacons[2], A.ID, w), // 3 honest on A (300)
reply(beacons[3], forgedB.ID, w), // 2 Byzantine on the forged child (200)
reply(beacons[4], forgedB.ID, w),
}
// floor = ⅔ of 500 = 333. Neither A (300) nor forgedB (200) clears it directly, so the
// ancestor-tolerant tally runs: the forgers' stake flows DOWN through A (its real parent),
// crediting A with 500 while forgedB keeps only 200 → A named, forgedB never.
f, err := policy.AcceptsFrontier(context.Background(), replies)
require.NoError(t, err)
require.Equal(t, A.ID, f.ID, "C1: the forged child only RATIFIES A — A is named")
require.NotEqual(t, forgedB.ID, f.ID, "C1: the Byzantine-minority forged tip is NEVER named")
require.Equal(t, A.Height, f.Height)
}
// ----- F: SPLIT REACHABLE ANCESTRY ------------------------------------------
// TestBootstrapTrust_F_SplitReachableAncestrySelectsCommonAncestor: 3 reachable configured beacons
// each report a DIFFERENT sibling tip (three pending blocks built on the same committed block H —
// the healthy bleeding edge). No single tip holds a supermajority, but H is in all three accepted
// chains, so the policy names H (the highest ⅔-of-responders common committed block), NOT any
// isolated tip.
func TestBootstrapTrust_F_SplitReachableAncestrySelectsCommonAncestor(t *testing.T) {
const w uint64 = 100
refs, byID := refChain(39) // genesis..39; H := refs[39] (the common committed block)
H := refs[39]
a1, a2, a3 := childRef(H), childRef(H), childRef(H) // three sibling pending blocks at height 40
for _, c := range []BlockRef{a1, a2, a3} {
byID[c.ID] = c
}
beacons := nodeIDs(3)
policy := &BootstrapPolicy{
TrustedBeacons: equalBeacons(beacons, w),
MinResponses: 3,
Source: &stubAncestry{byID: byID},
}
replies := []BeaconReply{
reply(beacons[0], a1.ID, w),
reply(beacons[1], a2.ID, w),
reply(beacons[2], a3.ID, w),
}
// floor = ⅔ of 300 = 200. Each sibling holds only 100, but H is shared by all three → 300 > 200.
f, err := policy.AcceptsFrontier(context.Background(), replies)
require.NoError(t, err)
require.Equal(t, H.ID, f.ID, "must select the common committed ancestor H")
require.Equal(t, H.Height, f.Height)
require.NotEqual(t, a1.ID, f.ID)
require.NotEqual(t, a2.ID, f.ID)
require.NotEqual(t, a3.ID, f.ID)
}
// ----- G: FINALITY UNCHANGED ------------------------------------------------
// TestBootstrapTrust_G_FinalityUnchanged proves INVARIANT 3: a bootstrap-accepted frontier is NOT
// finality. The SAME 3-of-5 support that AcceptsFrontier admits as a sync anchor does NOT satisfy
// FinalityQuorum.HasFinality — live block acceptance still requires > ⅔ of CURRENT validator
// stake (4 of 5 here). The bootstrap quorum cannot finalize a block.
func TestBootstrapTrust_G_FinalityUnchanged(t *testing.T) {
const w uint64 = 100
const total = 5 * w
beacons := nodeIDs(5)
frontier := ids.GenerateTestID()
policy := &BootstrapPolicy{TrustedBeacons: equalBeacons(beacons, w), MinResponses: 3}
// BootstrapTrust ACCEPTS 3 of 5 (a sync anchor).
f, err := policy.AcceptsFrontier(context.Background(), []BeaconReply{
reply(beacons[0], frontier, w),
reply(beacons[1], frontier, w),
reply(beacons[2], frontier, w),
})
require.NoError(t, err)
require.Equal(t, frontier, f.ID)
require.Equal(t, StakeWeight(3*w), f.Weight, "the frontier is backed by exactly the 3 responders")
// FinalityQuorum says that SAME 3-of-5 weight is NOT finality — the decisions are different
// objects with different thresholds. Finality is unchanged: it still needs > ⅔ (4 of 5).
cq := DefaultFinalityQuorum()
require.False(t, cq.HasFinality(3*w, total),
"INVARIANT 3: a bootstrap-accepted frontier (3 of 5) is NOT a finalizing supermajority")
require.True(t, cq.HasFinality(4*w, total),
"finality UNCHANGED: > ⅔ of current stake (4 of 5) still finalizes")
require.False(t, cq.HasFinality(f.Weight, total),
"the bootstrap quorum's own backing weight cannot finalize a block")
// AFTER-SYNC (the owner's "bootstrap is not a finality bypass"): the node has now SYNCED to the
// frontier via BootstrapTrust and re-entered live consensus. A NEW block that collects the SAME
// 3-of-5 stake STILL does not finalize — bootstrap admitted a sync ANCHOR, it did not lower the
// finality bar. Live acceptance returns to strict > ⅔ of CURRENT stake, exactly as before any
// bootstrap. HasFinality is stateless in the bootstrap outcome, which is the whole point: there
// is no code path by which "we bootstrapped from 3/5" leaks into the finality decision.
require.False(t, cq.HasFinality(3*w, total),
"AFTER syncing from a 3-of-5 bootstrap frontier, live finality STILL needs > ⅔ (4 of 5) — no bypass")
}
// ----- checkpoint override (complements B) ----------------------------------
// edCheckpointAuthority is a test checkpoint authority backed by Ed25519 — a PROVEN primitive, no
// custom crypto. It signs a checkpoint's canonical (id,height) message and verifies against its own
// public key, rejecting an empty signature and any key that is not the configured authority.
type edCheckpointAuthority struct {
priv ed25519.PrivateKey
pub ed25519.PublicKey
}
func newEdCheckpointAuthority(t *testing.T) *edCheckpointAuthority {
t.Helper()
pub, priv, err := ed25519.GenerateKey(nil)
require.NoError(t, err)
return &edCheckpointAuthority{priv: priv, pub: pub}
}
func (a *edCheckpointAuthority) sign(id ids.ID, height uint64) []byte {
return ed25519.Sign(a.priv, CanonicalCheckpointMessage(id, height))
}
// VerifyCheckpoint implements CheckpointVerifier: authenticate against the authority's public key.
func (a *edCheckpointAuthority) VerifyCheckpoint(id ids.ID, height uint64, sig []byte) bool {
return len(sig) != 0 && ed25519.Verify(a.pub, CanonicalCheckpointMessage(id, height), sig)
}
// TestBootstrapTrust_CheckpointOverride: below the response floor (1 of 5), the DEFAULT is reject
// (test B), but an operator who pins a SIGNED checkpoint gets the explicit override — the node
// anchors to the authenticated (id,height) instead of trusting the lone beacon. This is the
// sanctioned escape hatch for a deeply-partitioned node, NEVER an open-ended ≥1-beacon acceptance,
// and (INVARIANT 4) NEVER a bare unsigned config value.
func TestBootstrapTrust_CheckpointOverride(t *testing.T) {
beacons := nodeIDs(5)
authority := newEdCheckpointAuthority(t)
ckptID := ids.GenerateTestID()
const ckptHeight = uint64(1_082_796)
policy := &BootstrapPolicy{
TrustedBeacons: equalBeacons(beacons, equalStake),
MinResponses: 3,
Checkpoint: &Checkpoint{ID: ckptID, Height: ckptHeight, Signature: authority.sign(ckptID, ckptHeight)},
CheckpointVerifier: authority,
}
// 1 reachable beacon — below the floor — but a SIGNED checkpoint is pinned.
f, err := policy.AcceptsFrontier(context.Background(), []BeaconReply{
reply(beacons[0], ids.GenerateTestID(), equalStake),
})
require.NoError(t, err)
require.True(t, f.FromCheckpoint, "below the floor with a SIGNED checkpoint → anchor to the checkpoint")
require.Equal(t, ckptID, f.ID)
require.Equal(t, ckptHeight, f.Height)
// Without the checkpoint the same 1-of-5 is rejected (the default — never trust the lone beacon).
policy.Checkpoint = nil
_, err = policy.AcceptsFrontier(context.Background(), []BeaconReply{
reply(beacons[0], ids.GenerateTestID(), equalStake),
})
require.ErrorIs(t, err, ErrInsufficientBootstrapResponses)
}
// TestBootstrapTrust_CheckpointMustBeSigned is INVARIANT 4: a checkpoint that is present but not
// AUTHENTICATED is REJECTED (fail closed). A compromised flag/config that pins a false (id,height)
// cannot inject a sync anchor without the authority's signature. Four rejection modes, one accept.
func TestBootstrapTrust_CheckpointMustBeSigned(t *testing.T) {
beacons := nodeIDs(5)
authority := newEdCheckpointAuthority(t)
attacker := newEdCheckpointAuthority(t) // a DIFFERENT key — not the configured authority
ckptID := ids.GenerateTestID()
const h = uint64(500_000)
lone := []BeaconReply{reply(beacons[0], ids.GenerateTestID(), equalStake)} // 1-of-5, below floor
base := func() *BootstrapPolicy {
return &BootstrapPolicy{TrustedBeacons: equalBeacons(beacons, equalStake), MinResponses: 3, CheckpointVerifier: authority}
}
// (1) UNSIGNED checkpoint (empty signature) → rejected even with a verifier wired.
p := base()
p.Checkpoint = &Checkpoint{ID: ckptID, Height: h}
_, err := p.AcceptsFrontier(context.Background(), lone)
require.ErrorIs(t, err, ErrInsufficientBootstrapResponses, "an UNSIGNED checkpoint must be rejected")
// (2) signed by a NON-AUTHORITY (attacker) key → rejected.
p = base()
p.Checkpoint = &Checkpoint{ID: ckptID, Height: h, Signature: attacker.sign(ckptID, h)}
_, err = p.AcceptsFrontier(context.Background(), lone)
require.ErrorIs(t, err, ErrInsufficientBootstrapResponses, "a checkpoint signed by a non-authority key must be rejected")
// (3) authority signature over a DIFFERENT (id,height) — replay onto a forged anchor → rejected.
p = base()
forgedID := ids.GenerateTestID()
p.Checkpoint = &Checkpoint{ID: forgedID, Height: h, Signature: authority.sign(ckptID, h)} // sig binds ckptID, not forgedID
_, err = p.AcceptsFrontier(context.Background(), lone)
require.ErrorIs(t, err, ErrInsufficientBootstrapResponses, "a signature transplanted to a different (id,height) must be rejected")
// (4) NO verifier configured → any checkpoint is untrusted (fail closed).
p = base()
p.CheckpointVerifier = nil
p.Checkpoint = &Checkpoint{ID: ckptID, Height: h, Signature: authority.sign(ckptID, h)}
_, err = p.AcceptsFrontier(context.Background(), lone)
require.ErrorIs(t, err, ErrInsufficientBootstrapResponses, "no verifier ⇒ even a validly-signed checkpoint is untrusted")
// (accept) authority signs the exact pinned (id,height) → trusted.
p = base()
p.Checkpoint = &Checkpoint{ID: ckptID, Height: h, Signature: authority.sign(ckptID, h)}
f, err := p.AcceptsFrontier(context.Background(), lone)
require.NoError(t, err)
require.True(t, f.FromCheckpoint)
require.Equal(t, ckptID, f.ID)
}
// ----- safety guard for the global ancestor-tolerant tally ------------------
// TestBootstrapTrust_ForkAtSharedGenesisFailsSafe is the load-bearing guard for the
// MinFrontierHeight floor — the safety property the global cross-anchor tally (which makes case F
// work) would otherwise break. Two branches fork at a DEEP shared ancestor H (height 5), and the
// node is stale ABOVE the fork (height 23). The tally credits H with the union of BOTH halves'
// stake (all responders share H), so without the floor it would name H — and since the node
// already HOLDS H, the loop would FALSE-COMPLETE at the stale height instead of recognizing it has
// no ⅔-agreed frontier ahead. The MinFrontierHeight floor refuses to name any block beneath the
// node's last-accepted height, turning the partition into a safe ErrNoBootstrapQuorum.
//
// Asserted deterministically at the POLICY level (a stub AncestrySource serves BOTH branches'
// shared ancestry — the real wire transport's rotated sampling may only serve one, masking the
// vulnerability, so the integration path is NOT a faithful test of this guard). Revert the floor
// (set MinFrontierHeight: 0) and this names H instead of failing safe.
func TestBootstrapTrust_ForkAtSharedGenesisFailsSafe(t *testing.T) {
const w uint64 = 100
const nodeHeight = 23
// Shared prefix genesis..H (H at height 5), then two divergent branches to height 40.
shared, byID := refChain(5)
H := shared[5]
branchA := []BlockRef{H}
branchB := []BlockRef{H}
for h := 6; h <= 40; h++ {
a := childRef(branchA[len(branchA)-1])
b := childRef(branchB[len(branchB)-1])
byID[a.ID], byID[b.ID] = a, b
branchA = append(branchA, a)
branchB = append(branchB, b)
}
tipA, tipB := branchA[len(branchA)-1], branchB[len(branchB)-1]
beacons := nodeIDs(6)
policy := &BootstrapPolicy{
TrustedBeacons: equalBeacons(beacons, w),
MinResponses: 4,
MinFrontierHeight: nodeHeight, // the node is stale at height 23, ABOVE the fork at 5
Source: &stubAncestry{byID: byID},
}
replies := []BeaconReply{
reply(beacons[0], tipA.ID, w), reply(beacons[1], tipA.ID, w), reply(beacons[2], tipA.ID, w),
reply(beacons[3], tipB.ID, w), reply(beacons[4], tipB.ID, w), reply(beacons[5], tipB.ID, w),
}
// H (height 5) is shared by all 6 → 600 > floor(400). But it is BELOW the node's height, so the
// floor refuses it; no block at/above height 23 has ⅔ → fail safe.
f, err := policy.AcceptsFrontier(context.Background(), replies)
require.Nil(t, f, "must not name the deep shared ancestor — that would false-complete at the stale height")
require.ErrorIs(t, err, ErrNoBootstrapQuorum,
"a fork sharing only blocks BELOW the node's height must fail safe, never name the deep common ancestor")
// The same split with the node BELOW the fork (a fresh node) legitimately names H — the floor
// only blocks naming history the node already has, never a real frontier ahead.
policy.MinFrontierHeight = 0
f, err = policy.AcceptsFrontier(context.Background(), replies)
require.NoError(t, err)
require.Equal(t, H.ID, f.ID, "with the node below the fork, H IS the ⅔-common frontier to sync to")
}
// ----- H: SKEWED-WEIGHT PARTITION-CAPTURE (the re-red HIGH; MinResponseWeight floor) ---------
// weightedBeacons builds a TrustedBeacons map from an explicit per-node weight list — for
// modeling a SKEWED (non-uniform) validator stake distribution.
func weightedBeacons(beacons []ids.NodeID, w []uint64) map[ids.NodeID]StakeWeight {
m := make(map[ids.NodeID]StakeWeight, len(beacons))
for i, id := range beacons {
m[id] = StakeWeight(w[i])
}
return m
}
// TestBootstrapTrust_H_SkewedWeightPartitionRejected is the load-bearing regression for the re-red
// HIGH finding. Under SKEWED validator weights the MinResponses COUNT floor and the ⅔-of-responders
// WEIGHT agreement diverge: an attacker who eclipses the HEAVY honest beacon but lets enough LIGHT
// honest beacons through to satisfy the count can shrink the responder-WEIGHT denominator until his
// < ⅓-of-total Byzantine stake clears ⅔-of-responders and NAMES A FORGED FRONTIER. The MinResponseWeight
// stake-majority floor (> ½ of TOTAL configured beacon stake) closes this — a < ⅓-stake adversary can
// never make the responders carry a ⅔ weight majority once they must also carry > ½ of the total.
//
// Red's PoC: 6 beacons w={3,3,13,1,1,1}, total 22, Byzantine {B0,B1}=6 (27% < ⅓). The attacker
// partitions to {B0,B1 on forgedF} + {H2,H3 on realR} = 4 responders (= the majority count floor),
// responderWeight=8, ⅔-floor=5, backing[forgedF]=6 > 5 → forgedF would be named. The heavy honest H1
// (weight 13, on the real tip) is eclipsed. With MinResponseWeight=⌈22/2⌉=12, responderWeight=8 < 12
// → the partition is rejected (the node waits for / re-samples a stake-majority of beacons).
func TestBootstrapTrust_H_SkewedWeightPartitionRejected(t *testing.T) {
refs, byID := refChain(30)
realR := refs[30]
forgedF := childRef(realR) // forged sibling at height 31 (its only honest ancestor is realR)
byID[forgedF.ID] = forgedF
b := nodeIDs(6)
weights := []uint64{3, 3, 13, 1, 1, 1} // total 22; Byzantine b[0],b[1]=6 (<⅓)
var total uint64
for _, w := range weights {
total += w
}
tb := weightedBeacons(b, weights)
// The eclipse: only the 2 Byzantine + 2 LIGHT honest answer; the HEAVY honest b[2] (the real
// tip's weight-13 voter) and b[5] are partitioned away.
replies := []BeaconReply{
reply(b[0], forgedF.ID, weights[0]), // Byzantine, light
reply(b[1], forgedF.ID, weights[1]), // Byzantine, light
reply(b[3], realR.ID, weights[3]), // honest, light
reply(b[4], realR.ID, weights[4]), // honest, light
}
// WITHOUT the stake-majority floor (the bug): the forged tip is named.
vuln := &BootstrapPolicy{TrustedBeacons: tb, MinResponses: 4, Source: &stubAncestry{byID: byID}}
if f, err := vuln.AcceptsFrontier(context.Background(), replies); err == nil && f != nil {
require.Equal(t, forgedF.ID, f.ID,
"VULN PRECONDITION: without MinResponseWeight the eclipsed skewed partition names the forged tip (proves the floor is load-bearing)")
}
// WITH the stake-majority floor (the fix, exactly as bootstrapPolicy() now wires it): rejected.
fixed := &BootstrapPolicy{
TrustedBeacons: tb,
MinResponses: 4,
MinResponseWeight: StakeWeight(total/2 + 1), // ⌈total/2⌉ = 12
Source: &stubAncestry{byID: byID},
}
_, err := fixed.AcceptsFrontier(context.Background(), replies)
require.ErrorIs(t, err, ErrInsufficientBootstrapResponses,
"FIX: responderWeight 8 < ½-stake floor 12 → the skewed partition cannot name a frontier (forged or otherwise)")
// And the fix still admits an HONEST stake-majority: add the heavy honest H1 (weight 13) on realR.
full := append(replies, reply(b[2], realR.ID, weights[2])) // responderWeight 8+13 = 21 ≥ 12
f, err := fixed.AcceptsFrontier(context.Background(), full)
require.NoError(t, err, "an honest stake-majority of responders still names the real frontier")
require.Equal(t, realR.ID, f.ID, "the real tip is named once a stake-majority is reachable; forged never")
}
// TestBootstrapTrust_D2_NonConfiguredSwarmNamesNothing is the cleaner load-bearing isolation of the
// configured-beacon filter (INVARIANT 1) that the re-red asked for: a SWARM of non-configured peers
// (enough to clear any count floor on their own) all shouting a forged frontier names NOTHING,
// because none is in TrustedBeacons. This proves the filter, not merely the MinResponders floor.
func TestBootstrapTrust_D2_NonConfiguredSwarmNamesNothing(t *testing.T) {
const w uint64 = 100
refs, byID := refChain(30)
real := refs[30]
forged, fbyID := refChain(40) // a wholly forged chain from a fresh genesis
for id, r := range fbyID {
byID[id] = r
}
configured := nodeIDs(3) // the real beacon set
policy := &BootstrapPolicy{
TrustedBeacons: equalBeacons(configured, w),
MinResponses: 2,
MinResponseWeight: StakeWeight(w*3/2 + 1),
Source: &stubAncestry{byID: byID},
}
// 50 non-configured peers, each heavy, all on the forged tip — NOT in TrustedBeacons.
swarm := nodeIDs(50)
var replies []BeaconReply
for _, p := range swarm {
replies = append(replies, reply(p, forged[40].ID, 9_000_000))
}
_, err := policy.AcceptsFrontier(context.Background(), replies)
require.ErrorIs(t, err, ErrInsufficientBootstrapResponses,
"INVARIANT 1: non-configured peers carry ZERO weight — a forged swarm names nothing")
// Add the 3 real configured beacons on the real tip → the real tip is named, swarm invisible.
for _, c := range configured {
replies = append(replies, reply(c, real.ID, w))
}
f, err := policy.AcceptsFrontier(context.Background(), replies)
require.NoError(t, err)
require.Equal(t, real.ID, f.ID, "only configured beacons name the frontier; the 50-peer forged swarm is ignored")
}
// TestBootstrapPolicy_WiresStakeMajorityFloor is the regression guard the re-red flagged (LOW):
// the production constructor bootstrapPolicy() MUST emit MinResponseWeight = ⌈total/2⌉. The H/D2
// tests construct policies directly, so a mutation that stops the constructor from setting the
// floor would not be caught — this asserts the WIRING on the real production path. Mutation-proof:
// neuter `if total > 0` in bootstrapPolicy() and this test fails (MinResponseWeight==0).
func TestBootstrapPolicy_WiresStakeMajorityFloor(t *testing.T) {
refs, _ := refChain(5)
vm := newBSVMAt(refs5BSBlocks(refs), 0)
bh, _ := newBSHandlerWeighted(t, vm, map[ids.NodeID]uint64{}) // handler shell; we call bootstrapPolicy directly
// SKEWED set: total = 22, ⌈total/2⌉ = 12.
b := nodeIDs(6)
weights := map[ids.NodeID]uint64{b[0]: 3, b[1]: 3, b[2]: 13, b[3]: 1, b[4]: 1, b[5]: 1}
var total uint64
for _, w := range weights {
total += w
}
pol := bh.bootstrapPolicy(weights)
require.Equal(t, StakeWeight(total/2+1), pol.MinResponseWeight,
"REGRESSION: bootstrapPolicy() must wire MinResponseWeight = ⌈total/2⌉ (skewed-weight floor)")
require.Equal(t, len(weights)/2+1, pol.MinResponses,
"bootstrapPolicy() must wire the count-majority floor too")
require.NotNil(t, pol.Source, "the policy must carry an AncestrySource")
// EQUAL-weight: 5 × 0.5e18 — the floor must not re-deadlock 3-of-5 (= 0.6 ≥ 0.5).
eq := equalBeacons(nodeIDs(5), 500_000_000_000_000_000)
var eqTotal uint64
for _, w := range eq {
eqTotal += uint64(w)
}
eqPol := bh.bootstrapPolicy(eq)
require.Equal(t, StakeWeight(eqTotal/2+1), eqPol.MinResponseWeight)
require.Less(t, eqPol.MinResponseWeight, StakeWeight(3*500_000_000_000_000_000),
"3-of-5 equal stake (0.6·total) must clear the ½ floor — no re-deadlock")
// DEGENERATE: empty weights → floor disabled (0), no panic.
require.Equal(t, StakeWeight(0), bh.bootstrapPolicy(map[ids.NodeID]uint64{}).MinResponseWeight,
"empty weights → MinResponseWeight disabled (pre-P-chain / single-node fallback)")
}
// refs5BSBlocks adapts a BlockRef chain to the []*bsTestBlock the bsTestVM needs (genesis only
// accepted), so newBSHandlerWeighted has a VM. The handler is used only to call bootstrapPolicy().
func refs5BSBlocks(refs []BlockRef) []*bsTestBlock {
out := make([]*bsTestBlock, len(refs))
var parent ids.ID
for i, r := range refs {
out[i] = &bsTestBlock{id: r.ID, parent: parent, height: r.Height, bytes: []byte(r.ID.String()), valid: true}
parent = r.ID
}
return out
}
// ----- CaughtUp: the tip-holder go-live determination (RED CRITICAL fix) ----
//
// CaughtUp is the DUAL of AcceptsFrontier — "nobody is ahead" vs "here is the block ahead to sync
// to". It is the go-live path for a TIP-HOLDER on a mixed-height co-restart, where the responders
// SPLIT below the ⅔ naming threshold so AcceptsFrontier names NOTHING yet the node is plainly not
// behind. Getting its SAFETY exactly right is the hinge between "fixes the freeze" and "reopens the
// stale-go-live bug": these pin all three conditions (floor met, none-ahead, holds-every-tip) and
// prove the two adversarial fake-caught-up attempts FAIL.
// heldOracle builds the height ORACLE CaughtUp injects: a block's height, ok=false when not held.
func heldOracle(held map[ids.ID]uint64) func(ids.ID) (uint64, bool) {
return func(id ids.ID) (uint64, bool) { h, ok := held[id]; return h, ok }
}
// TestBootstrapTrust_CaughtUp_TipHolderSplitGoesReady is the CRITICAL regression at the policy layer:
// the EXACT mainnet co-restart shape. A producer at N sees 4 responders split {N, N, N-16, genesis};
// the tip-holders are only ½ (< ⅔), so AcceptsFrontier names NOTHING (ErrNoBootstrapQuorum) — yet the
// node holds every reported tip and none is above N, so CaughtUp is TRUE. It pins BOTH halves: the
// SAME replies yield no NAMED frontier (the case the tip-holder fails safe DOWN without this fix) but
// ARE caught-up.
func TestBootstrapTrust_CaughtUp_TipHolderSplitGoesReady(t *testing.T) {
const N = 40
refs, byID := refChain(N) // genesis..N
b := nodeIDs(5) // 5 equal-weight beacons (the node is the 5th, not a responder)
const w = uint64(100) // total 500 → MinResponseWeight ⌈500/2⌉=251, MinResponses majority=3
policy := &BootstrapPolicy{
TrustedBeacons: equalBeacons(b, w),
MinResponses: 3,
MinResponseWeight: 251,
MinFrontierHeight: N,
Source: &stubAncestry{byID: byID},
}
// 4 connected responders: 2 at the tip N, one stale at N-16, one at genesis — the production shape.
replies := []BeaconReply{
reply(b[0], refs[N].ID, w),
reply(b[1], refs[N].ID, w),
reply(b[2], refs[N-16].ID, w),
reply(b[3], refs[0].ID, w),
}
// HALF 1: AcceptsFrontier names NOTHING — the tip-holders (200) do not clear ⅔ (266), and the
// ⅔-backed common ancestor N-16 is below MinFrontierHeight=N (history the node already has).
_, err := policy.AcceptsFrontier(context.Background(), replies)
require.ErrorIs(t, err, ErrNoBootstrapQuorum,
"the mixed-height split names no frontier — exactly the case the tip-holder froze on without CaughtUp")
// HALF 2: the node HOLDS its accepted chain 0..N, so it holds every reported tip and none is above
// N → CaughtUp is TRUE. This is the go-live path the regression was missing.
held := map[ids.ID]uint64{refs[N].ID: N, refs[N-16].ID: N - 16, refs[0].ID: 0}
require.True(t, policy.CaughtUp(replies, N, heldOracle(held)),
"a tip-holder that holds every reported tip and is at the top of all of them IS caught up")
}
// TestBootstrapTrust_CaughtUp_StaleNodeNotCaughtUp is the FORWARD safety guard (the stale-go-live bug
// staying FIXED): a STALE node at N-16 with honest producers at N PRESENT must NOT be caught-up — an
// honest responder is ahead, so it still SYNCS. CaughtUp must not fire merely because SOME responders
// are at/below the node.
func TestBootstrapTrust_CaughtUp_StaleNodeNotCaughtUp(t *testing.T) {
const N = 40
refs, _ := refChain(N)
b := nodeIDs(5)
const w = uint64(100)
policy := &BootstrapPolicy{TrustedBeacons: equalBeacons(b, w), MinResponses: 3, MinResponseWeight: 251, MinFrontierHeight: N - 16}
replies := []BeaconReply{
reply(b[0], refs[N].ID, w), // honest, AHEAD
reply(b[1], refs[N].ID, w), // honest, AHEAD
reply(b[2], refs[N-16].ID, w), // at the node's height
reply(b[3], refs[N-20].ID, w), // below
}
// The node holds only 0..N-16 — it does NOT hold the producers' tip N.
held := map[ids.ID]uint64{refs[N-16].ID: N - 16, refs[N-20].ID: N - 20}
require.False(t, policy.CaughtUp(replies, N-16, heldOracle(held)),
"a stale node with an honest responder ahead must NOT be caught up — it syncs (stale-go-live stays fixed)")
}
// TestBootstrapTrust_CaughtUp_StaleNodeMinorityFakeRejected is adversarial fake-caught-up #1 (honest
// present): a node at N-16 where a <⅓-stake set of beacons reports ≤ N-16 to fake caught-up WHILE the
// honest producers at N are also present. The honest max is ahead (and the node lacks tip N) → NOT
// caught up. The minority cannot fake it past the honest responders.
func TestBootstrapTrust_CaughtUp_StaleNodeMinorityFakeRejected(t *testing.T) {
const N = 40
refs, _ := refChain(N)
b := nodeIDs(5)
const w = uint64(100)
policy := &BootstrapPolicy{TrustedBeacons: equalBeacons(b, w), MinResponses: 3, MinResponseWeight: 251, MinFrontierHeight: N - 16}
// 3 honest producers at N (ahead) + 1 Byzantine at N-16 trying to fake "everyone is at my height".
replies := []BeaconReply{
reply(b[0], refs[N].ID, w),
reply(b[1], refs[N].ID, w),
reply(b[2], refs[N].ID, w),
reply(b[3], refs[N-16].ID, w), // the < ⅓ liar
}
held := map[ids.ID]uint64{refs[N-16].ID: N - 16} // node holds only up to N-16
require.False(t, policy.CaughtUp(replies, N-16, heldOracle(held)),
"a <⅓ minority reporting ≤N-16 cannot fake caught-up while honest producers at N are present")
}
// TestBootstrapTrust_CaughtUp_EclipsedMinorityFailsSafe is adversarial fake-caught-up #2 (honest
// eclipsed): the honest producers (at N) are SUPPRESSED and only a <½-stake set of beacons reports
// ≤ N-16. The response FLOOR (the SAME one AcceptsFrontier uses) is not met → CaughtUp is FALSE →
// fail safe. Faking caught-up costs the same stake-majority of honest beacons that faking a NAMED
// frontier does — no partition-capture.
func TestBootstrapTrust_CaughtUp_EclipsedMinorityFailsSafe(t *testing.T) {
const N = 40
refs, _ := refChain(N)
b := nodeIDs(5)
const w = uint64(100) // total 500 → MinResponseWeight 251
policy := &BootstrapPolicy{TrustedBeacons: equalBeacons(b, w), MinResponses: 3, MinResponseWeight: 251, MinFrontierHeight: N - 16}
// Only 2 of 5 beacons answer (the honest producers at N are eclipsed). Their weight 200 < 251.
replies := []BeaconReply{
reply(b[2], refs[N-16].ID, w),
reply(b[3], refs[N-20].ID, w),
}
held := map[ids.ID]uint64{refs[N-16].ID: N - 16, refs[N-20].ID: N - 20}
require.False(t, policy.CaughtUp(replies, N-16, heldOracle(held)),
"an eclipsed <½-stake responder set cannot fake caught-up — the floor is not met (fail safe)")
// Sanity: AcceptsFrontier ALSO rejects this set below the floor (the SAME floor gates both paths).
_, err := policy.AcceptsFrontier(context.Background(), replies)
require.ErrorIs(t, err, ErrInsufficientBootstrapResponses, "the same floor gates naming and caught-up")
}
// TestBootstrapTrust_CaughtUp_OneAcceptedBlockBehindSyncs proves condition (b) uses the ACCEPTED
// height: a node at accepted N that has merely PROCESSED N+1 (holds it) is NOT caught up when a
// producer has ACCEPTED N+1 — it must sync that block. heightOf reads the block's canonical height,
// so a held-but-above-lastAccepted tip correctly defeats caught-up (the ±1 pending skew cannot fake it).
func TestBootstrapTrust_CaughtUp_OneAcceptedBlockBehindSyncs(t *testing.T) {
const N = 40
refs, _ := refChain(N + 1) // includes N+1
b := nodeIDs(5)
const w = uint64(100)
policy := &BootstrapPolicy{TrustedBeacons: equalBeacons(b, w), MinResponses: 3, MinResponseWeight: 251, MinFrontierHeight: N}
replies := []BeaconReply{
reply(b[0], refs[N+1].ID, w), // a producer ACCEPTED N+1
reply(b[1], refs[N].ID, w),
reply(b[2], refs[N].ID, w),
}
// The node holds 0..N AND has processed N+1 (held), but its ACCEPTED height is N.
held := map[ids.ID]uint64{refs[N+1].ID: N + 1, refs[N].ID: N}
require.False(t, policy.CaughtUp(replies, N, heldOracle(held)),
"a node one ACCEPTED block behind (even if it processed N+1) must NOT be caught up — it syncs")
}
// TestBootstrapTrust_CaughtUp_SameHeightForkNotHeld proves condition (c): a responder reporting a
// DIFFERENT block at the node's height (a fork the node never finalized) defeats caught-up — the node
// must HOLD every reported tip, not merely match heights numerically.
func TestBootstrapTrust_CaughtUp_SameHeightForkNotHeld(t *testing.T) {
const N = 40
refs, _ := refChain(N)
fork := BlockRef{ID: ids.GenerateTestID(), Height: N} // a sibling at height N the node does NOT hold
b := nodeIDs(5)
const w = uint64(100)
policy := &BootstrapPolicy{TrustedBeacons: equalBeacons(b, w), MinResponses: 3, MinResponseWeight: 251, MinFrontierHeight: N}
replies := []BeaconReply{
reply(b[0], refs[N].ID, w),
reply(b[1], refs[N].ID, w),
reply(b[2], fork.ID, w), // a fork at the same height N
}
held := map[ids.ID]uint64{refs[N].ID: N} // the node holds its tip N but NOT the fork
require.False(t, policy.CaughtUp(replies, N, heldOracle(held)),
"a same-height fork the node does not hold defeats caught-up (condition c: holds every reported tip)")
}
// ----- M1: the pre-existing eclipse-stale own-height path (red fast-follow) ------------------
//
// M1 is the pre-existing path the own-height filter tightening closes. BEFORE: nameFrontier filtered
// the ancestor-tolerant tally with `ref.Height < MinFrontierHeight` (== the node's own last-accepted),
// so a block AT the node's own height PASSED the filter and could be NAMED. An eclipse that throttles
// the genuinely-ahead responders below the ⅔ naming threshold — while letting the at-height responders
// through — makes the node's OWN height accrue ⅔ purely as the shared ANCESTOR of those ahead tips, so
// nameFrontier names it → FrontierNamed at own height → the node goes Ready STALE (here, 5 blocks
// behind a finalized N+5). AFTER: the filter is `ref.Height <= MinFrontierHeight`, so own height is
// EXCLUDED from naming; the at-own-height decision routes to CaughtUp, which SEES the N+5 ahead tips
// (un-held, above) and REFUSES → the node syncs/fails safe instead of going Ready stale.
//
// Deterministic, no network timing. Revert the filter to `<` and the first assertion (own height
// NOT named → ErrNoBootstrapQuorum) FAILS — that revert IS the M1 bug, so this is the RED-before /
// GREEN-after pin. The boundary sub-assertion (one notch lower DOES name N) proves it is precisely
// the OWN-HEIGHT exclusion doing the work, not some unrelated filter.
func TestBootstrapTrust_EclipseOwnHeightNotNamedRoutesToCaughtUp(t *testing.T) {
const N = 40 // the node's own last-accepted height
const ahead = N + 5 // a GENUINELY FINALIZED block 5 ahead — the eclipse throttles its visibility
refs, byID := refChain(ahead) // genesis..N+5, parent-linked; the ahead set's tip descends through N
const w = uint64(100)
b := nodeIDs(6) // 6 configured beacons @100 → total 600; MinResponseWeight ⌈600/2⌉=301, MinResponses majority=4
policy := &BootstrapPolicy{
TrustedBeacons: equalBeacons(b, w),
MinResponses: 4,
MinResponseWeight: 301,
MinFrontierHeight: N, // the node's own last-accepted height — exactly the M1 boundary
Source: &stubAncestry{byID: byID},
}
// THE ECLIPSE CONSTRUCTION (red's, verbatim numbers): the ahead responders are throttled to
// R_a = 300 (3 beacons at N+5, BELOW the ⅔-of-responders naming threshold), the behind/at-height
// responders R_b = 200 (2 beacons at N) all get through; the 6th beacon is eclipsed (no reply).
// R = R_a + R_b = 500 > ½·600 (floor met). R_a = 300 < ⅔R = 333 (so N+5 is NOT named). YET block N
// accrues R_a + R_b = 500 > ⅔R because the ahead nodes credit N as an ANCESTOR of N+5.
replies := []BeaconReply{
reply(b[0], refs[N].ID, w), // at the node's own height N
reply(b[1], refs[N].ID, w), // at the node's own height N (R_b = 200)
reply(b[2], refs[ahead].ID, w), // genuinely ahead at N+5
reply(b[3], refs[ahead].ID, w), // genuinely ahead at N+5
reply(b[4], refs[ahead].ID, w), // genuinely ahead at N+5 (R_a = 300, < ⅔·500 = 333)
// b[5] eclipsed — no reply.
}
// Sanity pins on the construction (so a future edit that breaks the eclipse shape is caught).
require.Equal(t, uint64(333), Ratio{2, 3}.floorOf(500), "⅔-of-responders floor over R=500 is 333")
require.Less(t, uint64(300), uint64(333), "R_a=300 is BELOW the ⅔ naming threshold — N+5 is not nameable")
// AFTER (the fix): own height N is EXCLUDED from naming → no ⅔-backed block ABOVE N exists
// (N+5 is sub-⅔) → ErrNoBootstrapQuorum. (Revert `<=`→`<` and this names refs[N] — the M1 bug.)
_, err := policy.AcceptsFrontier(context.Background(), replies)
require.ErrorIs(t, err, ErrNoBootstrapQuorum,
"M1 FIX: the node's OWN height must NOT be named even when ahead tips credit it as a ⅔-backed ancestor")
// …and the decision routes to CaughtUp, which SEES the genuinely-ahead N+5 tips (un-held, above
// the node's height) and REFUSES — so the node syncs toward N+5, never goes Ready at stale N.
held := map[ids.ID]uint64{} // the node holds 0..N, NOT N+1..N+5
for h := 0; h <= N; h++ {
held[refs[h].ID] = uint64(h)
}
require.False(t, policy.CaughtUp(replies, N, heldOracle(held)),
"M1 FIX: routed to CaughtUp, the eclipse's ahead tips (un-held, above N) correctly defeat caught-up → sync")
// BOUNDARY: the SAME replies with MinFrontierHeight one notch lower (N-1) DO name N (height N is
// now STRICTLY ABOVE the floor). This proves the refusal above is precisely the OWN-HEIGHT
// exclusion — not the ⅔ tally, the responder floor, or the voter count — doing the work.
policy.MinFrontierHeight = N - 1
f, err := policy.AcceptsFrontier(context.Background(), replies)
require.NoError(t, err, "one notch below own height, N is strictly above the floor and IS the ⅔-common frontier")
require.Equal(t, refs[N].ID, f.ID, "boundary: N is named iff its height is STRICTLY ABOVE MinFrontierHeight")
require.Equal(t, uint64(N), f.Height)
}