mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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: ¶ms,
|
||||
})
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user