mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
Red found no way past the predicate — every refusal was fail-closed. What it found instead was the opposite failure, three times over: a number about a quorum read somewhere that is not the authority for it. THE DOORS IN FRONT OF THE GATE. HandleIncomingCert and VerifyCatchupCertificate each carried a cheap sub-quorum filter over the sample committee — NovaQuorum(K) for Nova, Alpha() for Quasar — in front of verifyCert. That is a THIRD spelling of a quantity with one definition, and it is a different quantity: the gate derives the accept rung's floor from the SIGNING SET, where NovaSignerFloor saturates at three, while the door read the committee's majority, which grows with K. At six seats the gate accepts a certificate declaring three and the door demanded four; at nine it demanded five; at eleven, six. And where the set is SMALLER than the committee the door asked the set for more than the set's own floor: three signers derive two, and a door reading a committee of four asked for three. So the exact bytes a node's own assembler produces never reached the predicate that would have admitted them. A door in front of a gate may only refuse what the gate refuses, and these could not, so they are gone. Nothing is lost by not asking twice: the gate demands EQUALITY with the derived floor, which is strictly stronger than the "at least" a door applied. Nor is any work saved by refusing early — the certificates a floor filter drops are the ones carrying FEW votes, which are the cheap ones, and Verify stops at the first signature that fails. THE CALLER'S ALPHA. BuildVerifiedQuorumCert took the floor as an ARGUMENT, and on the count-only road nothing re-derived it: one vote and an alpha of one minted an authority token on a chain of five, while a certificate carrying those exact bytes was refused on arrival. The docstring said an alpha that disagrees with the set yields no token, and that was true only of the weighted road. So there is no argument. The floor is SignerFloor(tier, SignerCount(epoch)), read at the door, and there is nothing left for a caller to disagree with. With that, a nil stake source is refused at BOTH rungs rather than at the export rung alone: a floor is a property of the set, and with no set there is no floor and nothing to mint. exportNeedsStake goes with it — one rule where there were two, and the rule Rust and C++ already have by their types, each taking a set by reference so the absent case is unrepresentable rather than checked. The equal-stake road keeps minting through the engine, whose committee is its own, and keeps ADMITTING on the arrival road, where signatures have already arrived to be weighed. THE WIDTH. SignerFloor and Quorum returned uint32 while counting a set held in an int, so the floor wrapped at about six billion signers and SignerFloor(Quasar, ~6.4e9) answered 1 — a floor a lone signer meets. They return int now and are narrowed only where a certificate is written, which is one place; and because the derived clause compares at full width, a mint that narrowed is caught by it rather than believed. THE LAST SECOND SPELLING. attestation.go's export trigger said config.TwoThirdsCount(n) where it means SignerFloor(Quasar, n). Same number, and that is the point: a trigger spelled apart from the rule it must never outrun is a trigger that can drift from it. TestTheDoorAdmitsWhatTheGateAdmits walks the four sizes where the two numbers part, on both arrival roads, and fails on the old filter at every one. Co-authored-by: Hanzo Dev <[email protected]>
356 lines
16 KiB
Go
356 lines
16 KiB
Go
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||
// See the file LICENSE for licensing terms.
|
||
|
||
// height_test.go — round-2 safety tests for the per-height
|
||
// single-finalize guard (CRITICAL-1) and the BFT parameter floor (CRITICAL-2).
|
||
//
|
||
// CRITICAL-1 was a PROVEN fork: AcceptViaCert finalized unconditionally and the
|
||
// consensus tracked blocks by ID only, so two VALID α-certs for two DIFFERENT
|
||
// blocks at the SAME height (each over a different, attacker-chosen Round) both
|
||
// Verified and both VM.Accepted — two blocks at one height. These tests prove
|
||
// the guard now finalizes the FIRST and REJECTS the SECOND (no second VM.Accept)
|
||
// and surfaces the conflict as equivocation evidence.
|
||
package chain
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/luxfi/consensus/config"
|
||
"github.com/luxfi/consensus/core/slashing"
|
||
"github.com/luxfi/ids"
|
||
"github.com/luxfi/log"
|
||
)
|
||
|
||
// params4 is the MINIMAL Byzantine-fault-tolerant config: K=4, α=3 (3-of-4),
|
||
// f=1. Overlap 2·3−4=2 ≥ ⌊3/3⌋+1=2 — a real quorum (unlike 3-of-5, which the
|
||
// BFT floor correctly rejects). Used for the multi-validator fork tests.
|
||
func params4() config.Parameters {
|
||
return config.Parameters{K: 4, Alpha: 0.75, AlphaPreference: 3, AlphaConfidence: 3, Beta: 2}
|
||
}
|
||
|
||
// buildCertAtRound assembles a real (Ed25519-signed) α-of-K cert for blockID at
|
||
// (height, round) signed by the first `n` validators of vs. The Round is folded
|
||
// into every signature, so two certs at the same height with DIFFERENT rounds
|
||
// are BOTH internally valid — exactly the attacker's two-valid-certs primitive.
|
||
func buildCertAtRound(t *testing.T, vs *testValidatorSet, chainID, blockID, parentID ids.ID, height uint64, round uint32, n int) []byte {
|
||
t.Helper()
|
||
pos := VotePosition{ChainID: chainID, Height: height, Round: round, BlockID: blockID, ParentID: parentID}
|
||
votes := make([]SignedVote, 0, n)
|
||
for i := 0; i < n; i++ {
|
||
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
|
||
}
|
||
// The certificate declares the floor the SET derives for the export rung, which
|
||
// is the only threshold a receiving node admits — never the number of signatures
|
||
// this one happens to carry. Assembly runs at the vote count so an under-quorum
|
||
// certificate can still be stated; the declaration is stamped after.
|
||
cert, err := AssembleQuorumCert(pos, Quasar, uint32(n), votes)
|
||
if err != nil {
|
||
t.Fatalf("assemble cert (round %d): %v", round, err)
|
||
}
|
||
cert.Threshold = uint32(SignerFloor(Quasar, len(vs.ids)))
|
||
b, err := cert.MarshalBinary()
|
||
if err != nil {
|
||
t.Fatalf("marshal cert (round %d): %v", round, err)
|
||
}
|
||
return b
|
||
}
|
||
|
||
// trackVerifiedBlock simulates HandleIncomingBlock: the follower has locally
|
||
// verified+tracked a block (added to consensus + pendingBlocks) so a cert for it
|
||
// can finalize.
|
||
func trackVerifiedBlock(rt *Runtime, blk *verifyOnceBlock, round uint32) {
|
||
cb := &Block{id: blk.id, parentID: blk.parentID, height: blk.height, timestamp: blk.timestamp.Unix(), data: blk.bytes}
|
||
_ = rt.Transitive.consensus.AddBlock(context.Background(), cb)
|
||
rt.Transitive.mu.Lock()
|
||
rt.Transitive.pendingBlocks[blk.id] = &PendingBlock{ConsensusBlock: cb, VMBlock: blk, ProposedAt: time.Now(), Round: round}
|
||
rt.Transitive.mu.Unlock()
|
||
}
|
||
|
||
// TestCriticalFork_TwoCertsOneHeightAcrossRounds is the round-1 PoC, now a
|
||
// regression: two valid α-of-K certs for DIFFERENT blocks at the SAME height
|
||
// (different rounds). The first finalizes; the SECOND must be REJECTED — no
|
||
// second VM.Accept, no second finalized block at that height.
|
||
func TestCriticalFork_TwoCertsOneHeightAcrossRounds(t *testing.T) {
|
||
vs := newTestValidatorSet(4)
|
||
chainID := ids.GenerateTestID()
|
||
db := slashing.NewDB(time.Hour)
|
||
|
||
follower := NewWithConfig(Config{Params: params4()},
|
||
WithQuorumCert(chainID, vs.nodeID(3), vs, &recordingGossiper{}, vs.signerFor(3)),
|
||
WithSlashing(slashing.NewDetector(64, 0.5), db))
|
||
if err := follower.Start(context.Background(), true); err != nil {
|
||
t.Fatalf("Start: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = follower.Stop(context.Background()) })
|
||
rt := &Runtime{Transitive: follower, config: NetworkConfig{ChainID: chainID, Logger: log.Noop()}}
|
||
|
||
// Two conflicting blocks at the SAME height 1 (genesis parent), distinct IDs.
|
||
blkA := newTestBlock(1, ids.Empty, "height1-A")
|
||
blkB := newTestBlock(1, ids.Empty, "height1-B")
|
||
trackVerifiedBlock(rt, blkA, 0)
|
||
trackVerifiedBlock(rt, blkB, 7) // attacker-chosen non-zero round
|
||
|
||
// Cert A over round 0; cert B over round 7 — BOTH valid 3-of-4 quorums.
|
||
certA := buildCertAtRound(t, vs, chainID, blkA.id, ids.Empty, 1, 0, 3)
|
||
certB := buildCertAtRound(t, vs, chainID, blkB.id, ids.Empty, 1, 7, 3)
|
||
|
||
// First cert finalizes A.
|
||
if !rt.HandleIncomingCert(certA) {
|
||
t.Fatal("first cert must finalize block A")
|
||
}
|
||
if !follower.IsAccepted(blkA.id) || blkA.AcceptCalled() != 1 {
|
||
t.Fatalf("A must be accepted exactly once (accepted=%v calls=%d)", follower.IsAccepted(blkA.id), blkA.AcceptCalled())
|
||
}
|
||
|
||
// SECOND cert at the SAME height — MUST be rejected. THE FORK STOPS HERE.
|
||
if rt.HandleIncomingCert(certB) {
|
||
t.Fatal("CRITICAL FORK: second cert at an already-finalized height was accepted")
|
||
}
|
||
if follower.IsAccepted(blkB.id) {
|
||
t.Fatal("CRITICAL FORK: block B finalized at an already-finalized height")
|
||
}
|
||
if blkB.AcceptCalled() != 0 {
|
||
t.Fatalf("CRITICAL FORK: VM.Accept called %d times on the conflicting block B (must be 0)", blkB.AcceptCalled())
|
||
}
|
||
|
||
// Exactly one block finalized at height 1, and it is A.
|
||
if fin, ok := follower.consensus.FinalizedBlockAtHeight(1); !ok || fin != blkA.id {
|
||
t.Fatalf("height 1 must be finalized to A, got (%s, ok=%v)", fin, ok)
|
||
}
|
||
|
||
// The conflict must have produced equivocation evidence for B's voters.
|
||
if len(db.GetAllRecords()) == 0 {
|
||
t.Fatal("equivocation evidence must be recorded for the conflicting cert's voters")
|
||
}
|
||
}
|
||
|
||
// TestHeightGuard_SecondCertSameBlockIsIdempotent proves a re-delivered cert for
|
||
// the SAME block at an already-finalized height is a harmless no-op (idempotent),
|
||
// NOT an equivocation — only a DIFFERENT block triggers rejection/evidence.
|
||
func TestHeightGuard_SecondCertSameBlockIsIdempotent(t *testing.T) {
|
||
vs := newTestValidatorSet(4)
|
||
chainID := ids.GenerateTestID()
|
||
db := slashing.NewDB(time.Hour)
|
||
follower := NewWithConfig(Config{Params: params4()},
|
||
WithQuorumCert(chainID, vs.nodeID(3), vs, &recordingGossiper{}, vs.signerFor(3)),
|
||
WithSlashing(slashing.NewDetector(64, 0.5), db))
|
||
_ = follower.Start(context.Background(), true)
|
||
t.Cleanup(func() { _ = follower.Stop(context.Background()) })
|
||
rt := &Runtime{Transitive: follower, config: NetworkConfig{ChainID: chainID, Logger: log.Noop()}}
|
||
|
||
blk := newTestBlock(1, ids.Empty, "idem")
|
||
trackVerifiedBlock(rt, blk, 0)
|
||
cert := buildCertAtRound(t, vs, chainID, blk.id, ids.Empty, 1, 0, 3)
|
||
|
||
if !rt.HandleIncomingCert(cert) {
|
||
t.Fatal("first delivery must finalize")
|
||
}
|
||
// Re-delivery: block already Decided → returns false (no re-Accept) but is NOT
|
||
// an equivocation (same block).
|
||
rt.HandleIncomingCert(cert)
|
||
if blk.AcceptCalled() != 1 {
|
||
t.Fatalf("VM.Accept must be exactly once across re-delivery, got %d", blk.AcceptCalled())
|
||
}
|
||
if len(db.GetAllRecords()) != 0 {
|
||
t.Fatal("a re-delivered SAME-block cert must NOT be flagged as equivocation")
|
||
}
|
||
}
|
||
|
||
// TestHeightGuard_ConcurrentCertReplayIsSingleFlight reproduces the live
|
||
// catch-up amplification where several peers deliver the same valid cert at
|
||
// once. Exactly one delivery may perform the signature checks and finalize;
|
||
// queued duplicates must take the cheap stale-height path rather than repeat
|
||
// cryptography, VM work, and success logging.
|
||
func TestHeightGuard_ConcurrentCertReplayIsSingleFlight(t *testing.T) {
|
||
const deliveries = 32
|
||
vs := newTestValidatorSet(4)
|
||
chainID := ids.GenerateTestID()
|
||
var verifyCalls atomic.Int64
|
||
verifier := VoteVerifierFunc(func(nodeID ids.NodeID, message, sig []byte, epochHeight uint64) bool {
|
||
verifyCalls.Add(1)
|
||
// Make the pre-fix race deterministic: every concurrent handler clears the
|
||
// height guard before any one of them can finish verification.
|
||
time.Sleep(2 * time.Millisecond)
|
||
return vs.VerifyVote(nodeID, message, sig, epochHeight)
|
||
})
|
||
follower := NewWithConfig(Config{Params: params4()},
|
||
WithQuorumCert(chainID, vs.nodeID(3), verifier, &recordingGossiper{}, vs.signerFor(3)))
|
||
if err := follower.Start(context.Background(), true); err != nil {
|
||
t.Fatalf("Start: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = follower.Stop(context.Background()) })
|
||
rt := &Runtime{Transitive: follower, config: NetworkConfig{ChainID: chainID, Logger: log.Noop()}}
|
||
|
||
blk := newTestBlock(1, ids.Empty, "concurrent-cert-replay")
|
||
trackVerifiedBlock(rt, blk, 0)
|
||
cert := buildCertAtRound(t, vs, chainID, blk.id, ids.Empty, 1, 0, 3)
|
||
|
||
start := make(chan struct{})
|
||
results := make(chan bool, deliveries)
|
||
var wg sync.WaitGroup
|
||
for range deliveries {
|
||
wg.Add(1)
|
||
go func() {
|
||
defer wg.Done()
|
||
<-start
|
||
results <- rt.HandleIncomingCert(cert)
|
||
}()
|
||
}
|
||
close(start)
|
||
wg.Wait()
|
||
close(results)
|
||
|
||
finalized := 0
|
||
for result := range results {
|
||
if result {
|
||
finalized++
|
||
}
|
||
}
|
||
if finalized != 1 {
|
||
t.Fatalf("exactly one delivery must report finalization, got %d", finalized)
|
||
}
|
||
if got := blk.AcceptCalled(); got != 1 {
|
||
t.Fatalf("VM.Accept must run exactly once, got %d", got)
|
||
}
|
||
if got := verifyCalls.Load(); got != 3 {
|
||
t.Fatalf("one 3-vote cert should be verified once, got %d signature checks", got)
|
||
}
|
||
}
|
||
|
||
// TestHeightGuard_EquivocationAndMonotonic unit-tests FinalizeBranch's KEPT safety
|
||
// invariants directly on ChainConsensus: ONE finalized block per height (equivocation
|
||
// detection) and strictly-forward, contiguous finalized height. The old
|
||
// parent==finalizedTip ADMISSION refusal is GONE — siblings are admitted and resolved
|
||
// by the cert's reorg (TestFinalizeBranch_* and TestSiblingReorg_* cover that).
|
||
func TestHeightGuard_EquivocationAndMonotonic(t *testing.T) {
|
||
c := NewChainConsensus(4, 3, 2)
|
||
|
||
g := ids.GenerateTestID() // height 1 (genesis-ish, empty parent)
|
||
h2 := ids.GenerateTestID()
|
||
// Finalize height 1 (first finalize seeds the ledger).
|
||
if _, err := c.FinalizeBranch(g, 1, ids.Empty); err != nil {
|
||
t.Fatalf("finalize height 1: %v", err)
|
||
}
|
||
// (a) a DIFFERENT block at the SAME height → ErrHeightAlreadyFinalized (equivocation).
|
||
if _, err := c.FinalizeBranch(ids.GenerateTestID(), 1, ids.Empty); !errors.Is(err, ErrHeightAlreadyFinalized) {
|
||
t.Fatalf("expected ErrHeightAlreadyFinalized, got %v", err)
|
||
}
|
||
// Correct child (parent == tip g, height 2) → single-step finalize, no prune.
|
||
if plan, err := c.FinalizeBranch(h2, 2, g); err != nil {
|
||
t.Fatalf("valid child must finalize: %v", err)
|
||
} else if len(plan.Accept) != 1 || plan.Accept[0] != h2 || len(plan.Reject) != 0 {
|
||
t.Fatalf("single-step plan wrong: accepted=%v pruned=%v", plan.Accept, plan.Reject)
|
||
}
|
||
// Re-finalize an OLD height with a new block → rejected (equivocation at height 1).
|
||
if _, err := c.FinalizeBranch(ids.GenerateTestID(), 1, ids.Empty); !errors.Is(err, ErrHeightAlreadyFinalized) {
|
||
t.Fatalf("expected a rejection re-finalizing height 1, got %v", err)
|
||
}
|
||
// Same block, same height → idempotent: empty plan, nil error.
|
||
if plan, err := c.FinalizeBranch(h2, 2, g); err != nil || len(plan.Accept) != 0 {
|
||
t.Fatalf("idempotent re-finalize must be a nil-error no-op, got plan=%+v err=%v", plan, err)
|
||
}
|
||
if fh, set := c.GetFinalizedHeight(); !set || fh != 2 {
|
||
t.Fatalf("finalized height must be 2, got (%d, %v)", fh, set)
|
||
}
|
||
}
|
||
|
||
// TestHeightGuard_RejectsHeightGap proves finality stays CONTIGUOUS: a cert whose
|
||
// block sits more than one height above the tip with the tip as its DIRECT parent (a
|
||
// skipped height) is rejected. Defense-in-depth — an honest block's height is its
|
||
// parent's +1, so an honest single-step always passes; only a malformed cert fails.
|
||
func TestHeightGuard_RejectsHeightGap(t *testing.T) {
|
||
c := NewChainConsensus(4, 3, 2)
|
||
g := ids.GenerateTestID()
|
||
if _, err := c.FinalizeBranch(g, 1, ids.Empty); err != nil {
|
||
t.Fatalf("finalize height 1: %v", err)
|
||
}
|
||
// height 3 with parent==tip(g, height 1) — a GAP over height 2 → rejected.
|
||
if _, err := c.FinalizeBranch(ids.GenerateTestID(), 3, g); !errors.Is(err, ErrNonMonotonicFinalizedHeight) {
|
||
t.Fatalf("a height gap must be rejected, got %v", err)
|
||
}
|
||
// The strict successor (height 2, parent g) finalizes.
|
||
if _, err := c.FinalizeBranch(ids.GenerateTestID(), 2, g); err != nil {
|
||
t.Fatalf("contiguous successor must finalize: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestForcePreference_DoesNotMoveFinalizedTip proves the recovery hatch never moves
|
||
// finalizedTip off the recorded head. With the per-height ADMISSION gate gone there is
|
||
// no invariant for a stray preference to corrupt, but ForcePreference must still never
|
||
// rewrite finalized history — only (re)assert a build tip.
|
||
func TestForcePreference_DoesNotMoveFinalizedTip(t *testing.T) {
|
||
c := NewChainConsensus(4, 3, 2)
|
||
|
||
g := ids.GenerateTestID() // finalized head at height 1
|
||
if _, err := c.FinalizeBranch(g, 1, ids.Empty); err != nil {
|
||
t.Fatalf("finalize height 1: %v", err)
|
||
}
|
||
|
||
// Reaffirm the head — finalizedTip stays g.
|
||
c.ForcePreference(g)
|
||
if c.GetFinalizedTip() != g {
|
||
t.Fatalf("ForcePreference(head) must keep finalized tip g, got %s", c.GetFinalizedTip())
|
||
}
|
||
|
||
// MISUSE: force preference to an UNRELATED block. This must NOT move finalizedTip.
|
||
rogue := ids.GenerateTestID()
|
||
c.ForcePreference(rogue)
|
||
if c.GetFinalizedTip() != g {
|
||
t.Fatalf("ForcePreference(non-head) moved finalized tip: got %s want g", c.GetFinalizedTip())
|
||
}
|
||
if fh, set := c.GetFinalizedHeight(); !set || fh != 1 {
|
||
t.Fatalf("finalized height must stay 1, got (%d,%v)", fh, set)
|
||
}
|
||
|
||
// The correct child (parent == true tip g) finalizes.
|
||
if _, err := c.FinalizeBranch(ids.GenerateTestID(), 2, g); err != nil {
|
||
t.Fatalf("correct child (parent g) must finalize: %v", err)
|
||
}
|
||
}
|
||
|
||
// TestLocalVotePath_IsLivenessOnly proves the α-count path (ProcessVote/Poll) is now
|
||
// pure LIVENESS, decomplected from finality: reaching α marks a block worth a finalize
|
||
// ATTEMPT (IsAccepted, the engine's DrainAccepted trigger) but advances NO finalized
|
||
// history. Finality — and the single-block-per-height guarantee — is the cert path's
|
||
// job (FinalizeBranch). Two conflicting blocks can both reach α-count; neither is
|
||
// finalized without a cert, and a cert then finalizes exactly one.
|
||
func TestLocalVotePath_IsLivenessOnly(t *testing.T) {
|
||
c := NewChainConsensus(4, 3, 2) // α=3 (count)
|
||
a := &Block{id: ids.GenerateTestID(), parentID: ids.Empty, height: 1}
|
||
b := &Block{id: ids.GenerateTestID(), parentID: ids.Empty, height: 1}
|
||
_ = c.AddBlock(context.Background(), a)
|
||
_ = c.AddBlock(context.Background(), b)
|
||
|
||
// Drive BOTH siblings to the α=3 accept count.
|
||
for i := 0; i < 3; i++ {
|
||
_ = c.ProcessVote(context.Background(), a.id, ids.GenerateTestNodeID(), true)
|
||
_ = c.ProcessVote(context.Background(), b.id, ids.GenerateTestNodeID(), true)
|
||
}
|
||
// Both reach the α-count LIVENESS flag...
|
||
if !c.IsAccepted(a.id) || !c.IsAccepted(b.id) {
|
||
t.Fatal("both siblings must reach the α-count liveness flag")
|
||
}
|
||
// ...but the count path finalizes NOTHING: the per-height ledger is untouched.
|
||
if _, set := c.GetFinalizedHeight(); set {
|
||
t.Fatal("the count path must NOT advance finalized history — finality is cert-only")
|
||
}
|
||
if _, ok := c.FinalizedBlockAtHeight(1); ok {
|
||
t.Fatal("no block may be finalized at height 1 without a cert")
|
||
}
|
||
// A cert finalizes exactly ONE; the conflicting one is then refused by gate (a).
|
||
if _, err := c.FinalizeBranch(a.id, 1, ids.Empty); err != nil {
|
||
t.Fatalf("cert for a must finalize: %v", err)
|
||
}
|
||
if _, err := c.FinalizeBranch(b.id, 1, ids.Empty); !errors.Is(err, ErrHeightAlreadyFinalized) {
|
||
t.Fatalf("a SECOND finalize at height 1 must be refused as equivocation, got %v", err)
|
||
}
|
||
if fin, _ := c.FinalizedBlockAtHeight(1); fin != a.id {
|
||
t.Fatalf("height 1 must be finalized to a, got %s", fin)
|
||
}
|
||
}
|