engine/chain: FIX #1 self-finality floor + FIX #3 canonical vote aggregation (v1.35.29)

FIX #1 (SAFETY — transient-count self-finality, mainnet 1085013 fork):
bftCommittee floors a presetK>1 committee at the minimal BFT size (K=4/alpha=3)
when the live validator count transiently reads 1..3 during a restart.
validators.Manager.Count under-reports (len(m.validators[net])) before the P-chain
finishes replaying the staker set; the old clamp turned that count=1 into K=1/alpha=1,
and a K==1 engine synthesizes a 1-of-1 token that BYPASSES the 2/3-by-stake gate, so a
lone live node self-finalized divergent blocks (luxd-0/luxd-1 forked 1085013..1085016).
With the floor K never reaches 1 for a multi-validator preset, so every K()==1
self-finality site now implies a genuinely single-validator chain (the only sound case);
a sub-quorum live set HALTS fail-closed (VerifyWeighted, already fail-closed on zero
stake) and reclampCommitteeLocked grows K up to the genuine set after the first real
finalize. Preserves the testnet down-clamp regression bftCommittee(11,5)->(5,4) and the
launch-single path (New()/K=1 untouched).

FIX #3 (LIVENESS — wrapper-split cert stall, testnet block 288):
assembleCertLocked aggregates verified votes across ALL sibling wrappers that share the
winner's canonicalRep (de-duped by NodeID), so alpha validators that executed the SAME
inner block under DIFFERENT proposervm wrappers combine into ONE cert instead of splitting
the quorum across outer-keyed pending blocks. Companions: emitConvergedVote now counts
abandoned siblings (includeAbandoned=true) so every node converges on the identical
winner; the v4 clone re-sign resolves inner->outer via pendingByCanonicalLocked (the old
pendingBlocks[innerCanonical] missed for wrapped blocks — a silent no-op that only worked
on bare sim blocks); snapshot re-offer uses the canonical-aggregated selfVotedForCanonicalLocked.

Tests (fail-without/pass-with, proven by temporary revert):
- transient_selffinality_floor_test.go: bftCommittee floor unit + NewRuntime construction
  (transient count=1 boots K=4 not K=1) + floored-committee halts without a real 4-of-5 cert.
- cert_canonical_aggregation_test.go: two validators sign one inner block under different
  wrappers -> one cert assembles; pendingByCanonical inner->outer resolve.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-07 12:25:14 -07:00
co-authored by Hanzo Dev
parent 075596355c
commit 422b4cb9b6
4 changed files with 487 additions and 11 deletions
@@ -0,0 +1,165 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// cert_canonical_aggregation_test.go — FIX #3 (v1.35.29): cert termination via
// per-CANONICAL vote aggregation (the block-288 wrapper-split stall).
//
// THE STALL: a vote is signed over the CANONICAL execution identity, not the outer
// proposervm envelope (canonicalVoteMessageFor), so two validators that executed the
// SAME inner block under DIFFERENT outer wrappers produce byte-identical vote messages.
// But pendingBlocks is OUTER-keyed, so their votes land in DIFFERENT pending blocks —
// and the old assembleCertLocked collected only ONE wrapper's certVotes, splitting the
// quorum: α validators had signed the same inner block, yet no cert ever assembled and
// the height stalled forever. The SIGNING aliased correctly; the AGGREGATION did not.
//
// THE FIX: assembleCertLocked collects verified votes from ALL tracked sibling wrappers
// that share the winner's canonical identity, de-duped by NodeID, and verifies them
// against the one canonical message.
package chain
import (
"bytes"
"testing"
"time"
"github.com/luxfi/consensus/config"
"github.com/luxfi/ids"
)
// wrapperOf builds a consensus Block that is one OUTER proposervm envelope over a shared
// INNER execution block: distinct outer id/parent, identical canonical fields. Two of
// these are aliases of one inner block, exactly the proposervm re-wrap the fix collapses.
func wrapperOf(inner, parentCanon, execRoot, payloadRoot ids.ID, height uint64) *Block {
return &Block{
id: ids.GenerateTestID(), // distinct OUTER envelope id
parentID: ids.GenerateTestID(), // distinct OUTER parent envelope
height: height,
canonicalID: inner, // SHARED inner execution commitment
parentCanonicalID: parentCanon, // SHARED inner parent
execStateRoot: execRoot,
payloadRoot: payloadRoot,
}
}
// TestFix3_WrapperSplit_CertAssemblesAcrossOuterEnvelopes is the fail-without/pass-with.
// Two validators sign the SAME inner block under DIFFERENT outer wrappers; each wrapper's
// certVotes holds only ONE vote (below α=2). With per-outer aggregation the cert never
// assembles (the stall). With per-canonical aggregation it MUST assemble from both.
func TestFix3_WrapperSplit_CertAssemblesAcrossOuterEnvelopes(t *testing.T) {
vs := newTestValidatorSet(3)
e, _ := newQuorumEngine(t, config.LocalParams(), vs, 0, &recordingGossiper{}) // K=3, α=2
if a := e.consensus.Alpha(); a != 2 {
t.Fatalf("precondition: this test needs α=2, got α=%d", a)
}
inner := ids.GenerateTestID()
parentCanon := ids.GenerateTestID()
execRoot := ids.GenerateTestID()
payloadRoot := ids.GenerateTestID()
const height = uint64(288) // the live wrapper-split height
wa := wrapperOf(inner, parentCanon, execRoot, payloadRoot, height)
wb := wrapperOf(inner, parentCanon, execRoot, payloadRoot, height)
if wa.id == wb.id {
t.Fatal("wrappers must have DISTINCT outer ids")
}
if wa.canonicalRep() != wb.canonicalRep() {
t.Fatal("wrappers must share the SAME canonical identity (they wrap one inner block)")
}
e.mu.Lock()
pa := &PendingBlock{ConsensusBlock: wa, ProposedAt: time.Now()}
pb := &PendingBlock{ConsensusBlock: wb, ProposedAt: time.Now()}
e.pendingBlocks[wa.id] = pa
e.pendingBlocks[wb.id] = pb
posA := e.blockPositionLocked(pa, wa.id)
posB := e.blockPositionLocked(pb, wb.id)
e.mu.Unlock()
// The aliasing precondition: the two wrappers produce BYTE-IDENTICAL vote messages
// (the message binds the canonical id, not the outer envelope), so a signature over
// one verifies against the other.
if !bytes.Equal(CanonicalVoteMessage(posA), CanonicalVoteMessage(posB)) {
t.Fatal("two wrappers of one inner block MUST sign byte-identical vote messages")
}
// Validator 0 signs wrapper A; validator 1 signs wrapper B. Each vote lands in a
// DIFFERENT outer-keyed pending block.
e.mu.Lock()
e.recordCertVoteLocked(pa, Vote{BlockID: wa.id, NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, posA)})
e.recordCertVoteLocked(pb, Vote{BlockID: wb.id, NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, posB)})
// FAIL-WITHOUT precondition: neither wrapper alone holds α=2 votes.
if len(pa.certVotes) != 1 || len(pb.certVotes) != 1 {
e.mu.Unlock()
t.Fatalf("each wrapper must hold exactly ONE vote (A=%d, B=%d) — else the test proves nothing",
len(pa.certVotes), len(pb.certVotes))
}
// PASS-WITH: assembleCertLocked aggregates across the sibling wrappers → a cert forms.
certA := e.assembleCertLocked(pa, wa.id)
certB := e.assembleCertLocked(pb, wb.id)
e.mu.Unlock()
if certA == nil {
t.Fatal("WRAPPER-SPLIT STALL: no cert assembled from wrapper A — the two per-wrapper " +
"votes for the same inner block failed to aggregate (α=2 unreachable per-outer)")
}
if len(certA.Votes) != 2 {
t.Fatalf("cert must aggregate BOTH validators' votes across wrappers, got %d voters", len(certA.Votes))
}
if certB == nil || len(certB.Votes) != 2 {
t.Fatal("aggregation must be symmetric: wrapper B must also assemble the 2-vote cert")
}
// The assembled cert must VERIFY (the signatures are over the shared canonical message).
if err := certA.Verify(vs, 0); err != nil {
t.Fatalf("aggregated cert must verify: %v", err)
}
}
// TestFix3_PendingByCanonical_ResolvesInnerToOuter proves the inner→outer resolve the
// durable clone-recovery re-sign needs: committedSlot stores the INNER canonical id, but
// pendingBlocks is OUTER-keyed. A direct pendingBlocks[inner] lookup MISSES for a wrapped
// block (inner ≠ outer) — the v4 fallback's silent no-op on real wrappers. The resolver
// must find the tracked outer wrapper by its canonical identity.
func TestFix3_PendingByCanonical_ResolvesInnerToOuter(t *testing.T) {
vs := newTestValidatorSet(3)
e, _ := newQuorumEngine(t, config.LocalParams(), vs, 0, &recordingGossiper{})
inner := ids.GenerateTestID()
w := wrapperOf(inner, ids.GenerateTestID(), ids.GenerateTestID(), ids.GenerateTestID(), 42)
if w.id == inner {
t.Fatal("precondition: a wrapped block has outer id ≠ inner canonical")
}
e.mu.Lock()
e.pendingBlocks[w.id] = &PendingBlock{ConsensusBlock: w, ProposedAt: time.Now()}
// The old direct lookup on the INNER id misses (outer-keyed map).
_, directHit := e.pendingBlocks[inner]
// The resolver finds the tracked OUTER wrapper by canonical identity.
got, gotID := e.pendingByCanonicalLocked(inner)
e.mu.Unlock()
if directHit {
t.Fatal("precondition: pendingBlocks[inner] must MISS for a wrapped block")
}
if got == nil || gotID != w.id {
t.Fatalf("pendingByCanonicalLocked(inner) must resolve to the outer wrapper %s, got id=%s (nil=%v)",
w.id, gotID, got == nil)
}
// A decided or absent canonical resolves to nothing.
e.mu.Lock()
e.pendingBlocks[w.id].Decided = true
gotDecided, _ := e.pendingByCanonicalLocked(inner)
gotAbsent, _ := e.pendingByCanonicalLocked(ids.GenerateTestID())
e.mu.Unlock()
if gotDecided != nil {
t.Fatal("a DECIDED wrapper must not be returned (its height is settled)")
}
if gotAbsent != nil {
t.Fatal("an untracked canonical must resolve to nil")
}
}
+99 -4
View File
@@ -2230,6 +2230,55 @@ func (t *Transitive) committedCanonical(height uint64) (ids.ID, bool) {
return c, ok
}
// pendingByCanonicalLocked resolves an INNER canonical execution id to the tracked,
// undecided OUTER pending block that carries it — the inner→outer lookup the durable
// vote-guard clone-recovery needs (FIX #3). pendingBlocks is keyed by the OUTER
// proposervm envelope id, but committedSlot / slotCanonical store the INNER canonical id;
// a proposervm-wrapped block has inner != outer, so a direct pendingBlocks[canon] MISSES
// (it hit only on bare blocks where inner==outer — the v4 fallback's silent no-op on real
// wrappers). Returns the lowest-outer-id wrapper of that canonical (deterministic across
// equal-canonical aliases) and its outer id, or (nil, Empty) when no live wrapper is
// tracked. Caller holds t.mu.
func (t *Transitive) pendingByCanonicalLocked(canon ids.ID) (*PendingBlock, ids.ID) {
if canon == ids.Empty {
return nil, ids.Empty
}
var best *PendingBlock
var bestID ids.ID
for id, pb := range t.pendingBlocks {
if pb.ConsensusBlock == nil || pb.Decided {
continue
}
if pb.ConsensusBlock.canonicalRep() != canon {
continue
}
if best == nil || id.Compare(bestID) < 0 {
best, bestID = pb, id
}
}
return best, bestID
}
// selfVotedForCanonicalLocked reports whether this node's signed accept for the given
// canonical execution identity is already collected in ANY tracked wrapper of it — the
// canonical-aggregated view (FIX #3) of "have I voted at this height". A wrapped block's
// vote may live in a DIFFERENT outer wrapper than the one being inspected (votes bind the
// canonical id; the re-sign resolves inner→outer to one wrapper), so a per-wrapper
// certVotes check would busy-replay a height already voted under a sibling envelope.
// Caller holds t.mu.
func (t *Transitive) selfVotedForCanonicalLocked(canon ids.ID) bool {
for _, pb := range t.pendingBlocks {
cb := pb.ConsensusBlock
if cb == nil || cb.canonicalRep() != canon {
continue
}
if _, ok := pb.certVotes[t.nodeID]; ok {
return true
}
}
return false
}
// pruneCommittedSlotsBelow drops equivocation-guard entries STRICTLY BELOW a finalized
// height — retaining the just-finalized tip's slot, exactly as avalanchego keeps the last
// accepted block in ts.blocks. Heights strictly below the tip are guaranteed unsignable by
@@ -2544,7 +2593,12 @@ func (t *Transitive) snapshotVotableSlotsLocked() []votableSlot {
// ONLY when our self-vote is absent here; emitConvergedVote then re-signs the DURABLE
// committedSlot[H] canonical (never a fresh winner) and reserveSlotForSign refuses any
// conflicting sibling, so this is equivocation-safe.
if _, selfVoted := pb.certVotes[t.nodeID]; selfVoted {
// CANONICAL-aggregated self-vote check (FIX #3): our vote for this height may be
// recorded in a DIFFERENT wrapper of the same inner block than `pb` (votes bind the
// canonical id; the re-sign resolves inner→outer to one wrapper), so inspect ANY
// wrapper of cb's canonical — a per-`pb` check would busy-replay a height already
// voted under a sibling envelope.
if t.selfVotedForCanonicalLocked(cb.canonicalRep()) {
continue // our vote is already in the cert set — normal one-per-height suppression
}
// fall through: re-offer this bound-but-unvoted slot for the re-sign fallback
@@ -2784,10 +2838,43 @@ func (t *Transitive) assembleCertLocked(pending *PendingBlock, blockID ids.ID) *
// the SAME P-chain height the position's set-root commits to (MEDIUM-1).
epochHeight := t.epochHeightLocked(pending)
// FIX #3 (cert termination — per-CANONICAL vote aggregation). A vote is signed over the
// CANONICAL execution identity, NOT the outer proposervm envelope
// (canonicalVoteMessageFor): every wrapper of one inner block produces byte-identical
// vote messages. But pendingBlocks is OUTER-keyed, so α validators that executed the
// SAME inner block under DIFFERENT wrappers deposit their signed votes into DIFFERENT
// pending blocks — and collecting only THIS wrapper's certVotes splits the quorum, so no
// cert assembles and the height stalls forever (the block-288 wrapper-split; the SIGNING
// aliases correctly but the AGGREGATION did not). Collect the signed votes from ALL
// tracked sibling wrappers that share this winner's canonical identity and verify each
// against the one canonical `message`. De-dup by NodeID HERE (a node that voted on two
// wrappers signed the SAME bytes; AssembleQuorumCert REJECTS a duplicate NodeID), and
// prefer this wrapper's own vote (collected first). This only ever ADDS votes, so the
// signature verify + the ⅔-by-stake VerifyWeighted below remain the sole finality
// authority — a liveness completion, never a safety relaxation.
verified := make([]SignedVote, 0, len(pending.certVotes))
for _, sv := range pending.certVotes {
if t.voteVerifier.VerifyVote(sv.NodeID, message, sv.Signature, epochHeight) {
verified = append(verified, sv)
seen := make(map[ids.NodeID]struct{}, len(pending.certVotes))
collectVerified := func(votes map[ids.NodeID]SignedVote) {
for nodeID, sv := range votes {
if _, dup := seen[nodeID]; dup {
continue
}
if t.voteVerifier.VerifyVote(sv.NodeID, message, sv.Signature, epochHeight) {
seen[nodeID] = struct{}{}
verified = append(verified, sv)
}
}
}
collectVerified(pending.certVotes)
if pending.ConsensusBlock != nil {
canon := pending.ConsensusBlock.canonicalRep()
for _, sib := range t.pendingBlocks {
if sib == pending || sib.ConsensusBlock == nil {
continue
}
if sib.ConsensusBlock.canonicalRep() == canon {
collectVerified(sib.certVotes)
}
}
}
if uint32(len(verified)) < uint32(alpha) {
@@ -2849,6 +2936,14 @@ func (t *Transitive) TryAccept(ctx context.Context, blockID ids.ID) error {
// vs multi-validator finality path, so a chain that launched single-validator does not keep
// finalizing unilaterally after it decentralizes (RED's 1→N fork).
t.reclampCommitteeLocked()
// K()==1 is the single-validator finality path. A multi-validator chain can no longer
// REACH K()==1 through the live-committee sizer: bftCommittee floors a presetK>1
// committee at the minimal BFT size (K≥4/α≥3) even when the validator count transiently
// reads 1 during a restart, and reclampCommitteeLocked only ever grows K. So K()==1 now
// implies a genuinely single-validator chain (presetK≤1: --dev, SingleValidatorParams,
// or a launch-single L1 whose live set really is one) — the ONLY case where self-finality
// is sound (no peer to fork against). The transient-K=1 self-finalization that forked
// luxd-0/luxd-1 at 1085013 is closed at the ROOT (the sizer), not here.
singleValidator := t.consensus.K() == 1
if singleValidator {
+54 -7
View File
@@ -178,11 +178,42 @@ type Runtime struct {
// while staying reachable (α ≤ k). This is the one mechanism that keeps a network
// from booting with an unsatisfiable quorum (α > live validators), which wedges
// finality permanently: every block verifies but the α-of-K cert never assembles.
func bftCommittee(k, count int) (newK, alpha int, clamped bool) {
if count <= 0 || k <= count {
return k, 0, false
func bftCommittee(presetK, count int) (newK, alpha int, clamped bool) {
if count <= 0 || presetK <= count {
// No sampler (tests / --dev), or the preset committee already fits the live
// set: keep the hand-tuned (K, α) verbatim (clamped=false).
return presetK, 0, false
}
return count, bftAlpha(count), true
// 0 < count < presetK: fewer validators are currently resolved than the preset
// declares. Clamp K DOWN to the live set so an oversized preset stays finalizable
// (K=21→5), BUT never below the minimal Byzantine-fault-tolerant committee.
//
// SELF-FINALITY FLOOR (the 1085013 fork fix — the ROOT). validators.Manager.Count
// reads len(m.validators[net]), which UNDER-reports during a restart window before the
// P-chain has finished replaying the staker set — it transiently returns 1. The old
// clamp turned that transient count=1 into K=1/α=1, and a K==1 engine synthesizes a
// 1-of-1 finality token that BYPASSES the ⅔-by-stake gate: the lone live node
// self-finalized divergent blocks and forked luxd-0/luxd-1 at 1085013. Flooring K at
// the minimal BFT committee (K=4, α=3, f=1) means the α-of-K COUNT gate ALWAYS demands
// a real BFT quorum (α≥3) that a single node can never reach — so a chain whose live
// set is transiently (or genuinely) below the floor HALTS fail-closed (the cert never
// assembles) instead of self-finalizing, and reclampCommitteeLocked grows K back up
// toward presetK as the real validators resolve (K=4→5). The ⅔-by-stake VerifyWeighted
// gate remains the finality authority on top; this count floor is the INDEPENDENT
// defense the stake gate cannot give when an epoch set transiently resolves to a
// lone-node subset (100% of a 1-member set trivially clears ⅔). A genuine
// single-validator chain sets presetK≤1 and never enters this branch (K stays 1 → it
// finalizes correctly on its own accept, with no peer to fork against).
const minBFTCommittee = 4 // K=4/α=3: 2α−K = 2 ≥ f+1 = 2 with f = ⌊(K-1)/3⌋ = 1
floorK := minBFTCommittee
if presetK < floorK {
floorK = presetK // a sub-BFT preset (dev K=3) keeps its own size as the floor
}
newK = count
if newK < floorK {
newK = floorK
}
return newK, bftAlpha(newK), true
}
// bftAlpha is the BFT accept quorum (the integer α COUNT) for a committee of `count` members: the
@@ -874,17 +905,33 @@ func (rt *Runtime) emitConvergedVote(_ context.Context, height uint64, parentID
t.mu.Lock()
var winnerID ids.ID
var pending *PendingBlock
if alreadySigned {
winnerID = bound
// CLONE-RECOVERY re-sign: `bound` is the DURABLE committedSlot[H] canonical — the
// INNER execution id (reserveSlotForSign stored slotCanonical(pos)). pendingBlocks is
// OUTER-keyed, so the old `pendingBlocks[bound]` MISSED for a proposervm-wrapped block
// (inner != outer) → pending==nil → silent no-op: the v4 fallback only ever worked on
// bare sim blocks (inner==outer), and on mainnet the missing self-vote was never
// re-contributed (the wedge). Resolve the tracked OUTER wrapper carrying that canonical
// and re-sign THAT — still the durable binding, so reserveSlotForSign's
// conflicting-sibling refusal below keeps it equivocation-safe.
pending, winnerID = t.pendingByCanonicalLocked(bound)
} else {
w, _, ok := t.convergedWinnerAtHeightLocked(height, parentID, false) // legacy: exclude abandoned (unchanged)
// includeAbandoned=TRUE (FIX #3 companion): abandonment only stops rePoll's
// RE-SOLICITATION (spam control) and is a PER-NODE clock decision, so excluding
// abandoned siblings makes different nodes select a different lowest-canonical winner →
// converged prevotes never align → the distributed liveness stall. Counting every live
// (undecided) sibling keeps the winner globally identical (matching the view-change
// path, which already passed true), so α converged votes land on ONE block and a single
// cert forms. Never manufactures a vote or bypasses the α-of-K cert — safety intact.
w, _, ok := t.convergedWinnerAtHeightLocked(height, parentID, true)
if !ok {
t.mu.Unlock()
return
}
winnerID = w
pending = t.pendingBlocks[winnerID]
}
pending := t.pendingBlocks[winnerID]
if pending == nil || pending.Decided {
t.mu.Unlock()
return
@@ -0,0 +1,169 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// transient_selffinality_floor_test.go — FIX #1 (v1.35.29): the transient-count
// self-finality fork (mainnet 1085013).
//
// THE FORK: validators.Manager.Count(net) reads len(m.validators[net]), which
// UNDER-reports during a restart window before the P-chain has replayed the staker
// set — it transiently returns 1. The old bftCommittee turned that transient count=1
// into K=1/α=1, and a K==1 engine synthesizes a 1-of-1 finality token that BYPASSES
// the ⅔-by-stake gate (buildSingleValidatorCertLocked) → the lone live node
// self-finalized divergent blocks and forked luxd-0/luxd-1 at 1085013..1085016.
//
// THE FIX (the ROOT): bftCommittee floors a presetK>1 committee at the minimal BFT
// size (K=4/α=3, f=1) even when the live count is transiently 1..3, so the α-of-K
// COUNT gate ALWAYS demands a real BFT quorum a single node can never reach — the
// chain HALTS fail-closed until enough validators resolve, and reclampCommitteeLocked
// grows K up toward presetK as they do. A genuine single-validator chain (presetK≤1)
// is untouched (K stays 1, it finalizes on its own accept, no peer to fork against).
package chain
import (
"testing"
"time"
"github.com/luxfi/consensus/config"
"github.com/luxfi/ids"
)
// TestFix1_BFTCommittee_TransientCountNeverSelfFinalizes is the sizer-level
// fail-without/pass-with. OLD: bftCommittee(presetK>1, count=1) = (1,1) — a
// self-finalizing 1-of-1 committee. NEW: it floors at the minimal BFT committee, so a
// multi-validator preset can NEVER be clamped to α≤1 by a transient count.
func TestFix1_BFTCommittee_TransientCountNeverSelfFinalizes(t *testing.T) {
// The exact fork inputs: a multi-validator preset whose live set transiently reads 1.
// The floor is the minimal BFT committee K=4/α=3, never the self-finalizing K=1/α=1.
for _, tc := range []struct {
presetK, count int
wantK, wantAlpha int
}{
{5, 1, 4, 3}, // the pin's "presetK=5 chain, 1 live validator at boot"
{21, 1, 4, 3}, // mainnet preset, transient count=1 (the 1085013 fork input)
{11, 1, 4, 3}, // testnet preset, transient count=1
{21, 2, 4, 3}, // 2 live — still below the BFT floor, still HALTs (not 2-of-2 self-quorum)
{21, 3, 4, 3}, // 3 live — floored
{21, 4, 4, 3}, // 4 live — exactly the floor
{21, 5, 5, 4}, // 5 live — the genuine set clamps normally (4-of-5)
} {
k, alpha, clamped := bftCommittee(tc.presetK, tc.count)
if !clamped {
t.Errorf("bftCommittee(%d,%d): expected clamped=true", tc.presetK, tc.count)
}
if k != tc.wantK || alpha != tc.wantAlpha {
t.Errorf("bftCommittee(%d,%d) = (K=%d,α=%d); want (K=%d,α=%d)",
tc.presetK, tc.count, k, alpha, tc.wantK, tc.wantAlpha)
}
// THE SAFETY INVARIANT: a multi-validator preset is NEVER clamped to a
// self-finalizing committee (α≤1 ⇒ one vote finalizes).
if alpha <= 1 {
t.Errorf("SELF-FINALITY: bftCommittee(%d,%d) yielded α=%d ≤ 1 — a lone node could self-finalize",
tc.presetK, tc.count, alpha)
}
}
// Property: across every multi-validator preset and every sub-preset live count,
// the clamp yields a satisfiable, non-self-finalizing BFT committee (2 ≤ α ≤ K ≤ presetK).
for presetK := 2; presetK <= 30; presetK++ {
for count := 1; count < presetK; count++ {
k, alpha, _ := bftCommittee(presetK, count)
if alpha < 2 {
t.Fatalf("bftCommittee(%d,%d): α=%d permits self-finality (want ≥2)", presetK, count, alpha)
}
if alpha > k || k > presetK || k < 2 {
t.Fatalf("bftCommittee(%d,%d) = (K=%d,α=%d): not a valid sub-preset BFT committee", presetK, count, k, alpha)
}
}
}
}
// TestFix1_BFTCommittee_PreservesGenuineSingleAndNormalClamp guards the fix from
// regressing (a) the genuine single-validator path and (b) the normal oversized-preset
// down-clamp (the testnet anti-wedge regression).
func TestFix1_BFTCommittee_PreservesGenuineSingleAndNormalClamp(t *testing.T) {
// (a) Genuine single validator: presetK≤1 ⇒ K stays 1 (self-finalizes correctly —
// there is no peer to fork against). presetK≤count ⇒ no clamp.
for _, count := range []int{1, 5, 21} {
if k, _, clamped := bftCommittee(1, count); clamped || k != 1 {
t.Errorf("bftCommittee(1,%d) = (K=%d,clamped=%v); genuine single must stay K=1, unclamped", count, k, clamped)
}
}
// (b) The testnet exact wedge must still shrink to the live set (K=5/α=4).
for _, tc := range []struct{ presetK, count, wantK, wantAlpha int }{
{11, 5, 5, 4}, // TestnetParams on 5 validators
{21, 5, 5, 4}, // MainnetParams on 5 validators
{21, 7, 7, 5}, // 7 live ⇒ 5-of-7
} {
k, alpha, clamped := bftCommittee(tc.presetK, tc.count)
if !clamped || k != tc.wantK || alpha != tc.wantAlpha {
t.Errorf("bftCommittee(%d,%d) = (K=%d,α=%d,clamped=%v); want (K=%d,α=%d,true)",
tc.presetK, tc.count, k, alpha, clamped, tc.wantK, tc.wantAlpha)
}
}
// (c) count unknown/empty: never clamp (a missing set must not degenerate K).
for _, count := range []int{0, -1} {
if _, _, clamped := bftCommittee(20, count); clamped {
t.Errorf("bftCommittee(20,%d) must not clamp", count)
}
}
}
// TestFix1_NewRuntime_TransientCountBootsFlooredNotSelfFinalizing drives the REAL
// construction path: a presetK=5 chain whose validator sampler transiently reports 1
// live validator (the restart window) must boot a FLOORED committee (K≥4/α≥3), NOT the
// self-finalizing K=1/α=1 the old sizer produced.
func TestFix1_NewRuntime_TransientCountBootsFlooredNotSelfFinalizing(t *testing.T) {
self, sampler := makeValidators(1) // Count(net) == 1 — the transient restart read
params := config.LocalBFTParams()
params.K = 5 // presetK=5 (a 5-validator chain); the live set momentarily reads 1
rt := NewRuntime(NetworkConfig{
ChainID: ids.GenerateTestID(),
NetworkID: ids.Empty,
NodeID: self,
Validators: sampler,
Params: &params,
})
if got := rt.Transitive.consensus.K(); got != 4 {
t.Fatalf("transient count=1 with presetK=5 must FLOOR the committee to K=4 (minimal BFT), got K=%d "+
"(K=1 would be the 1085013 self-finality committee)", got)
}
if got := rt.Transitive.consensus.Alpha(); got < 3 {
t.Fatalf("floored committee α must be ≥3 (BFT quorum), got α=%d — α=1 self-finalizes", got)
}
if pk := rt.Transitive.presetK; pk != 5 {
t.Fatalf("presetK must be preserved as the re-clamp target, got %d", pk)
}
}
// TestFix1_FlooredCommittee_HaltsWithoutRealQuorum is the downstream proof: a floored
// K=4/α=3 committee backed by a 5-validator stake source NEVER advances finality
// (never VM.Accept, never advance the decided floor) until a real 4-of-5 ⅔-stake
// supermajority signs — a lone self-vote, and even a 3-of-5 count-quorum, HALT.
func TestFix1_FlooredCommittee_HaltsWithoutRealQuorum(t *testing.T) {
vs := newTestValidatorSet(5) // equal unit stake ⇒ ⅔-of-5 needs 4 voters
rec := &recordingGossiper{}
params := config.LocalBFTParams() // K=4/α=3 — the committee the transient floor produces
e, chainID := newQuorumEngineOpts(t, params, vs, 0, rec, WithStakeWeighting(vs))
floorBefore := e.consensus.GetDecidedFloor()
blk := newTestBlock(1, ids.Empty, "floored-halt")
pos := trackProposal(e, chainID, blk, 0) // inserts own proposal + records THIS node's (node 0) signed accept
// 1 self-vote — 1/5 = 20% stake. Must HALT (self-finality is exactly this).
mustNotFinalize(t, e, blk, 1200*time.Millisecond, "lone self-vote (20% stake)")
// 3 votes {0,1,2} — reaches the α=3 COUNT quorum but only 3/5 = 60% stake. Must HALT:
// a count quorum is a liveness signal, not finality; the ⅔-stake cert is the authority.
e.ReceiveVote(vs.signedVote(1, pos))
e.ReceiveVote(vs.signedVote(2, pos))
mustNotFinalize(t, e, blk, 1200*time.Millisecond, "3-of-5 count quorum but 60% stake")
if got := e.consensus.GetDecidedFloor(); got != floorBefore {
t.Fatalf("decided floor advanced (%d→%d) WITHOUT a real 4-of-5 cert", floorBefore, got)
}
// 4th vote {0,1,2,3} — 4/5 = 80% > ⅔. Now a real cert assembles and the block finalizes.
e.ReceiveVote(vs.signedVote(3, pos))
mustFinalize(t, e, blk, 3*time.Second, "4-of-5 ⅔-stake supermajority")
}