Files
consensus/engine/chain/committee_scaling_test.go
zeekayandHanzo Dev bdb331cfac chain: accept a block only on the two-thirds certificate
Local, unreleased. Acceptance and the ledger's decided height move only on
the Quasar (two-thirds) certificate; a certificate's tier label is read at
that rung. Winner in two tiers; votes parked only from their authenticated
signer, bounded and expiring; relay once per proposer a height; stateless
blocks charged to no sender.

Co-authored-by: Hanzo Dev <[email protected]>
2026-09-24 17:29:55 -07:00

174 lines
7.0 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.
// committee_scaling_test.go — the owner's "prove each rung" gate: the BFT finality
// committee scales 1→N with the LIVE validator set, and a genuine n-validator chain
// resolves a sibling storm at every rung n=1..5 with the correct effective (K,α).
//
// The effective quorum is bftAlpha(n) = ⌊2n/3⌋+1 — the smallest integer STRICTLY
// greater than ⅔ of n (config.TwoThirdsStakeFloor+1), the same rational threshold the
// ⅔-by-stake cert enforces. A block is accepted only on that certificate over a set of at
// least minBFTCommittee. That yields:
//
// n=1 → 1-of-1 (single validator: its own synthesized certificate — self-finalize is
// correct, there is no peer to fork against)
// n=2 → nothing (f=0: a ⅔ certificate over two signers absorbs no fault, so no block
// is accepted — safety before liveness)
// n=3 → nothing (f=0, as n=2)
// n=4 → 3-of-4 (f=1; the classic 2f+1)
// n=5 → 4-of-5 (f=1; the mainnet C-Chain rung)
//
// This is scenario A — a GENUINE n-validator chain (preset K=n, e.g. a small sovereign
// L1 / FeasibleParams). Scenario B — a LARGE preset (MainnetParams K=21) SHRUNK to the
// live set by effectiveCommittee/bftCommittee, with the minBFTCommittee=4 floor that
// keeps a transient low count from self-finalizing (the 1085013 guard) — is proven by
// TestViewChange_MainnetParams_5Validators_CommitteeFromStakeSource (21→5/α4) and
// TestFix1_* (the floor). Here every rung uses its own genuine committee so the floor
// is inert and the pure scaling is visible.
package chain
import (
"fmt"
"testing"
"time"
"github.com/luxfi/consensus/config"
"github.com/luxfi/ids"
)
// genuineParams builds the config of a REAL n-validator chain: the sample K and
// both α gates sized to the live set (K=n, α=bftAlpha(n)). View-change is on for n>1 (the
// round machine that converges a symmetric sibling split); a single validator needs none.
func genuineParams(n int) config.Parameters {
a := bftAlpha(n)
p := stormParams5() // storm-tuned timing (RoundTO 1s), then resize to n
p.K = n
p.AlphaPreference = a
p.AlphaConfidence = a
// The legacy float α (Quasar-compat) must track the integer ratio, floored at the 0.66
// minimum config.Valid() requires (same clamp FeasibleParams applies).
p.Alpha = float64(a) / float64(n)
if p.Alpha < 0.66 {
p.Alpha = 0.66
}
if p.Alpha > 1.0 {
p.Alpha = 1.0
}
p.Beta = 1
p.BetaVirtuous = 1
if p.BetaRogue < n {
p.BetaRogue = n
}
if err := p.Valid(); err != nil {
panic(fmt.Sprintf("genuineParams(%d) invalid: %v", n, err))
}
return p
}
func TestCommitteeScaling_1through5_EachRungDecidesOrWaits(t *testing.T) {
type rung struct {
n int
wantAlpha int
decides bool
}
rungs := []rung{
{1, 1, true}, // 1-of-1 self-finalize
{2, 2, false}, // below minBFTCommittee: nothing accepted
{3, 3, false}, // below minBFTCommittee: nothing accepted
{4, 3, true}, // 3-of-4
{5, 4, true}, // 4-of-5 (mainnet rung)
}
for _, r := range rungs {
r := r
t.Run(fmt.Sprintf("n=%d_expects_%d-of-%d", r.n, r.wantAlpha, r.n), func(t *testing.T) {
// Precondition: the formula itself yields the expected quorum.
if got := bftAlpha(r.n); got != r.wantAlpha {
t.Fatalf("bftAlpha(%d)=%d, expected %d", r.n, got, r.wantAlpha)
}
net := newSimNet(t, r.n, genuineParams(r.n))
// The effective FINALITY committee every gate (cert + view-change) sizes to must be
// exactly (n, bftAlpha(n)) — sized from the live StakeSource, not a hardcoded preset.
gotN, gotA := net.nodes[0].rt.Transitive.effectiveCommittee(0)
if gotN != r.n || gotA != r.wantAlpha {
t.Fatalf("effectiveCommittee = (K=%d, α=%d), expected (K=%d, α=%d)",
gotN, gotA, r.n, r.wantAlpha)
}
// Drive a SIBLING SPLIT: every validator builds a distinct competing block on the
// same genesis parent (the anyone-can-propose storm). Convergence to ONE finalized
// head then turns entirely on the committee threshold under test. (n=1 builds one.)
built := make(map[ids.ID]struct{}, r.n)
for i := 0; i < r.n; i++ {
blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "rung-"+itoa(r.n)+"-sib-"+itoa(i))
built[blk.ID()] = struct{}{}
net.build(i, blk)
}
if !r.decides {
// Below minBFTCommittee: every node signs, nothing is accepted, and so no
// two blocks are.
if waitFor(3*time.Second, func() bool { return len(net.headsAtHeight(1)) > 0 }) {
t.Fatalf("n=%d: a committee below %d accepted a block: heads=%v",
r.n, minBFTCommittee, net.headsAtHeight(1))
}
return
}
// FINALIZE: all n nodes must converge on a SINGLE head at height 1 (no fork, no
// double-finalization). stormAwaitSingleHead fails immediately on two distinct heads.
head := stormAwaitSingleHead(t, net, 1)
if _, ok := built[head]; !ok {
t.Fatalf("finalized head %s is not one of the built siblings", head)
}
// Every up node must agree on that head (full convergence, not just a subset).
heads := net.headsAtHeight(1)
if len(heads) != 1 {
t.Fatalf("n=%d: expected exactly ONE finalized head across the fleet, got %v", r.n, heads)
}
if c := heads[head]; c != net.upCount() {
t.Fatalf("n=%d: head %s finalized by %d/%d nodes — not full convergence", r.n, head, c, net.upCount())
}
})
}
}
// TestCommitteeScaling_N1_SelfFinalizesButN2NeedsBoth locks the two tricky low rungs the
// owner called out: n=1 self-finalizes correctly (its own synthesized certificate), but
// n=2 must NOT self-finalize on one node's vote — a lone node stays un-final (fail-closed).
// This is the safety boundary between "genuine single validator" and "any larger set".
func TestCommitteeScaling_N1_SelfFinalizesButN2NeedsBoth(t *testing.T) {
// n=1: the sole validator finalizes its own block with no peer.
t.Run("n=1_self_finalizes", func(t *testing.T) {
net := newSimNet(t, 1, genuineParams(1))
blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "solo")
net.build(0, blk)
if ok := waitFor(stormTO, func() bool {
got, ok := net.nodes[0].rt.FinalizedBlockAtHeight(1)
return ok && got == blk.ID()
}); !ok {
t.Fatal("n=1: the sole validator must self-finalize its own block (1-of-1)")
}
})
// n=2 with one peer DOWN: the lone up node holds only its own vote → it must NOT
// finalize. This is the anti-self-finality guard at the smallest multi-validator rung.
t.Run("n=2_lone_node_never_self_finalizes", func(t *testing.T) {
net := newSimNet(t, 2, genuineParams(2))
net.down(1) // peer offline: only node 0 is up
blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "n2-solo")
net.build(0, blk)
// Give it real time to (wrongly) self-finalize if the guard were broken.
if finalized := waitFor(2*time.Second, func() bool {
_, ok := net.nodes[0].rt.FinalizedBlockAtHeight(1)
return ok
}); finalized {
t.Fatal("n=2: a lone up node must NOT finalize on its own vote (2-of-2 required) — " +
"self-finalizing here would fork the chain when the peer returns")
}
})
}