consensus: comments state the invariant, not the incident

Comments across this tree named hosts, validator ids, clusters, ticket numbers
and heights observed on running networks. The repository is public, so each one
turned a reader of the code into a reader of our operations, and each aged badly:
the rule stays true, the anecdote does not.

Rewritten rather than deleted. Almost every one was standing in for something
real that was learned expensively, so the anecdote is replaced by the rule that
made it wrong — a reader now gets the reason the code has its shape, which is the
part worth keeping. One test went entirely: it read captured files from a fixed
path keyed by host name and skipped everywhere else, so it was evidence rather
than a test, and its one reusable claim is already covered.

Comments and test data only; no executable change.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-14 14:57:08 -07:00
co-authored by Hanzo Dev
parent 7893ed40df
commit be53ad78ac
26 changed files with 1413 additions and 1481 deletions
+1 -1
View File
@@ -822,7 +822,7 @@ func TestVersionEntropy_Edge(t *testing.T) {
{"empty", ""}, {"empty", ""},
{"simple", "1.0"}, {"simple", "1.0"},
{"complex", "1.2.3"}, {"complex", "1.2.3"},
{"very_complex", "1.2.3.4.5"}, {"more_than_semver", "1.2.3-rc.4"},
} }
for _, tt := range tests { for _, tt := range tests {
+13 -54
View File
@@ -6,7 +6,6 @@ package main
import ( import (
"encoding/binary" "encoding/binary"
"hash/crc32" "hash/crc32"
"os"
"testing" "testing"
"github.com/luxfi/ids" "github.com/luxfi/ids"
@@ -38,28 +37,33 @@ func encodeV3(bindings map[uint64]binding, floor uint64) []byte {
return append(buf, crc[:]...) return append(buf, crc[:]...)
} }
// Both record shapes in one frame: a hard lock sitting exactly AT the floor (no round) and a
// v3 view lock above it. The floor is inclusive, so a binding at the floor must survive the
// round trip rather than be trimmed with the decided past.
func TestDecode_RoundTrip(t *testing.T) { func TestDecode_RoundTrip(t *testing.T) {
const floor = 4_000_000
const round = 12
a := ids.GenerateTestID() a := ids.GenerateTestID()
b := ids.GenerateTestID() b := ids.GenerateTestID()
in := map[uint64]binding{ in := map[uint64]binding{
1082879: {height: 1082879, target: a}, // hard lock (no round) — AT floor floor: {height: floor, target: a},
1082880: {height: 1082880, lockRound: 8450, hasLock: true, target: b}, // v3 view lock — above floor floor + 1: {height: floor + 1, lockRound: round, hasLock: true, target: b},
} }
s, err := decode("test", encodeV3(in, 1082879)) s, err := decode("test", encodeV3(in, floor))
if err != nil { if err != nil {
t.Fatalf("decode: %v", err) t.Fatalf("decode: %v", err)
} }
if s.finalizedThrough != 1082879 { if s.finalizedThrough != floor {
t.Fatalf("floor: got %d want 1082879", s.finalizedThrough) t.Fatalf("floor: got %d want %d", s.finalizedThrough, floor)
} }
if len(s.bindings) != 2 { if len(s.bindings) != 2 {
t.Fatalf("bindings: got %d want 2", len(s.bindings)) t.Fatalf("bindings: got %d want 2", len(s.bindings))
} }
// sorted by height: [1082879 hard][1082880 round=8450] // bindings come back sorted by height: [floor, hard] then [floor+1, round]
if s.bindings[0].height != 1082879 || s.bindings[0].hasLock { if s.bindings[0].height != floor || s.bindings[0].hasLock {
t.Fatalf("binding0: %+v", s.bindings[0]) t.Fatalf("binding0: %+v", s.bindings[0])
} }
if s.bindings[1].height != 1082880 || !s.bindings[1].hasLock || s.bindings[1].lockRound != 8450 || s.bindings[1].target != b { if s.bindings[1].height != floor+1 || !s.bindings[1].hasLock || s.bindings[1].lockRound != round || s.bindings[1].target != b {
t.Fatalf("binding1: %+v", s.bindings[1]) t.Fatalf("binding1: %+v", s.bindings[1])
} }
} }
@@ -86,48 +90,3 @@ func TestAlphaFor(t *testing.T) {
} }
} }
} }
// TestDecode_RealMainnetFiles pins byte-parity against the captured live pods (ground truth): the
// 1082879 decided floor and the 1082880 split-lock the incident is about. Skips when the evidence
// dir is absent (CI without the captured files).
func TestDecode_RealMainnetFiles(t *testing.T) {
const dir = "/tmp/lux-1082880-evidence"
files := map[string]string{ // pod → expected 1082880 lock target (cb58)
dir + "/vote-guard.luxd-0": "2494WYuivjJ4V5scrrBDUopqnK6quAkgyfECkXDeG3Pgs6r5Wu",
dir + "/vote-guard.luxd-1": "yezD2DSHgnbyqajqhYR19kvBbmK9x86MZubyNuU75eMHJ7HnA",
}
tested := 0
for path, wantTarget := range files {
data, err := os.ReadFile(path)
if err != nil {
continue // evidence not present on this host
}
s, err := decode(path, data)
if err != nil {
t.Fatalf("decode real file %s: %v", path, err)
}
if s.finalizedThrough != 1082879 {
t.Fatalf("%s: floor got %d want 1082879", path, s.finalizedThrough)
}
var got ids.ID
var found bool
for _, b := range s.bindings {
if b.height == 1082880 {
got, found = b.target, true
if b.lockRound != 8450 {
t.Fatalf("%s: 1082880 lockRound got %d want 8450", path, b.lockRound)
}
}
}
if !found {
t.Fatalf("%s: no 1082880 binding", path)
}
if got.String() != wantTarget {
t.Fatalf("%s: 1082880 target got %s want %s", path, got, wantTarget)
}
tested++
}
if tested == 0 {
t.Skip("no captured mainnet vote-guard files present; skipping real-file parity")
}
}
+17 -20
View File
@@ -13,24 +13,21 @@ import (
) )
// TestSelfHeal_AbandonedBlock_KeepsRequestingCatchup is the sibling of // TestSelfHeal_AbandonedBlock_KeepsRequestingCatchup is the sibling of
// TestSelfHeal_StuckTrackedNonOwnBlock_TriggersCertCatchup, and the difference between // TestSelfHeal_StuckTrackedNonOwnBlock_TriggersCertCatchup: that one proves the recovery
// them is the whole defect: that one proves the recovery fires, this one proves it fires // fires, this one proves it fires again.
// AGAIN.
// //
// Abandonment used to end two things with one `continue`. Not re-polling a block whose // Abandoning a block ends two separate things, and only one of them should end. Not
// voters cannot vote on it is correct and deliberate. Never asking for it again is not: // re-polling a block its voters can no longer vote on is right — peers vote near their own
// the cert catch-up fired once, at the instant of abandonment, and rePollAbandoned is set // frontier, so a height they have run past will never gather a quorum however often it is
// for the life of the process and cleared nowhere, so the block was never looked at again. // asked. Not asking for the block again is wrong: rePollAbandoned is set once and cleared
// A single dropped fetch was therefore permanent. // nowhere, so a node whose single catch-up fetch is dropped never looks at that height
// again and sits there for the life of the process. Restart changes nothing; the same
// attempts are replayed and end in the same place.
// //
// That is what froze lux-mainnet luxd-2 and luxd-3 on the C-Chain at 1,159,050 while // For a node that far behind the certificate fetch is the only remaining door — its own
// luxd-0/1/4 ran on to 1,160,600. Both stalled nodes HELD the blocks — their EVM had them // store may already hold the block, yet consensus cannot accept it without a decision it
// canonical — and consensus simply never accepted them, because by then the peers were // can no longer vote its way to. So the fetch must be reissued while the block stays
// 1,500 blocks ahead and no longer vote on a block that old. Cert catch-up was the only // undecided: one per cooldown, not one ever.
// door left and it had already shut. Restarting replays the same eight strikes.
//
// PRE-FIX: exactly one fetch, ever. POST-FIX: a fetch per cooldown, for as long as the
// block stays undecided.
func TestSelfHeal_AbandonedBlock_KeepsRequestingCatchup(t *testing.T) { func TestSelfHeal_AbandonedBlock_KeepsRequestingCatchup(t *testing.T) {
vs := newTestValidatorSet(5) vs := newTestValidatorSet(5)
chainID := ids.GenerateTestID() chainID := ids.GenerateTestID()
@@ -89,7 +86,7 @@ func TestSelfHeal_AbandonedBlock_KeepsRequestingCatchup(t *testing.T) {
} }
e.mu.Unlock() e.mu.Unlock()
if !stillPending { if !stillPending {
t.Fatal("precondition: an abandoned block must STAY pending — it is recoverable, not dropped") t.Fatal("precondition: an abandoned block must stay pending — it is recoverable, not dropped")
} }
// Ask again. An undecided block that nothing has delivered must still be wanted. // Ask again. An undecided block that nothing has delivered must still be wanted.
@@ -97,9 +94,9 @@ func TestSelfHeal_AbandonedBlock_KeepsRequestingCatchup(t *testing.T) {
if got := cu.count(); got <= first { if got := cu.count(); got <= first {
t.Fatalf("an abandoned block whose catch-up was dropped must be re-requested once the "+ t.Fatalf("an abandoned block whose catch-up was dropped must be re-requested once the "+
"cooldown lapses: fetches went %d -> %d. One dropped fetch is permanent, which is "+ "cooldown lapses: fetches went %d -> %d. Asked for exactly once, a single dropped "+
"how two mainnet validators sat at a fixed height for hours while their peers held "+ "fetch pins the node at this height for good, even while its peers hold the block",
"the blocks the whole time", first, got) first, got)
} }
if m, _ := cu.lastMissing(); m != blk9.id { if m, _ := cu.lastMissing(); m != blk9.id {
t.Fatalf("the re-request must target the stuck block %s, got %s", blk9.id, m) t.Fatalf("the re-request must target the stuck block %s, got %s", blk9.id, m)
+12 -12
View File
@@ -3,14 +3,14 @@
// applied_head_test.go — catch-up has to aim at the VM, not at the ledger. // applied_head_test.go — catch-up has to aim at the VM, not at the ledger.
// //
// The consensus ledger and the VM persist separately. A node that stops uncleanly can // The consensus ledger and the VM persist separately, so a node that stops uncleanly
// come back with certified finality ABOVE the block its VM last committed, and then // can come back with certified finality above the block its VM last committed. Finality
// finality cannot advance at all: the fail-closed guard correctly refuses to move past // then cannot advance at all: the fail-closed guard refuses to move past applied state,
// applied state, while catch-up — steering by the ledger — asks peers for the height // while catch-up steering by the ledger asks peers for the height above the gap and so
// above the gap and never fetches the blocks the VM is missing. // never fetches the blocks the VM is missing. Every arriving cert is refused for naming
// a parent the VM does not hold, and the two heads never reconverge.
// //
// Measured on mainnet luxd-3: ledger 1160628, EVM 1159050, every incoming cert refused // Catch-up must therefore steer by the lower of the two heads: what the VM has applied.
// "expected accepted block to have parent ...:1159050 but got ...:1161398".
package chain package chain
@@ -39,12 +39,12 @@ func (v *appliedVM) GetBlock(_ context.Context, id ids.ID) (block.Block, error)
return nil, errors.New("not held") return nil, errors.New("not held")
} }
// TestAppliedHeadReportsTheVMWhenTheLedgerIsAhead is the mainnet state exactly: a // TestAppliedHeadReportsTheVMWhenTheLedgerIsAhead: a ledger certified well above the
// ledger certified well above the block the VM actually holds. // block the VM actually holds.
func TestAppliedHeadReportsTheVMWhenTheLedgerIsAhead(t *testing.T) { func TestAppliedHeadReportsTheVMWhenTheLedgerIsAhead(t *testing.T) {
const ( const (
ledgerHeight = 1_160_628 // what consensus had certified ledgerHeight = 1_000_000 // certified by consensus
appliedHeight = 1_159_050 // what the EVM had committed appliedHeight = 998_500 // committed by the VM
) )
blk := &verifyOnceBlock{ blk := &verifyOnceBlock{
@@ -75,7 +75,7 @@ func TestAppliedHeadReportsTheVMWhenTheLedgerIsAhead(t *testing.T) {
t.Fatalf("AppliedHead: %v", err) t.Fatalf("AppliedHead: %v", err)
} }
if applied == ledgerHeight { if applied == ledgerHeight {
t.Fatal("AppliedHead returned the LEDGER's height — catch-up would ask for blocks above the gap and never fetch what the VM is missing") t.Fatal("AppliedHead returned the ledger's height — catch-up would ask for blocks above the gap and never fetch what the VM is missing")
} }
if applied != appliedHeight { if applied != appliedHeight {
t.Fatalf("applied=%d, want the VM's %d", applied, appliedHeight) t.Fatalf("applied=%d, want the VM's %d", applied, appliedHeight)
+77 -77
View File
@@ -1,22 +1,22 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved. // Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms. // See the file LICENSE for licensing terms.
// bootstrap_sync_test.go — proves the BOOTSTRAP accept path (initial sync by // bootstrap_sync_test.go — the bootstrap accept path: initial sync by
// fetch-from-frontier + re-execute, no vote, no cert): // fetch-from-frontier and re-execute, with no vote and no cert.
// //
// - EMPTY node (genesis → tip): a fresh node with only genesis converges to a // - Empty node (genesis → tip): a fresh node holding only genesis converges to a
// peer's height N by re-executing fetched blocks 1..N oldest-first. It does NOT // peer's height N by re-executing fetched blocks 1..N oldest-first, rather than
// stay stuck at 0. // staying at 0.
// - PARTIAL node (M → N): a node already at height M converges to N by executing // - Partial node (M → N): a node already at height M converges to N by executing
// M+1..N; re-feeding a block it already holds is a clean no-op. // M+1..N; re-feeding a block it already holds is a clean no-op.
// - SAFETY (reject invalid): a block that fails Verify (a corrupt/forged block // - Safety: a block that fails Verify — corrupt or forged, from a peer that need
// from a malicious peer) is REJECTED — VM.Accept never runs and finalized height // not be honest — is rejected. VM.Accept never runs and finalized height does not
// does not advance past it. The sync recovers when a VALID block arrives. // advance past it. The sync recovers when a valid block arrives.
// - PHASE BOUNDARY: once the node goes live (FinishBootstrap), the bootstrap accept // - Phase boundary: once the node goes live (FinishBootstrap), the bootstrap accept
// path is fail-closed — a fetched block can no longer finalize without an α-of-K // path is fail-closed — a fetched block can no longer finalize without an α-of-K
// cert. This is exactly where bootstrap ends and the live cert-gate begins. // cert. That is where bootstrap ends and the live cert-gate begins.
// - ORDERING: a gapped / out-of-order block is refused by the per-height guard, so // - Ordering: a gapped or out-of-order block is refused by the per-height guard, so
// oldest-first is ENFORCED, not assumed. // oldest-first is enforced by the engine rather than assumed of the transport.
package chain package chain
import ( import (
@@ -41,8 +41,8 @@ func feedBootstrap(t *testing.T, rt *Runtime, gap []*verifyOnceBlock) {
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// EMPTY NODE — genesis → tip. The headline fix: a fresh/empty node FETCHES + // Empty node — genesis → tip. A node holding only genesis fetches, executes and
// EXECUTES + ACCEPTS blocks 1..N from a peer and reaches height N (not stuck at 0). // accepts blocks 1..N from a peer, reaching height N.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
func TestBootstrap_EmptyNodeSyncsGenesisToTip(t *testing.T) { func TestBootstrap_EmptyNodeSyncsGenesisToTip(t *testing.T) {
@@ -50,8 +50,9 @@ func TestBootstrap_EmptyNodeSyncsGenesisToTip(t *testing.T) {
vm := newCatchupVM() vm := newCatchupVM()
rt, _, rec := newCatchupRuntime(t, vs, 0, vm) rt, _, rec := newCatchupRuntime(t, vs, 0, vm)
// An EMPTY node: it holds only genesis (height 0). This is the luxd-0 incident — // An empty node: it holds only genesis (height 0). A bootstrapper that fetches
// stuck at C-Chain height 0 because the bootstrapper fetched nothing. // nothing leaves such a node at height 0 for as long as it runs — nothing else in
// the engine will advance it.
genesis := newTestBlock(0, ids.Empty, "genesis") genesis := newTestBlock(0, ids.Empty, "genesis")
seedBehindAt(t, rt, vm, genesis) seedBehindAt(t, rt, vm, genesis)
@@ -61,28 +62,27 @@ func TestBootstrap_EmptyNodeSyncsGenesisToTip(t *testing.T) {
gap := buildGap(vm, genesis, N) gap := buildGap(vm, genesis, N)
feedBootstrap(t, rt, gap) feedBootstrap(t, rt, gap)
// CONVERGENCE: the empty node reached the network tip purely by fetch+execute. // Convergence: the empty node reached the network tip purely by fetch+execute.
if fh, set := rt.Transitive.consensus.GetFinalizedHeight(); !set || fh != uint64(N) { if fh, set := rt.Transitive.consensus.GetFinalizedHeight(); !set || fh != uint64(N) {
t.Fatalf("empty node did NOT sync to tip: finalized height (%d,%v), want %d", fh, set, N) t.Fatalf("empty node did not sync to tip: finalized height (%d,%v), want %d", fh, set, N)
} }
if tip := rt.Transitive.consensus.GetFinalizedTip(); tip != gap[N-1].id { if tip := rt.Transitive.consensus.GetFinalizedTip(); tip != gap[N-1].id {
t.Fatalf("finalized tip %s != block N %s", tip, gap[N-1].id) t.Fatalf("finalized tip %s != block N %s", tip, gap[N-1].id)
} }
// Bootstrap accepts WITHOUT voting or assembling certs — it re-executes // Bootstrap accepts without voting or assembling certs — it re-executes blocks the
// network-finalized blocks. No vote/cert may be emitted (re-voting a decided // network already finalized. No vote or cert may be emitted: re-voting a decided
// height is spam the network drops). // height is spam the network drops.
rec.mu.Lock() rec.mu.Lock()
votes, certs := len(rec.votes), len(rec.certs) votes, certs := len(rec.votes), len(rec.certs)
rec.mu.Unlock() rec.mu.Unlock()
if votes != 0 || certs != 0 { if votes != 0 || certs != 0 {
t.Fatalf("bootstrap must NOT vote (%d) or gossip certs (%d) — it re-executes finalized blocks", votes, certs) t.Fatalf("bootstrap must not vote (%d) or gossip certs (%d) — it re-executes finalized blocks", votes, certs)
} }
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// PARTIAL NODE — M → N. The stranded-spare incident (luxd-2 at 1082780, tip // Partial node — M → N. A node already at M converges to N by executing the gap.
// 1082797): a node already at M converges to N by executing the gap.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
func TestBootstrap_PartialNodeConvergesToTip(t *testing.T) { func TestBootstrap_PartialNodeConvergesToTip(t *testing.T) {
@@ -90,8 +90,8 @@ func TestBootstrap_PartialNodeConvergesToTip(t *testing.T) {
vm := newCatchupVM() vm := newCatchupVM()
rt, _, _ := newCatchupRuntime(t, vs, 0, vm) rt, _, _ := newCatchupRuntime(t, vs, 0, vm)
const M = uint64(1082780) const M = uint64(1_000_000) // a node deep in a long chain, not near genesis
const k = 17 // the incident delta: N = M+17 = 1082797 const k = 17 // gap width: N = M+k
tip := newTestBlock(M, ids.Empty, "tip@M") tip := newTestBlock(M, ids.Empty, "tip@M")
seedBehindAt(t, rt, vm, tip) seedBehindAt(t, rt, vm, tip)
gap := buildGap(vm, tip, k) gap := buildGap(vm, tip, k)
@@ -99,10 +99,10 @@ func TestBootstrap_PartialNodeConvergesToTip(t *testing.T) {
feedBootstrap(t, rt, gap) feedBootstrap(t, rt, gap)
if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != M+uint64(k) { if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != M+uint64(k) {
t.Fatalf("partial node did NOT converge: finalized height %d, want %d (M=%d + k=%d)", fh, M+uint64(k), M, k) t.Fatalf("partial node did not converge: finalized height %d, want %d (M=%d + k=%d)", fh, M+uint64(k), M, k)
} }
// IDEMPOTENT RESPONDER OVERLAP: re-feeding a block we already hold (height ≤ // Idempotent responder overlap: re-feeding a block we already hold (height ≤
// finalized) is a clean no-op — not a re-Accept, not an error. The frontier // finalized) is a clean no-op — not a re-Accept, not an error. The frontier
// responder always serves some blocks we already have; bootstrap must skip them. // responder always serves some blocks we already have; bootstrap must skip them.
already := gap[0] already := gap[0]
@@ -110,14 +110,14 @@ func TestBootstrap_PartialNodeConvergesToTip(t *testing.T) {
t.Fatalf("re-feeding an already-synced block must be a no-op, got: %v", err) t.Fatalf("re-feeding an already-synced block must be a no-op, got: %v", err)
} }
if got := already.AcceptCalled(); got != 1 { if got := already.AcceptCalled(); got != 1 {
t.Fatalf("already-synced block must NOT be re-Accepted, AcceptCalled=%d", got) t.Fatalf("already-synced block must not be re-Accepted, AcceptCalled=%d", got)
} }
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// SAFETY — a block that fails Verify is REJECTED. A malicious peer cannot advance // Safety — a block that fails Verify is rejected. A peer cannot advance the sync
// the sync with a corrupt/forged block; finalized height does not move. The sync // with a corrupt or forged block; finalized height does not move. The sync then
// then recovers when a VALID block at that height arrives. // recovers when a valid block at that height arrives.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
func TestBootstrap_RejectsInvalidBlockThenRecovers(t *testing.T) { func TestBootstrap_RejectsInvalidBlockThenRecovers(t *testing.T) {
@@ -129,27 +129,27 @@ func TestBootstrap_RejectsInvalidBlockThenRecovers(t *testing.T) {
tip := newTestBlock(M, ids.Empty, "tip@M") tip := newTestBlock(M, ids.Empty, "tip@M")
seedBehindAt(t, rt, vm, tip) seedBehindAt(t, rt, vm, tip)
// A corrupt block at the contiguous next height M+1 whose Verify FAILS. We model // A corrupt block at the contiguous next height M+1 whose Verify fails. The
// the failure faithfully with the verify-once block: pre-exhausting its single // verify-once block models that faithfully: pre-exhausting its single successful
// successful Verify makes the bootstrap path's Verify (the next call) fail, exactly // Verify makes the bootstrap path's Verify (the next call) fail, as a real VM's
// as a real VM's Verify would reject a block with a bad state root / invalid txs. // Verify would reject a block with a bad state root or invalid txs.
bad := newTestBlock(M+1, tip.id, "corrupt@M+1") bad := newTestBlock(M+1, tip.id, "corrupt@M+1")
vm.register(bad) vm.register(bad)
_ = bad.Verify(context.Background()) // exhaust the one good Verify → next call errors _ = bad.Verify(context.Background()) // exhaust the one good Verify → next call errors
err := rt.AcceptBootstrapBlock(context.Background(), bad.bytes) err := rt.AcceptBootstrapBlock(context.Background(), bad.bytes)
if err == nil { if err == nil {
t.Fatal("SAFETY VIOLATION: a block that fails Verify was accepted via bootstrap") t.Fatal("a block that fails Verify was accepted via bootstrap")
} }
if got := bad.AcceptCalled(); got != 0 { if got := bad.AcceptCalled(); got != 0 {
t.Fatalf("SAFETY VIOLATION: invalid block ran VM.Accept %d×", got) t.Fatalf("invalid block ran VM.Accept %d×", got)
} }
if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != M { if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != M {
t.Fatalf("SAFETY VIOLATION: finalized height moved off M=%d to %d on an invalid block", M, fh) t.Fatalf("finalized height moved off M=%d to %d on an invalid block", M, fh)
} }
// RECOVERY: a VALID block at the same height M+1 finalizes — the rejected invalid // Recovery: a valid block at the same height M+1 finalizes — the rejected block
// block did not poison the height (it never committed to the ledger). // did not poison the height, because it never committed to the ledger.
good := newTestBlock(M+1, tip.id, "valid@M+1") good := newTestBlock(M+1, tip.id, "valid@M+1")
vm.register(good) vm.register(good)
if err := rt.AcceptBootstrapBlock(context.Background(), good.bytes); err != nil { if err := rt.AcceptBootstrapBlock(context.Background(), good.bytes); err != nil {
@@ -161,8 +161,8 @@ func TestBootstrap_RejectsInvalidBlockThenRecovers(t *testing.T) {
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// PHASE BOUNDARY — once the node goes live (FinishBootstrap), the bootstrap accept // Phase boundary — once the node goes live (FinishBootstrap), the bootstrap accept
// path is FAIL-CLOSED. This is where bootstrap ends and the cert-gated live path // path is fail-closed. That is where bootstrap ends and the cert-gated live path
// begins: a fetched block can no longer finalize without an α-of-K cert. // begins: a fetched block can no longer finalize without an α-of-K cert.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -186,25 +186,25 @@ func TestBootstrap_FailClosedOnceLive(t *testing.T) {
t.Fatal("FinishBootstrap must end the bootstrap phase") t.Fatal("FinishBootstrap must end the bootstrap phase")
} }
// Now a fetched block — even a perfectly valid, contiguous one — is REFUSED by the // Now a fetched block — even a perfectly valid, contiguous one — is refused by the
// bootstrap path. Once live, only the α-of-K cert path finalizes. // bootstrap path. Once live, only the α-of-K cert path finalizes.
next := newTestBlock(M+1, tip.id, "post-live@M+1") next := newTestBlock(M+1, tip.id, "post-live@M+1")
vm.register(next) vm.register(next)
if err := rt.AcceptBootstrapBlock(context.Background(), next.bytes); err == nil { if err := rt.AcceptBootstrapBlock(context.Background(), next.bytes); err == nil {
t.Fatal("PHASE VIOLATION: bootstrap accept succeeded after the node went live (cert-gate bypass)") t.Fatal("bootstrap accept succeeded after the node went live, bypassing the cert-gate")
} }
if got := next.AcceptCalled(); got != 0 { if got := next.AcceptCalled(); got != 0 {
t.Fatalf("PHASE VIOLATION: post-live bootstrap accept ran VM.Accept %d×", got) t.Fatalf("post-live bootstrap accept ran VM.Accept %d×", got)
} }
if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != M { if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != M {
t.Fatalf("PHASE VIOLATION: finalized height moved off M=%d to %d via post-live bootstrap", M, fh) t.Fatalf("finalized height moved off M=%d to %d via post-live bootstrap", M, fh)
} }
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// ORDERING — an out-of-order / gapped block is refused by the per-height guard, so // Ordering — an out-of-order or gapped block is refused by the per-height guard, so
// the oldest-first invariant is ENFORCED. After the gap is filled in order, the // the oldest-first invariant is enforced. After the gap is filled in order, the same
// same height finalizes. // height finalizes.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
func TestBootstrap_OutOfOrderRefusedThenInOrderConverges(t *testing.T) { func TestBootstrap_OutOfOrderRefusedThenInOrderConverges(t *testing.T) {
@@ -218,12 +218,12 @@ func TestBootstrap_OutOfOrderRefusedThenInOrderConverges(t *testing.T) {
gap := buildGap(vm, tip, 3) // M+1, M+2, M+3 gap := buildGap(vm, tip, 3) // M+1, M+2, M+3
// Skip ahead: feed M+2 while still finalized at M. The contiguity guard refuses it // Skip ahead: feed M+2 while still finalized at M. The contiguity guard refuses it
// (height M+2 != finalized+1 == M+1) WITHOUT verifying/accepting. // (height M+2 != finalized+1 == M+1) without verifying or accepting.
if err := rt.AcceptBootstrapBlock(context.Background(), gap[1].bytes); err == nil { if err := rt.AcceptBootstrapBlock(context.Background(), gap[1].bytes); err == nil {
t.Fatal("ORDERING VIOLATION: a height-M+2 block accepted while finalized at M (gap guard bypassed)") t.Fatal("a height-M+2 block was accepted while finalized at M, bypassing the gap guard")
} }
if got := gap[1].AcceptCalled(); got != 0 { if got := gap[1].AcceptCalled(); got != 0 {
t.Fatalf("ORDERING VIOLATION: out-of-order block ran VM.Accept %d×", got) t.Fatalf("out-of-order block ran VM.Accept %d×", got)
} }
if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != M { if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != M {
t.Fatalf("out-of-order accept moved finalized height off M=%d to %d", M, fh) t.Fatalf("out-of-order accept moved finalized height off M=%d to %d", M, fh)
@@ -237,63 +237,63 @@ func TestBootstrap_OutOfOrderRefusedThenInOrderConverges(t *testing.T) {
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// M2 — FIRST-BLOCK ANCHOR. When the consensus finalized-height tracker is UNSET // First-block anchor. When the consensus finalized-height tracker is unset — the
// (the un-seeded / empty-genesis path — SyncState only sets it when the VM has a // un-seeded, empty-genesis path, since SyncState only sets it when the VM has a
// non-empty last-accepted), the per-height guard alone would record WHATEVER // non-empty last-accepted — the per-height guard alone would record whatever
// (height, parent) the first fetched block claims. AcceptBootstrapBlock instead // (height, parent) the first fetched block claims. AcceptBootstrapBlock instead
// binds the FIRST block to the VM's ACTUAL last-accepted: a peer cannot seed // binds the first block to the VM's actual last-accepted, so a peer cannot seed
// finality at an arbitrary height/parent. // finality at an arbitrary height or parent.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
func TestBootstrap_M2_FirstBlockAnchorsToLocalLastAccepted(t *testing.T) { func TestBootstrap_FirstBlockAnchorsToLocalLastAccepted(t *testing.T) {
vs := newTestValidatorSet(5) vs := newTestValidatorSet(5)
vm := newCatchupVM() vm := newCatchupVM()
rt, _, _ := newCatchupRuntime(t, vs, 0, vm) rt, _, _ := newCatchupRuntime(t, vs, 0, vm)
// Empty node holding only genesis (height 0). Crucially we DO NOT seedBehindAt — // Empty node holding only genesis (height 0). We deliberately skip seedBehindAt, so
// so the consensus finalized-height tracker stays UNSET (set==false). The VM's // the consensus finalized-height tracker stays unset (set==false). The VM's
// last-accepted is genesis. // last-accepted is genesis.
genesis := newTestBlock(0, ids.Empty, "genesis") genesis := newTestBlock(0, ids.Empty, "genesis")
vm.register(genesis) vm.register(genesis)
_ = vm.SetPreference(context.Background(), genesis.id) _ = vm.SetPreference(context.Background(), genesis.id)
if _, set := rt.Transitive.consensus.GetFinalizedHeight(); set { if _, set := rt.Transitive.consensus.GetFinalizedHeight(); set {
t.Fatal("precondition: tracker must be UNSET for the M2 path") t.Fatal("precondition: tracker must be unset for the unanchored path")
} }
// ATTACK A — a peer's first block seeds finality far ahead (height 500) on an // A peer's first block seeds finality far ahead (height 500) on an arbitrary
// arbitrary parent. Without the anchor, markFinalizedLocked would record it. The // parent. Without the anchor, markFinalizedLocked would record it. The anchor
// anchor refuses it: height 500 != localLastH+1 (== 1). // refuses it: height 500 != localLastH+1 (== 1).
ahead := newTestBlock(500, ids.GenerateTestID(), "seed-ahead@500") ahead := newTestBlock(500, ids.GenerateTestID(), "seed-ahead@500")
vm.register(ahead) vm.register(ahead)
if err := rt.AcceptBootstrapBlock(context.Background(), ahead.bytes); err == nil { if err := rt.AcceptBootstrapBlock(context.Background(), ahead.bytes); err == nil {
t.Fatal("M2 VIOLATION: first block at height 500 seeded finality off an unset tracker") t.Fatal("first block at height 500 seeded finality off an unset tracker")
} }
if got := ahead.AcceptCalled(); got != 0 { if got := ahead.AcceptCalled(); got != 0 {
t.Fatalf("M2 VIOLATION: ahead block ran VM.Accept %d×", got) t.Fatalf("ahead block ran VM.Accept %d×", got)
} }
if _, set := rt.Transitive.consensus.GetFinalizedHeight(); set { if _, set := rt.Transitive.consensus.GetFinalizedHeight(); set {
t.Fatal("M2 VIOLATION: tracker became set off an unanchored first block") t.Fatal("tracker became set off an unanchored first block")
} }
// ATTACK B — the right height (1) but the WRONG parent (not genesis). Refused: the // The right height (1) but the wrong parent (not genesis). Refused: the first
// first block must extend the VM's actual last-accepted. // block must extend the VM's actual last-accepted.
wrongParent := newTestBlock(1, ids.GenerateTestID(), "wrong-parent@1") wrongParent := newTestBlock(1, ids.GenerateTestID(), "wrong-parent@1")
vm.register(wrongParent) vm.register(wrongParent)
if err := rt.AcceptBootstrapBlock(context.Background(), wrongParent.bytes); err == nil { if err := rt.AcceptBootstrapBlock(context.Background(), wrongParent.bytes); err == nil {
t.Fatal("M2 VIOLATION: first block at height 1 with a non-genesis parent was accepted") t.Fatal("first block at height 1 with a non-genesis parent was accepted")
} }
if _, set := rt.Transitive.consensus.GetFinalizedHeight(); set { if _, set := rt.Transitive.consensus.GetFinalizedHeight(); set {
t.Fatal("M2 VIOLATION: tracker became set off a wrong-parent first block") t.Fatal("tracker became set off a wrong-parent first block")
} }
// HONEST — height 1, parent == genesis. Anchored, Verify passes, finalizes; the // Height 1, parent == genesis. Anchored, Verify passes, finalizes; the tracker is
// tracker is now seeded at height 1 and the normal contiguity guard takes over. // now seeded at height 1 and the normal contiguity guard takes over.
first := newTestBlock(1, genesis.id, "first@1") first := newTestBlock(1, genesis.id, "first@1")
vm.register(first) vm.register(first)
if err := rt.AcceptBootstrapBlock(context.Background(), first.bytes); err != nil { if err := rt.AcceptBootstrapBlock(context.Background(), first.bytes); err != nil {
t.Fatalf("honest contiguous first block must finalize, got: %v", err) t.Fatalf("contiguous first block must finalize, got: %v", err)
} }
if fh, set := rt.Transitive.consensus.GetFinalizedHeight(); !set || fh != 1 { if fh, set := rt.Transitive.consensus.GetFinalizedHeight(); !set || fh != 1 {
t.Fatalf("M2: tracker must be seeded at height 1 after the anchored first block, got (%d,%v)", fh, set) t.Fatalf("tracker must be seeded at height 1 after the anchored first block, got (%d,%v)", fh, set)
} }
} }
+86 -85
View File
@@ -1,17 +1,17 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved. // Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms. // See the file LICENSE for licensing terms.
// catchup_convergence_test.go — proves the cert-carrying catch-up path: // catchup_test.go — the cert-carrying catch-up path:
// //
// - LIVENESS: a validator stranded at height N converges to the network tip // - Liveness: a validator stranded at height N converges to the network tip N+k by
// N+k by accepting fetched (block, cert) pairs through AcceptCatchupBlock, // accepting fetched (block, cert) pairs through AcceptCatchupBlock, without
// WITHOUT re-voting (no live quorum exists for an already-finalized height). // re-voting — no live quorum exists for an already-finalized height.
// - SAFETY: a forged / sub-quorum / below-α-floor cert delivered via catch-up // - Safety: a forged, sub-quorum or below-α-floor cert delivered via catch-up is
// is REJECTED and finalizes nothing — the cert-gate holds through catch-up // rejected and finalizes nothing. The cert-gate holds through catch-up exactly as
// exactly as it does on the live path. A node cannot be force-fed a chain. // it does on the live path, so a node cannot be force-fed a chain.
// - ORDERING: even a VALID cert applied out of parent order is refused by the // - Ordering: even a valid cert applied out of parent order is refused by the
// per-height guard (finality is contiguous), so the oldest-first invariant // per-height guard, since finality is contiguous. Oldest-first is enforced by the
// is ENFORCED, not merely assumed. // engine rather than assumed of the transport.
package chain package chain
import ( import (
@@ -25,11 +25,11 @@ import (
"github.com/luxfi/log" "github.com/luxfi/log"
) )
// catchupVM is a FAITHFUL test VM: ParseBlock returns the SAME *verifyOnceBlock // catchupVM is a faithful test VM: ParseBlock returns the same *verifyOnceBlock that
// that was registered for those bytes, so block identity (ID/height/parent) and // was registered for those bytes, so block identity (ID/height/parent) and AcceptCalled
// AcceptCalled tracking survive a round-trip through bytes — exactly as a real // tracking survive a round-trip through bytes, as a real VM's deterministic codec does
// VM's deterministic codec does (unlike verifyOnceVM.ParseBlock, which discards // and unlike verifyOnceVM.ParseBlock, which discards identity. Unknown bytes parse to
// identity). Unknown bytes parse to an error so AcceptCatchupBlock rejects them. // an error so AcceptCatchupBlock rejects them.
type catchupVM struct { type catchupVM struct {
mu sync.Mutex mu sync.Mutex
byBytes map[string]*verifyOnceBlock byBytes map[string]*verifyOnceBlock
@@ -88,12 +88,12 @@ func (m *catchupVM) SetPreference(_ context.Context, id ids.ID) error {
var _ BlockBuilder = (*catchupVM)(nil) var _ BlockBuilder = (*catchupVM)(nil)
// newCatchupRuntime builds a STARTED stake-weighted multi-validator Runtime for // newCatchupRuntime builds a started stake-weighted multi-validator Runtime for
// validator `self`, wired with the test validator set (verifier + signer + stake), // validator `self`, wired with the test validator set (verifier + signer + stake), the
// the faithful VM, and a recording gossiper (so a test can assert NO votes/certs // faithful VM, and a recording gossiper so a test can assert that no votes or certs are
// are emitted during catch-up). It returns the runtime, its chainID, and the // emitted during catch-up. It returns the runtime, its chainID, and the recorder. Stake
// recorder. Stake-weighting is wired because the value (mainnet) chain that wedged // weighting is wired because catch-up must clear the same ⅔-of-stake predicate that
// is stake-weighted — catch-up must clear the SAME ⅔-of-stake predicate. // live finality does; a headcount quorum is not enough.
func newCatchupRuntime(t *testing.T, vs *testValidatorSet, self int, vm BlockBuilder) (*Runtime, ids.ID, *recordingGossiper) { func newCatchupRuntime(t *testing.T, vs *testValidatorSet, self int, vm BlockBuilder) (*Runtime, ids.ID, *recordingGossiper) {
t.Helper() t.Helper()
chainID := ids.GenerateTestID() chainID := ids.GenerateTestID()
@@ -117,10 +117,10 @@ func newCatchupRuntime(t *testing.T, vs *testValidatorSet, self int, vm BlockBui
return rt, chainID, rec return rt, chainID, rec
} }
// catchupCertFor assembles a REAL finality cert for blk at the given chainID, // catchupCertFor assembles a real finality cert for blk at the given chainID, signed by
// signed by `voters` over blk's canonical position, asserting `threshold`. This is // `voters` over blk's canonical position, asserting `threshold`. It is byte-identical
// byte-identical to the cert an AHEAD node would have stored (CertForBlock) and // to the cert a node ahead of us would have stored (CertForBlock) and gossiped at
// gossiped at finalize time. Returns the marshaled cert bytes. // finalize time. Returns the marshaled cert bytes.
func catchupCertFor(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock, voters []int, threshold uint32) []byte { func catchupCertFor(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock, voters []int, threshold uint32) []byte {
t.Helper() t.Helper()
pos := VotePosition{ChainID: chainID, Height: blk.height, Round: 0, BlockID: blk.id, ParentID: blk.parentID} pos := VotePosition{ChainID: chainID, Height: blk.height, Round: 0, BlockID: blk.id, ParentID: blk.parentID}
@@ -139,17 +139,16 @@ func catchupCertFor(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *ver
return b return b
} }
// seedBehindAt strands the runtime at finalized height N with tip block `tip`, // seedBehindAt strands the runtime at finalized height N with tip block `tip`, as a
// exactly as a node that fell behind the frontier (the incident: luxd-0 at // node that fell behind the frontier would be. The gap blocks built afterward extend
// 1082780). The gap blocks built afterward extend `tip`. // `tip`.
func seedBehindAt(t *testing.T, rt *Runtime, vm *catchupVM, tip *verifyOnceBlock) { func seedBehindAt(t *testing.T, rt *Runtime, vm *catchupVM, tip *verifyOnceBlock) {
t.Helper() t.Helper()
vm.register(tip) vm.register(tip)
// Establish CERTIFIED finality at N (a node that legitimately finalized up to N // Establish certified finality at N: a node that legitimately finalized up to N
// in-process). Post-incident-1082814, SyncState is only a non-authoritative HINT — // in-process. SyncState is only a non-authoritative hint and does not seed certified
// it no longer seeds certified finality — so the certified baseline is set through // finality, so the certified baseline has to come through the real finalize fold,
// the real finalize fold (first-finalize seeds at tip.height). The distinct // whose first finalize seeds at tip.height.
// restart-from-hint recovery is covered by the incident regression suite.
if _, err := rt.Transitive.consensus.FinalizeBranch(tip.id, tip.height, ids.Empty); err != nil { if _, err := rt.Transitive.consensus.FinalizeBranch(tip.id, tip.height, ids.Empty); err != nil {
t.Fatalf("seed behind at height %d: %v", tip.height, err) t.Fatalf("seed behind at height %d: %v", tip.height, err)
} }
@@ -174,10 +173,10 @@ func buildGap(vm *catchupVM, tip *verifyOnceBlock, k int) []*verifyOnceBlock {
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// LIVENESS — the stranded node converges to the tip THROUGH the cert path, with // Liveness — a stranded node converges to the tip through the cert path, with no
// ZERO re-voting. This is the fix for the wedge: the network will not re-vote an // re-voting. The network will not re-vote an already-finalized height, so the only
// already-finalized height, so the only way back is accepting the certs it // way back for a node left behind is applying the certs the network already
// already assembled. // assembled.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
func TestCatchup_BehindNodeConvergesViaCertPath(t *testing.T) { func TestCatchup_BehindNodeConvergesViaCertPath(t *testing.T) {
@@ -185,9 +184,9 @@ func TestCatchup_BehindNodeConvergesViaCertPath(t *testing.T) {
vm := newCatchupVM() vm := newCatchupVM()
rt, chainID, rec := newCatchupRuntime(t, vs, 0, vm) rt, chainID, rec := newCatchupRuntime(t, vs, 0, vm)
// Strand the node at N=1082780 (the incident height) and build the 17-block gap // Strand the node at N, deep in a long chain, and build a k-block gap up to the
// up to the network tip N+17=1082797 (the incident delta). // network tip N+k.
const N = uint64(1082780) const N = uint64(1_000_000)
const k = 17 const k = 17
tip := newTestBlock(N, ids.Empty, "tip@N") tip := newTestBlock(N, ids.Empty, "tip@N")
seedBehindAt(t, rt, vm, tip) seedBehindAt(t, rt, vm, tip)
@@ -208,45 +207,46 @@ func TestCatchup_BehindNodeConvergesViaCertPath(t *testing.T) {
} }
} }
// CONVERGENCE: the behind node advanced N → N+k purely through cert-accept. // Convergence: the behind node advanced N → N+k purely through cert-accept.
if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != N+uint64(k) { if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != N+uint64(k) {
t.Fatalf("convergence FAILED: finalized height %d, want %d (N=%d + k=%d)", fh, N+uint64(k), N, k) t.Fatalf("convergence failed: finalized height %d, want %d (N=%d + k=%d)", fh, N+uint64(k), N, k)
} }
// THE DISTINGUISHING ASSERTION: convergence happened WITHOUT re-voting. The // The distinguishing assertion: convergence happened without re-voting. The
// catch-up path never broadcasts a vote and never re-gossips a cert — it applies // catch-up path never broadcasts a vote and never re-gossips a cert — it applies
// FINISHED certs. (If catch-up had fallen back to the voting Put path, the node // finished ones. Falling back to the voting Put path would broadcast votes for
// would have broadcast votes for already-decided heights and still wedged.) // already-decided heights, which no peer will answer, leaving the node stuck.
rec.mu.Lock() rec.mu.Lock()
votes, certs := len(rec.votes), len(rec.certs) votes, certs := len(rec.votes), len(rec.certs)
rec.mu.Unlock() rec.mu.Unlock()
if votes != 0 { if votes != 0 {
t.Fatalf("catch-up must NOT broadcast votes (re-voting an already-finalized height), got %d", votes) t.Fatalf("catch-up must not broadcast votes (re-voting an already-finalized height), got %d", votes)
} }
if certs != 0 { if certs != 0 {
t.Fatalf("catch-up must NOT re-gossip certs (it applies finished certs), got %d", certs) t.Fatalf("catch-up must not re-gossip certs (it applies finished certs), got %d", certs)
} }
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// SAFETY — a bad cert delivered via catch-up is REJECTED and finalizes nothing. // Safety — a bad cert delivered via catch-up is rejected and finalizes nothing. The
// The cert-gate (VerifyWeighted / α-floor) holds through the catch-up path with // cert-gate (VerifyWeighted and the α-floor) holds through the catch-up path with
// the SAME rigor as live finality. A node cannot be force-fed a bad chain. // the same rigor as live finality, so a node cannot be force-fed a bad chain.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
func TestCatchup_RejectsForgedAndSubQuorumCerts(t *testing.T) { func TestCatchup_RejectsForgedAndSubQuorumCerts(t *testing.T) {
const N = uint64(1082780) const N = uint64(1_000_000)
// Each sub-case strands a FRESH node at N and tries to push block N+1 with a // Each sub-case strands a fresh node at N and tries to push block N+1 with a
// DEFECTIVE cert. None may finalize. Fresh runtimes keep verifyOnceBlock.Verify // defective cert. None may finalize. Fresh runtimes keep verifyOnceBlock.Verify
// single-shot and isolate the per-height ledger. // single-shot and isolate the per-height ledger.
cases := []struct { cases := []struct {
name string name string
cert func(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock) []byte cert func(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock) []byte
}{ }{
{ {
// FORGED signature: 4 voters (count ok, stake ok) but voter 0's slot // Forged signature: 4 voters (count ok, stake ok) but voter 0's slot
// carries voter 1's signature → clause-6 (signature) fails → no cert. // carries voter 1's signature, so the signature clause fails and no cert
// is formed.
name: "forged-signature", name: "forged-signature",
cert: func(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock) []byte { cert: func(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock) []byte {
pos := VotePosition{ChainID: chainID, Height: blk.height, Round: 0, BlockID: blk.id, ParentID: blk.parentID} pos := VotePosition{ChainID: chainID, Height: blk.height, Round: 0, BlockID: blk.id, ParentID: blk.parentID}
@@ -265,19 +265,20 @@ func TestCatchup_RejectsForgedAndSubQuorumCerts(t *testing.T) {
}, },
}, },
{ {
// SUB-QUORUM by stake: 3 of 5 validators (count=3≥α=3 passes the COUNT // Sub-quorum by stake: 3 of 5 validators clears the count predicate
// predicate) but 3/5 = 60% ≤ ⅔ → VerifyWeighted's strict supermajority // (count=3 ≥ α=3) but 3/5 = 60% ≤ ⅔, so VerifyWeighted's strict
// fails → no finality. This is the HIGH-3 headcount-vs-stake gap, enforced // supermajority fails and nothing finalizes. This is the gap between a
// through catch-up. // headcount quorum and a stake quorum, enforced through catch-up.
name: "sub-quorum-stake", name: "sub-quorum-stake",
cert: func(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock) []byte { cert: func(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock) []byte {
return catchupCertFor(t, vs, chainID, blk, []int{0, 1, 2}, 3) return catchupCertFor(t, vs, chainID, blk, []int{0, 1, 2}, 3)
}, },
}, },
{ {
// BELOW α-FLOOR: a cert that asserts a LOWER threshold (1) than the chain's // Below the α-floor: a cert asserting a lower threshold (1) than the
// α (3). HandleIncomingCert rejects it at the MinThreshold floor even though // chain's α (3). HandleIncomingCert rejects it at the MinThreshold floor
// its 4 signatures verify — sub-quorum finality-forgery defence. // even though its 4 signatures verify, so a cert cannot buy finality by
// naming a weaker quorum than the chain runs.
name: "below-alpha-floor", name: "below-alpha-floor",
cert: func(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock) []byte { cert: func(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock) []byte {
return catchupCertFor(t, vs, chainID, blk, []int{0, 1, 2, 3}, 1) return catchupCertFor(t, vs, chainID, blk, []int{0, 1, 2, 3}, 1)
@@ -299,13 +300,13 @@ func TestCatchup_RejectsForgedAndSubQuorumCerts(t *testing.T) {
err := rt.AcceptCatchupBlock(context.Background(), blk.bytes, bad) err := rt.AcceptCatchupBlock(context.Background(), blk.bytes, bad)
if err == nil { if err == nil {
t.Fatalf("SAFETY VIOLATION: a %s cert was accepted via catch-up", tc.name) t.Fatalf("a %s cert was accepted via catch-up", tc.name)
} }
if rt.IsAccepted(blk.id) { if rt.IsAccepted(blk.id) {
t.Fatalf("SAFETY VIOLATION: %s cert finalized block N+1 (IsAccepted)", tc.name) t.Fatalf("%s cert finalized block N+1 (IsAccepted)", tc.name)
} }
if got := blk.AcceptCalled(); got != 0 { if got := blk.AcceptCalled(); got != 0 {
t.Fatalf("SAFETY VIOLATION: %s cert ran VM.Accept %d×", tc.name, got) t.Fatalf("%s cert ran VM.Accept %d×", tc.name, got)
} }
if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != N { if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != N {
t.Fatalf("%s: finalized height moved off N=%d to %d on a bad cert", tc.name, N, fh) t.Fatalf("%s: finalized height moved off N=%d to %d on a bad cert", tc.name, N, fh)
@@ -315,10 +316,10 @@ func TestCatchup_RejectsForgedAndSubQuorumCerts(t *testing.T) {
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// ORDERING — even a VALID cert applied OUT OF PARENT ORDER is refused. The // Ordering — even a valid cert applied out of parent order is refused. The
// per-height guard requires height == finalizedHeight+1 AND parent == finalizedTip, // per-height guard requires height == finalizedHeight+1 and parent == finalizedTip,
// so the oldest-first invariant is ENFORCED by the engine, not merely assumed by // so the oldest-first invariant is enforced by the engine rather than assumed of the
// the transport. After the gap is filled in order, the same block finalizes. // transport. After the gap is filled in order, the same block finalizes.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
func TestCatchup_OutOfOrderRefusedThenInOrderConverges(t *testing.T) { func TestCatchup_OutOfOrderRefusedThenInOrderConverges(t *testing.T) {
@@ -326,30 +327,30 @@ func TestCatchup_OutOfOrderRefusedThenInOrderConverges(t *testing.T) {
vm := newCatchupVM() vm := newCatchupVM()
rt, chainID, _ := newCatchupRuntime(t, vs, 0, vm) rt, chainID, _ := newCatchupRuntime(t, vs, 0, vm)
const N = uint64(1082780) const N = uint64(1_000_000)
tip := newTestBlock(N, ids.Empty, "tip@N") tip := newTestBlock(N, ids.Empty, "tip@N")
seedBehindAt(t, rt, vm, tip) seedBehindAt(t, rt, vm, tip)
gap := buildGap(vm, tip, 2) // N+1, N+2 (kept pristine for step 2) gap := buildGap(vm, tip, 2) // N+1, N+2 (kept pristine for step 2)
// (1) Try to skip ahead: a DISTINCT block at height N+2 (parent = the real N+1) // Try to skip ahead: a distinct block at height N+2 (parent = the real N+1) with a
// with a perfectly VALID 4-of-5 cert, applied while still finalized at N. The // perfectly valid 4-of-5 cert, applied while still finalized at N. The per-height
// per-height guard refuses it (height N+2 != finalizedHeight+1 == N+1) — a valid // guard refuses it (height N+2 != finalizedHeight+1 == N+1) — a valid cert does not
// cert does NOT license a non-contiguous finalize. // license a non-contiguous finalize.
ooo := newTestBlock(N+2, gap[0].id, "ooo@N+2") ooo := newTestBlock(N+2, gap[0].id, "ooo@N+2")
vm.register(ooo) vm.register(ooo)
certOoo := catchupCertFor(t, vs, chainID, ooo, []int{0, 1, 2, 3}, 3) certOoo := catchupCertFor(t, vs, chainID, ooo, []int{0, 1, 2, 3}, 3)
if err := rt.AcceptCatchupBlock(context.Background(), ooo.bytes, certOoo); err == nil { if err := rt.AcceptCatchupBlock(context.Background(), ooo.bytes, certOoo); err == nil {
t.Fatal("ORDERING VIOLATION: a height-N+2 block accepted while finalized at N (contiguity guard bypassed)") t.Fatal("a height-N+2 block was accepted while finalized at N, bypassing the contiguity guard")
} }
if rt.IsAccepted(ooo.id) { if rt.IsAccepted(ooo.id) {
t.Fatal("ORDERING VIOLATION: out-of-order block finalized") t.Fatal("out-of-order block finalized")
} }
if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != N { if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != N {
t.Fatalf("out-of-order accept moved finalized height off N=%d to %d", N, fh) t.Fatalf("out-of-order accept moved finalized height off N=%d to %d", N, fh)
} }
// (2) Now apply N+1 then N+2 IN ORDER → both finalize. The earlier refusal was // Now apply N+1 then N+2 in order and both finalize. The earlier refusal was the
// the contiguity guard, not a stuck path. // contiguity guard, not a stuck path.
for i, blk := range gap { for i, blk := range gap {
cert := catchupCertFor(t, vs, chainID, blk, []int{0, 1, 2, 3}, 3) cert := catchupCertFor(t, vs, chainID, blk, []int{0, 1, 2, 3}, 3)
if err := rt.AcceptCatchupBlock(context.Background(), blk.bytes, cert); err != nil { if err := rt.AcceptCatchupBlock(context.Background(), blk.bytes, cert); err != nil {
@@ -362,9 +363,9 @@ func TestCatchup_OutOfOrderRefusedThenInOrderConverges(t *testing.T) {
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// SERVE — the ahead side. A node that finalized a block retains and SERVES its // Serve — the ahead side. A node that finalized a block retains and serves its cert
// cert (CertForBlock), and the served bytes are exactly what a behind node needs // (CertForBlock), and the served bytes are exactly what a node behind it needs to
// to finalize the same block. Closes the loop: store-on-finalize ⇄ serve. // finalize the same block. That closes the loop: store-on-finalize ⇄ serve.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
func TestCatchup_CertForBlockServesWhatWasFinalized(t *testing.T) { func TestCatchup_CertForBlockServesWhatWasFinalized(t *testing.T) {
@@ -387,8 +388,8 @@ func TestCatchup_CertForBlockServesWhatWasFinalized(t *testing.T) {
t.Fatalf("finalize N+1: %v", err) t.Fatalf("finalize N+1: %v", err)
} }
// After finalize: the node serves a cert that decodes+verifies to the SAME // After finalize: the node serves a cert that decodes and verifies to the same
// finality witness — a peer can finalize on it with zero trust in this node. // finality witness, so a peer can finalize on it with zero trust in this node.
served, ok := rt.CertForBlock(blk.id) served, ok := rt.CertForBlock(blk.id)
if !ok { if !ok {
t.Fatal("CertForBlock did not serve the finalized block's cert") t.Fatal("CertForBlock did not serve the finalized block's cert")
+196 -215
View File
@@ -3,39 +3,37 @@
// cert.go — the engine-level finality witness for chain consensus. // cert.go — the engine-level finality witness for chain consensus.
// //
// THE FINALITY RULE (one rule, one place): // The finality rule, stated once:
// //
// A value block FINALIZES only after α distinct validators have each produced // A value block finalizes only after α distinct validators have each produced
// a correctly-signed ACCEPT vote over the SAME consensus position // a correctly-signed ACCEPT vote over the same consensus position
// (chain, height, round, block_id, parent_id). The proof of that fact is a // (chain, height, round, canonical block, canonical parent). The proof of that
// QuorumCert. No node — not even the proposer — may finalize a value block // fact is a QuorumCert. No node — not even the proposer — may finalize a value
// without holding (or having verified) a QuorumCert for it. // block without holding, or having verified, a QuorumCert for it.
// //
// This closes the proposer-self-finality hole: previously the proposer force- // A proposer's own vote is one vote and buys its own proposal no credit, and a
// accepted its own block on its LONE self-vote (consensus.ForceAccept), and a // peer's REJECT is never read as an ACCEPT. The α-of-K counting in consensus.go
// peer's REJECT was counted as ACCEPT for the proposer's own block // is the sole finality authority; a QuorumCert is its portable, verifiable
// (effectiveAccept = ... || IsOwnProposal). Both are deleted. The α-of-K // witness.
// Snowball counting in consensus.go is now the sole finality authority, and a
// QuorumCert is its portable, verifiable witness.
// //
// DECOMPLECTION — the rule vs. the witness's cryptography are SEPARATE: // The rule and the witness's cryptography are separate concerns:
// //
// - The RULE ("α distinct validators accepted this exact value") lives here // - The rule ("α distinct validators accepted this exact value") lives here
// and is identical on every chain (P/X/C/D) and in every deployment. // and is identical on every chain (P/X/C/D) and in every deployment.
// - The per-vote signature CRYPTOGRAPHY is pluggable via VoteVerifier. The // - The per-vote signature cryptography is pluggable via VoteVerifier. The
// engine never invents a signature scheme; the node injects one (BLS, // engine never invents a signature scheme; the node injects one (BLS,
// ML-DSA, secp256k1) backed by a proven library. A QuorumCert over signed // ML-DSA, secp256k1) backed by a proven library. A QuorumCert over signed
// votes is the full-node-verifiable witness at THIS abstraction level. // votes is the full-node-verifiable witness at this abstraction level.
// - protocol/quasar.WeightedQuorumCert is the SAME relation expressed with // - protocol/quasar.WeightedQuorumCert is the same relation expressed with
// the heavyweight PQ apparatus (per-signer FIPS 204/205 records + weighted // the heavyweight PQ apparatus (per-signer FIPS 204/205 records + weighted
// validator-set Merkle root + epoch). When a chain has its validator ML-DSA // validator-set Merkle root + epoch). When a chain has its validator ML-DSA
// key material and weighted-set root plumbed through the node layer, a // key material and weighted-set root plumbed through the node layer, a
// QuorumCert UPGRADES to carry a quasar.WeightedQuorumCert as its crypto // QuorumCert upgrades to carry a quasar.WeightedQuorumCert as its crypto
// witness with NO change to the finality rule (see CryptoWitness / the // witness with no change to the finality rule (see CryptoWitness and the
// quasar bridge in quasar.go). One rule; the witness format is // quasar bridge in quasar.go). One rule; the witness format is orthogonal
// orthogonal and forward-compatible. // and forward-compatible.
// //
// This is a quorum CERTIFICATE, not threshold signing: nothing is aggregated, // This is a quorum certificate, not threshold signing: nothing is aggregated,
// no secret share is combined. Building a cert needs no secrets — any node that // no secret share is combined. Building a cert needs no secrets — any node that
// has collected α distinct signed ACCEPT votes assembles the identical cert // has collected α distinct signed ACCEPT votes assembles the identical cert
// (leaderless, permissionless). Verification is the direct predicate below. // (leaderless, permissionless). Verification is the direct predicate below.
@@ -53,45 +51,29 @@ import (
) )
// QuorumCertVersion is the wire/struct version of the engine-level quorum // QuorumCertVersion is the wire/struct version of the engine-level quorum
// certificate. Bound into the canonical vote message and the cert digest so a // certificate. It is bound into the canonical vote message and into the cert
// version bump is non-malleable. // digest, so a version bump is non-malleable: a signature produced under one
// version cannot be presented under another.
// //
// Wire v2 added VotePosition.ValidatorSetRoot (the MEDIUM epoch-binding fix). // Version 3 makes the inner execution commitment the primary consensus object.
// The signed message binds {canonical_block_id, parent_canonical_id,
// execution_state_root, payload_root} and omits the outer proposervm envelope id
// from the signature entirely. That outer id is a non-authoritative transport
// cache key: carried on the wire for block lookup, absent from the signed message
// and from every finality and equivocation decision. Two outer envelopes wrapping
// the same inner block therefore produce identical signed messages — their votes
// interoperate and their certs are duplicate aliases, never a fork.
// //
// Wire v3 is the design's "QCv2" — the CANONICAL-COMMITMENT cert (incident // Versions do not interoperate, by construction. Because the version is folded
// 1082814 durable fix). It makes the inner execution commitment the PRIMARY // into the signed message, a cert signed under one version fails a verifier of
// consensus object: the signed message binds {canonical_block_id, // another on both the version clause and the signature clause, loudly rather than
// parent_canonical_id, execution_state_root, payload_root} and DROPS the outer // by silent mis-parse. There is no partial interop and no read-only legacy window.
// proposervm envelope id from the signature entirely. The outer id is demoted to
// a non-authoritative transport cache key (carried in the wire for block lookup,
// excluded from the signed message and from every finality/equivocation
// decision). Two outer envelopes that wrap the SAME inner block therefore produce
// IDENTICAL signed messages — their votes interoperate and their certs are
// duplicates (harmless aliases), never a fork.
// //
// This is a deliberate forward-only break: a v2 signature (outer-id finality) and // That is fail-safe but not fail-live across a version boundary: while fewer than
// a v3 signature (canonical finality) are over different messages and carry // α validators run a given version, no α-quorum of that version can form, so
// different versions, so a mixed-version cert fails clause-1 (version) and // finality stalls on both sides of the skew. A version change is therefore a single
// clause-6 (signature) loudly rather than silently mis-parsing. There is no partial // coordinated cut of at least α of the validator set, not a staged migration — a
// QCv1→QCv2 interop and no read-only legacy window. // trickle that straddles α on each side halts finality on both.
//
// ROLL DISCIPLINE (M2 — fail-safe, but NOT fail-live across the version skew).
// Because the version is bound into the signed message, a v2 cert and a v3 cert can
// NEVER be confused and NEVER fork — cross-version verification fails closed. But
// fail-CLOSED means finality STALLS while fewer than α validators run v3: v3 nodes
// reject v2 certs (wrong version) and vice-versa, so no α-quorum of one version
// forms during a mixed window, and any v2 node still alive keeps the old outer-id
// equivocation-halt behavior. Therefore:
//
// 1. Cut ≥ α validators per net to v3 ATOMICALLY (≥4 of 5) — the same coordinated
// all-validator roll the incident recovery already performs. A trickle upgrade
// that straddles α on each version halts finality on BOTH.
// 2. The v2 baseline image MUST already carry the converge-on-A seed-recovery (the
// live recovery on v1.31.x), so the brief mixed window is not itself halt-prone
// before the v3 cut completes.
//
// We control all 5 validators per net and roll them together, so the skew window is
// a single coordinated restart, not a staged migration.
const QuorumCertVersion uint16 = 3 const QuorumCertVersion uint16 = 3
// QCType names a certificate's semantic role so a signature gathered for one // QCType names a certificate's semantic role so a signature gathered for one
@@ -108,7 +90,7 @@ const (
) )
// Typed verification errors. Each maps 1:1 to one predicate clause so a caller // Typed verification errors. Each maps 1:1 to one predicate clause so a caller
// or test can name the exact failure. Every one is a CLEAN rejection (the cert // or test can name the exact failure. Every one is a clean rejection (the cert
// or vote is invalid); none is a panic and none does unbounded work — an // or vote is invalid); none is a panic and none does unbounded work — an
// adversarial cert yields an error, never a node crash. // adversarial cert yields an error, never a node crash.
var ( var (
@@ -141,7 +123,7 @@ type SignedVote struct {
// NodeID is the signing validator's identifier. Votes in a cert are sorted // NodeID is the signing validator's identifier. Votes in a cert are sorted
// strictly-increasing by this field (distinctness / anti-double-count). // strictly-increasing by this field (distinctness / anti-double-count).
NodeID ids.NodeID NodeID ids.NodeID
// Accept is the validator's decision. For a finality cert this MUST be true. // Accept is the validator's decision. For a finality cert it is always true.
Accept bool Accept bool
// Signature is the validator's signature over CanonicalVoteMessage of the // Signature is the validator's signature over CanonicalVoteMessage of the
// cert's position. Verified by a VoteVerifier; the engine is scheme-agnostic. // cert's position. Verified by a VoteVerifier; the engine is scheme-agnostic.
@@ -150,21 +132,21 @@ type SignedVote struct {
// VotePosition is the consensus position a vote (and a cert) binds to. // VotePosition is the consensus position a vote (and a cert) binds to.
// //
// THE CANONICAL/TRANSPORT SPLIT (the incident-1082814 durable fix). The position // The canonical/transport split. The position carries two identities for the
// carries TWO identities for the block: // block:
// //
// - the CANONICAL execution identity — {CanonicalID, ParentCanonicalID, // - the canonical execution identity — {CanonicalID, ParentCanonicalID,
// ExecutionStateRoot, PayloadRoot}. This is the PRIMARY consensus object: the // ExecutionStateRoot, PayloadRoot}. This is the primary consensus object: the
// inner execution block commitment that finality, equivocation, ancestry, and // inner execution block commitment that finality, equivocation, ancestry and
// idempotency are ALL defined over. It is folded into the canonical signed // idempotency are all defined over. It is folded into the canonical signed
// message, so a signature is bound to the exact execution result. // message, so a signature is bound to the exact execution result.
// - the TRANSPORT/envelope identity — {BlockID, ParentID}, the outer proposervm // - the transport identity — {BlockID, ParentID}, the outer proposervm wrapper
// wrapper ids. These are a CACHE KEY for block lookup/gossip only. They are // ids. These are a cache key for block lookup and gossip only. They are left
// DELIBERATELY EXCLUDED from the signed message (CanonicalVoteMessage) and from // out of the signed message (CanonicalVoteMessage) and out of every finality
// every finality decision, so two different outer envelopes wrapping the SAME // decision, so two different outer envelopes wrapping the same inner block
// inner block sign the SAME message and are duplicates, never a fork. // sign the same message and are duplicates, never a fork.
// //
// Every CANONICAL axis (plus chain/height/round/set-root) is folded into the // Every canonical axis (plus chain/height/round/set-root) is folded into the
// signed message; a signature for one canonical position can never be replayed at // signed message; a signature for one canonical position can never be replayed at
// another. The transport ids are not signed — they are non-authoritative. // another. The transport ids are not signed — they are non-authoritative.
type VotePosition struct { type VotePosition struct {
@@ -172,18 +154,18 @@ type VotePosition struct {
Height uint64 Height uint64
Round uint32 Round uint32
// BlockID / ParentID are the OUTER proposervm envelope ids — the TRANSPORT // BlockID / ParentID are the outer proposervm envelope ids — the transport
// cache keys (block lookup, gossip, DAG tracking). NON-AUTHORITATIVE: excluded // cache keys (block lookup, gossip, DAG tracking). Non-authoritative: excluded
// from the signed message and from finality/equivocation. For a block that is // from the signed message and from finality and equivocation. For a block that
// not proposervm-wrapped at the engine boundary, BlockID == CanonicalID (the // is not proposervm-wrapped at the engine boundary, BlockID == CanonicalID and
// scheme degrades to outer==canonical and behaves exactly as before this split). // the scheme degrades to outer == canonical.
BlockID ids.ID BlockID ids.ID
ParentID ids.ID ParentID ids.ID
// CanonicalID is the inner EXECUTION block commitment — THE primary consensus // CanonicalID is the inner execution block commitment — the primary consensus
// object. Finality certifies THIS; the per-height equivocation index keys on // object. Finality certifies it; the per-height equivocation index keys on it;
// THIS; two certs at one height conflict iff THIS differs. It is bound into the // two certs at one height conflict iff it differs. It is bound into the signed
// signed message. For a non-wrapped block it equals BlockID. // message. For a non-wrapped block it equals BlockID.
CanonicalID ids.ID CanonicalID ids.ID
// ParentCanonicalID is the inner execution commitment of the parent — binds the // ParentCanonicalID is the inner execution commitment of the parent — binds the
// certified block into the canonical ancestry. Bound into the signed message. // certified block into the canonical ancestry. Bound into the signed message.
@@ -196,17 +178,17 @@ type VotePosition struct {
// PayloadRoot is the transaction/payload root (tx_root) the block commits to. // PayloadRoot is the transaction/payload root (tx_root) the block commits to.
// Bound into the signed message. ids.Empty when the VM does not expose one. // Bound into the signed message. ids.Empty when the VM does not expose one.
PayloadRoot ids.ID PayloadRoot ids.ID
// ValidatorSetRoot binds the cert to the EXACT weighted validator set the // ValidatorSetRoot binds the cert to the exact weighted validator set the
// vote was cast under (the MEDIUM fix). It is a commitment to the active // vote was cast under. It is a commitment to the active set and weights at
// set+weights at this position's height/epoch — mirroring quasar's // this position's height/epoch — the same axis quasar's
// ConsensusCert.ValidatorSetRoot, which already binds the same axis // ConsensusCert.ValidatorSetRoot binds (consensus_cert_legs.go). Because it is
// (consensus_cert_legs.go). Because it is folded into the canonical signed // folded into the canonical signed message, a cert assembled from votes cast
// message, a cert assembled from votes cast under set-root R cannot be // under set-root R cannot be re-presented as certifying under a different
// re-presented as certifying under a different set-root R': every signature // set-root R': every signature was over R, so reconstructing the message with
// was over R, so reconstructing the message with R' fails clause-6 verify. // R' fails clause-6 verify.
// //
// This turns the stake-weighted finality predicate ("⅔-by-stake at the // This turns the stake-weighted finality predicate ("⅔-by-stake at the
// cert-position epoch") from an assumption into an ENFORCED invariant: a // cert-position epoch") from an assumption into an enforced invariant: a
// cross-epoch stake change cannot retroactively flip an already-correct cert // cross-epoch stake change cannot retroactively flip an already-correct cert
// (the cert's identity is pinned to R, not to "current" stake), and a cert // (the cert's identity is pinned to R, not to "current" stake), and a cert
// gathered under one epoch's set cannot be laundered into another epoch. // gathered under one epoch's set cannot be laundered into another epoch.
@@ -234,15 +216,14 @@ func CanonicalVoteMessage(pos VotePosition) []byte {
// against (pos,true) and reject votes against (pos,false); the cert only ever // against (pos,true) and reject votes against (pos,false); the cert only ever
// uses (pos,true). // uses (pos,true).
// //
// THE MESSAGE BINDS THE CANONICAL EXECUTION IDENTITY, NOT THE OUTER ENVELOPE // The message binds the canonical execution identity, not the outer envelope.
// (the incident-1082814 fix). The outer proposervm ids (pos.BlockID/ParentID) // The outer proposervm ids (pos.BlockID/ParentID) are absent from this message —
// are NOT in this message — they are transport cache keys. The signed identity is // they are transport cache keys. The signed identity is the inner execution
// the inner execution commitment {canonical_id, parent_canonical_id, // commitment {canonical_id, parent_canonical_id, execution_state_root,
// execution_state_root, payload_root}. Consequence: two validators that executed // payload_root}. Consequence: two validators that executed the same inner block
// the SAME inner block sign byte-identical messages even if they received // sign byte-identical messages even if they received different outer envelopes,
// different outer envelopes, so their votes interoperate and a cert assembled // so their votes interoperate and a cert assembled from them verifies on every
// from them verifies on every node. A locally-derived wrapper id can NEVER reach // node. A locally-derived wrapper id can never reach a signature.
// a signature.
// //
// Layout (big-endian, fixed-width, length-free because every field is fixed): // Layout (big-endian, fixed-width, length-free because every field is fixed):
// //
@@ -256,11 +237,11 @@ func CanonicalVoteMessage(pos VotePosition) []byte {
// validator_set_root:32 (epoch/weighted-set commitment; Empty = unbound) // validator_set_root:32 (epoch/weighted-set commitment; Empty = unbound)
// accept:1 (0x01 accept | 0x00 reject) // accept:1 (0x01 accept | 0x00 reject)
// //
// validator_set_root is bound BEFORE the accept byte so a vote is committed to // validator_set_root is bound before the accept byte so a vote is committed to
// the exact weighted validator set it was cast under (the MEDIUM fix): a cert // the exact weighted validator set it was cast under: a cert gathered under
// gathered under set-root R cannot be re-verified as certifying under a // set-root R cannot be re-verified as certifying under a different set R'. The
// different set R'. The domain tag is bumped v1→v2 so a canonical-commitment // domain tag carries its own version so a canonical-commitment signature can
// signature can never be confused with a legacy outer-id signature. // never be confused with an outer-id signature.
func canonicalVoteMessageFor(pos VotePosition, accept bool) []byte { func canonicalVoteMessageFor(pos VotePosition, accept bool) []byte {
const tag = "LUX/chain/vote/v2\x00" const tag = "LUX/chain/vote/v2\x00"
buf := make([]byte, 0, len(tag)+2+1+32+8+4+32+32+32+32+32+1) buf := make([]byte, 0, len(tag)+2+1+32+8+4+32+32+32+32+32+1)
@@ -276,14 +257,14 @@ func canonicalVoteMessageFor(pos VotePosition, accept bool) []byte {
var u32 [4]byte var u32 [4]byte
binary.BigEndian.PutUint32(u32[:], pos.Round) binary.BigEndian.PutUint32(u32[:], pos.Round)
buf = append(buf, u32[:]...) buf = append(buf, u32[:]...)
// CANONICAL execution identity — the signed primary object. Outer ids omitted. // Canonical execution identity — the signed primary object. Outer ids omitted.
// FALLBACK (the non-wrapped degrade, in ONE place): a position whose canonical // The non-wrapped degrade lives here and only here: a position whose canonical
// fields are unset (a bare/in-process VM block with no inner/outer split, or a // fields are unset (a bare in-process VM block with no inner/outer split, or a
// fixed-set chain) binds its OUTER id under the canonical slot — byte-identical to // fixed-set chain) binds its outer id under the canonical slot. A
// the pre-canonical message for that block. A proposervm-wrapped block carries a // proposervm-wrapped block carries a distinct CanonicalID and binds that.
// distinct CanonicalID and binds THAT. Resolving the fallback here (not at the // Resolving the degrade here rather than at the position-build sites is what
// position-build sites) guarantees every producer of a position — engine or test — // guarantees every producer of a position — engine or test — signs and verifies
// signs/verifies the SAME bytes for the same block. // the same bytes for the same block.
canonicalID := pos.CanonicalID canonicalID := pos.CanonicalID
if canonicalID == ids.Empty { if canonicalID == ids.Empty {
canonicalID = pos.BlockID canonicalID = pos.BlockID
@@ -311,21 +292,21 @@ func canonicalVoteMessageFor(pos VotePosition, accept bool) []byte {
// quasar, or secp256k1). The engine defines the QUORUM RULE; the verifier // quasar, or secp256k1). The engine defines the QUORUM RULE; the verifier
// supplies the SIGNATURE CHECK. Decomplected. // supplies the SIGNATURE CHECK. Decomplected.
// //
// VerifyVote MUST be deterministic and side-effect free, return true iff sig is // VerifyVote is deterministic and side-effect free, returns true iff sig is a
// a valid signature by nodeID over message, and NEVER panic on adversarial // valid signature by nodeID over message, and does not panic on adversarial input
// input (return false). Implementations that consult a validator set MUST treat // (it returns false). An implementation that consults a validator set treats an
// an unknown nodeID as "false", not an error — a cert with an out-of-set voter // unknown nodeID as "false", not an error — a cert with an out-of-set voter is
// is simply invalid. // simply invalid.
// //
// epochHeight is the P-CHAIN height the block's weighted validator set is pinned // epochHeight is the P-chain height the block's weighted validator set is pinned
// to (MEDIUM-1 / RESIDUAL-B). An implementation that resolves the voter's public // to. An implementation that resolves the voter's public key from a validator set
// key from a validator set MUST resolve it from the set IN FORCE AT epochHeight — // resolves it from the set in force at epochHeight — the same height the set-root
// the SAME height the set-root and the ⅔-by-stake tally are read at — NOT from // and the ⅔-by-stake tally are read at — never from the current validator map.
// the current validator map. Resolving from the current map drops the legitimate // Resolving from the current map drops the legitimate vote of a validator that has
// vote of a validator that has since left the current set but was a member at // since left the current set but was a member at epochHeight (the async-skew window
// epochHeight (an async-skew window during a staking change), stalling finality // during a staking change), stalling finality for that block. The four reads —
// for that block. The four reads — membership, pubkey, set-root, stake — all key // membership, pubkey, set-root, stake — all key off epochHeight, so a cert is
// off epochHeight so a cert is internally consistent at exactly one epoch. // internally consistent at exactly one epoch.
type VoteVerifier interface { type VoteVerifier interface {
VerifyVote(nodeID ids.NodeID, message []byte, sig []byte, epochHeight uint64) bool VerifyVote(nodeID ids.NodeID, message []byte, sig []byte, epochHeight uint64) bool
} }
@@ -339,42 +320,42 @@ func (f VoteVerifierFunc) VerifyVote(nodeID ids.NodeID, message []byte, sig []by
} }
// StakeSource supplies validator voting weights so finality can be checked as a // StakeSource supplies validator voting weights so finality can be checked as a
// STAKE-WEIGHTED supermajority rather than a raw voter COUNT. This is the HIGH-3 // stake-weighted supermajority rather than a raw voter count. On a PoS chain with
// fix: on a PoS chain with unequal stake, "α distinct voters" is NOT the same as // unequal stake the two are not the same predicate: a coalition of many low-stake
// "≥⅔ of stake" — a coalition of many low-stake validators could reach the count // validators can reach "α distinct voters" while controlling a minority of stake.
// while controlling a minority of stake. When a value/PoS chain wires a // When a value/PoS chain supplies a StakeSource, a cert finalizes only if both
// StakeSource, a cert finalizes only if BOTH hold: (count ≥ α) AND (Σ voter // hold: (count ≥ α) and (Σ voter stake ≥ ⅔ Σ total stake).
// stake ≥ ⅔ Σ total stake).
// //
// Determinism + fail-closed: Weight MUST be deterministic for a given // Determinism and fail-closed: Weight is deterministic for a given (nodeID, epoch)
// (nodeID, epoch) and return 0 for an unknown/out-of-set voter (an out-of-set // and returns 0 for an unknown or out-of-set voter, so such a voter contributes no
// voter contributes no stake — it cannot inflate the numerator). TotalStake is // stake and cannot inflate the numerator. TotalStake is the epoch's total active
// the epoch's total active stake; if it is 0 the source is unusable and the // stake; if it is 0 the source is unusable and the caller treats the cert as
// caller treats the cert as unverifiable (fail closed). The engine binds the // unverifiable. The engine binds the source to the cert's position height so
// source to the cert's position height so weights are read at the right epoch. // weights are read at the right epoch.
type StakeSource interface { type StakeSource interface {
// Weight returns the voting weight (stake) of nodeID at the given height, or // Weight returns the voting weight (stake) of nodeID at the given height, or
// 0 if nodeID is not an active validator at that height. // 0 if nodeID is not an active validator at that height.
Weight(nodeID ids.NodeID, height uint64) uint64 Weight(nodeID ids.NodeID, height uint64) uint64
// TotalStake returns the total active validator stake at the given height. // TotalStake returns the total active validator stake at the given height.
TotalStake(height uint64) uint64 TotalStake(height uint64) uint64
// ValidatorCount returns the number of DISTINCT active validators at the given // ValidatorCount returns the number of distinct active validators at the given
// height — the round-scoped view-change's BFT committee size n (its POL/precommit // height — the round-scoped view-change's BFT committee size n. Its POL and
// count distinct validators, so n must be the LIVE set, not the sample K). // precommit both count distinct validators, so n is the live set, not the sample
// Read from the SAME height-indexed set as Weight/TotalStake so the count-quorum and // K. It is read from the same height-indexed set as Weight and TotalStake, so the
// the stake-quorum are the identical set. 0 for an unresolved/empty set (view-change // count-quorum and the stake-quorum are over one identical set. 0 for an
// then keeps the configured committee, guarded by the 2α−n>f bound). // unresolved or empty set; the view-change then keeps the configured committee,
// guarded by the 2α−n>f bound.
ValidatorCount(height uint64) int ValidatorCount(height uint64) int
} }
// ValidatorSetRootSource computes the commitment to the active weighted // ValidatorSetRootSource computes the commitment to the active weighted
// validator set at a given height — the value bound into a VotePosition's // validator set at a given height — the value bound into a VotePosition's
// ValidatorSetRoot. The node supplies it from the chain's validator set; the // ValidatorSetRoot. The node supplies it from the chain's validator set; the
// engine stamps it into every position it signs/assembles so a cert is // engine stamps it into every position it signs or assembles, so a cert is
// cryptographically pinned to the exact set+weights it was certified under // cryptographically pinned to the exact set and weights it was certified under.
// (the MEDIUM fix). It MUST be deterministic for a given height across all // It is deterministic for a given height across all honest nodes: every node
// honest nodes (every node computing the root for height H must agree, or their // computing the root for height H has to agree, or their signatures over the same
// signatures over the same block would not be mutually verifiable). // block would not be mutually verifiable.
// //
// Returning ids.Empty is the explicit "no epoch bound" answer — a chain that // Returning ids.Empty is the explicit "no epoch bound" answer — a chain that
// does not commit to a set-root signs and verifies with Empty consistently // does not commit to a set-root signs and verifies with Empty consistently
@@ -391,28 +372,29 @@ type ValidatorSetRootFunc func(height uint64) ids.ID
// ValidatorSetRoot implements ValidatorSetRootSource. // ValidatorSetRoot implements ValidatorSetRootSource.
func (f ValidatorSetRootFunc) ValidatorSetRoot(height uint64) ids.ID { return f(height) } func (f ValidatorSetRootFunc) ValidatorSetRoot(height uint64) ids.ID { return f(height) }
// VerifyWeighted is the TIER-SELECTED finality predicate. It first runs Verify (the // VerifyWeighted is the tier-selected finality predicate. It first runs Verify (the
// tier-agnostic structural + signature predicate) and then enforces the threshold for // tier-agnostic structural and signature predicate) and then enforces the threshold for
// the cert's OWN tier: // the cert's own tier:
// //
// - Nova → a strict MAJORITY of TOTAL STAKE by distinct signers (config.HalfStakeFloor) // - Nova → a strict majority of total stake by distinct signers (config.HalfStakeFloor)
// plus a NovaSignerFloor count, and DELIBERATELY not a supermajority. This is the // plus a NovaSignerFloor count, and deliberately not a supermajority. This is the
// LOCAL-EXECUTION gate: it must ignite at a bare majority (3 of 5) — the "survive 3/5" // local-execution rung: it has to ignite at a bare majority, so a ⅔ threshold here
// liveness mandate — so the ⅔ gate here would defeat its whole purpose. // would defeat its purpose. Crash-fault-safe by majority intersection, and not
// Crash-fault-safe by majority intersection; NOT Byzantine-safe (that is Quasar's job). // Byzantine-safe — that is Quasar's job.
// - Quasar → a strict >⅔ of TOTAL STAKE by distinct signers (config.TwoThirdsStakeFloor). // - Quasar → a strict >⅔ of total stake by distinct signers (config.TwoThirdsStakeFloor).
// This is the EXPORT gate — the Byzantine-safe finality bridges / DEX settlement / // This is the export rung, the Byzantine-safe finality bridges, DEX settlement,
// cross-chain messages / validator-set transitions consume. // cross-chain messages and validator-set transitions consume.
// //
// The tier is read from the cert, but the THRESHOLD is RE-DERIVED from the authoritative // The tier is read from the cert, but the threshold is re-derived from the authoritative
// validator set (never the cert's self-declared Threshold), so a cert cannot forge its // validator set rather than taken from the cert's self-declared Threshold, so a cert
// tier upward: a Nova set of votes relabeled Quasar fails the ⅔-by-stake check, and a // cannot forge its tier upward: a Nova set of votes relabeled Quasar fails the ⅔-by-stake
// Quasar cert relabeled Nova merely under-claims. An unknown tier fails closed. // check, and a Quasar cert relabeled Nova merely under-claims. An unknown tier fails
// closed.
// //
// A nil stake source means "no stake model wired" — the caller MUST instead use Verify // A nil stake source means no stake model is supplied — the caller uses Verify instead
// and is responsible for the equal-stake admission invariant (documented on the engine). // and is responsible for the equal-stake admission invariant documented on the engine.
// VerifyWeighted with a nil source fails closed to make a wiring mistake loud rather than // VerifyWeighted with a nil source fails closed, so a mis-connected caller is loud rather
// silently count-only. // than silently count-only.
func (c *QuorumCert) VerifyWeighted(verifier VoteVerifier, stake StakeSource, epochHeight uint64) error { func (c *QuorumCert) VerifyWeighted(verifier VoteVerifier, stake StakeSource, epochHeight uint64) error {
if err := c.Verify(verifier, epochHeight); err != nil { if err := c.Verify(verifier, epochHeight); err != nil {
return err return err
@@ -430,28 +412,28 @@ func (c *QuorumCert) VerifyWeighted(verifier VoteVerifier, stake StakeSource, ep
} }
} }
// verifyNovaMajority enforces the Nova LOCAL-EXECUTION threshold: the cert's distinct // verifyNovaMajority enforces the Nova local-execution threshold: the cert's distinct
// voters hold a strict MAJORITY OF STAKE at the epoch (config.HalfStakeFloor), and there // voters hold a strict majority of stake at the epoch (config.HalfStakeFloor), and there
// are at least NovaSignerFloor(n) of them. Both are recomputed from the authoritative live // are at least NovaSignerFloor(n) of them. Both are recomputed from the authoritative
// set, so a cert can never self-declare a below-majority Nova threshold; an UNRESOLVED set // live set, so a cert can never self-declare a below-majority Nova threshold. An
// (n<1, or zero total stake) fails closed — a majority of an unknown set cannot be // unresolved set (n<1, or zero total stake) fails closed: a majority of an unknown set
// asserted, and NovaQuorum(0)=1 would otherwise let a lone node self-accept during the // cannot be asserted, and NovaQuorum(0)=1 would otherwise let a lone node self-accept
// transient-empty validator view that forked 1085013. // while its view of the validator set is transiently empty.
// //
// STAKE, NOT HEAD-COUNT. Nova is still a bare majority and still deliberately below the // Stake, not head-count. Nova is still a bare majority and still deliberately below the
// Quasar ⅔ export gate — it is only read in the unit the chain actually weighs votes in. // Quasar ⅔ export threshold — it is only read in the unit the chain actually weighs votes
// On an equal-stake set the two readings are identical (⌊n/2⌋+1 signers either way), so a // in. On an equal-stake set the two readings are identical (⌊n/2⌋+1 signers either way),
// uniform fleet sees no change at all. They diverge when weights do, and a head-count is // so a uniform fleet sees no difference. They diverge when weights do, and a head-count is
// wrong in both directions there. It lets a registration at the minimum stake RAISE the // wrong in both directions there. It lets a registration at the minimum stake raise the
// gate: one more entry takes ⌊n/2⌋+1 from 3 to 4, so a set of five validators plus a // bar: one more entry takes ⌊n/2⌋+1 from 3 to 4, so a set of five validators plus a
// minimum-stake sixth needs four of the five to agree and tolerates one loss instead of // minimum-stake sixth needs four of the five to agree and tolerates one loss instead of
// two. And it lets that same minimum stake CAST a vote toward the majority that decides // two. And it lets that same minimum stake cast a vote toward the majority that decides
// what the chain is. Registration is open at minValidatorStake, so both are purchasable. // what the chain is. Registration is open at minValidatorStake, so both are purchasable.
// A majority of stake is neither. // A majority of stake is neither.
// //
// The signer floor is the guard the stake predicate cannot give: a single validator holding // The signer floor is the guard the stake predicate cannot give: a single validator
// a stake majority would otherwise self-ignite (the 1085013 class). Stake majority AND the // holding a stake majority would otherwise self-ignite. Stake majority and the floor are
// floor — two independent predicates, neither sufficient alone. // two independent predicates, neither sufficient alone.
func (c *QuorumCert) verifyNovaMajority(stake StakeSource, epochHeight uint64) error { func (c *QuorumCert) verifyNovaMajority(stake StakeSource, epochHeight uint64) error {
n := stake.ValidatorCount(epochHeight) n := stake.ValidatorCount(epochHeight)
if n < 1 { if n < 1 {
@@ -482,12 +464,12 @@ func (c *QuorumCert) verifyNovaMajority(stake StakeSource, epochHeight uint64) e
// verifyQuasarSupermajority enforces the Quasar EXPORT threshold: the summed stake of the // verifyQuasarSupermajority enforces the Quasar EXPORT threshold: the summed stake of the
// cert's distinct voters STRICTLY exceeds two-thirds of the total stake at the epoch. // cert's distinct voters STRICTLY exceeds two-thirds of the total stake at the epoch.
func (c *QuorumCert) verifyQuasarSupermajority(stake StakeSource, epochHeight uint64) error { func (c *QuorumCert) verifyQuasarSupermajority(stake StakeSource, epochHeight uint64) error {
// The ⅔-by-stake tally is read at the block's P-CHAIN EPOCH height — the SAME // The ⅔-by-stake tally is read at the block's P-chain epoch height — the same
// height the cert's set-root and the per-voter pubkeys are read at (MEDIUM-1) // height the cert's set-root and the per-voter pubkeys are read at — rather than
// — NOT c.Position.Height (the value-chain height, which platformvm would // at c.Position.Height, the value-chain height, which platformvm would reject as
// reject as unfinalized and which races ahead of the P-chain epoch). Reading // unfinalized and which races ahead of the P-chain epoch. Reading the tally at
// the tally at the same epoch the signatures were cast under guarantees a // the same epoch the signatures were cast under is what guarantees a validator
// validator whose vote is in the cert also contributes its epoch weight. // whose vote is in the cert also contributes its epoch weight.
total := stake.TotalStake(epochHeight) total := stake.TotalStake(epochHeight)
if total == 0 { if total == 0 {
// No known stake at this epoch — cannot assert a supermajority. Fail closed. // No known stake at this epoch — cannot assert a supermajority. Fail closed.
@@ -497,11 +479,11 @@ func (c *QuorumCert) verifyQuasarSupermajority(stake StakeSource, epochHeight ui
for i := range c.Votes { for i := range c.Votes {
voted += stake.Weight(c.Votes[i].NodeID, epochHeight) voted += stake.Weight(c.Votes[i].NodeID, epochHeight)
} }
// STRICT supermajority by stake: accept iff voted > floor(2·total/3) // Strict supermajority by stake: accept iff voted > floor(2·total/3). The floor
// (Tendermint +⅔). The floor is the SINGLE definition in config.TwoThirdsStakeFloor // has one definition, config.TwoThirdsStakeFloor — the same function the live-set
// — the SAME function the live-set parameter sizer derives α from // parameter sizer derives α from (config.WeightedSupermajorityThreshold), so the
// (config.WeightedSupermajorityThreshold), so the count threshold the node // count threshold the node sizes to can never drift from the stake predicate
// sizes to can never drift from the stake predicate enforced here. // enforced here.
twoThirdsFloor := config.TwoThirdsStakeFloor(total) twoThirdsFloor := config.TwoThirdsStakeFloor(total)
if voted <= twoThirdsFloor { if voted <= twoThirdsFloor {
return fmt.Errorf("%w: voted=%d total=%d (need > floor(2/3·total)=%d) at height %d", return fmt.Errorf("%w: voted=%d total=%d (need > floor(2/3·total)=%d) at height %d",
@@ -510,9 +492,9 @@ func (c *QuorumCert) verifyQuasarSupermajority(stake StakeSource, epochHeight ui
return nil return nil
} }
// AuthorizesExport reports whether THIS cert is export-grade finality (Quasar or // AuthorizesExport reports whether this cert is export-grade finality (Quasar or
// brighter) — the safety boundary a bridge / DEX-settlement / cross-chain consumer MUST // brighter) — the safety boundary a bridge, DEX settlement or cross-chain consumer
// gate on. A Nova cert (local execution) returns false even though it is a valid, // admits on. A Nova cert (local execution) returns false even though it is a valid,
// signature-verified majority cert. This is the cert-level projection of // signature-verified majority cert. This is the cert-level projection of
// Finality.AuthorizesExport, so a raw cert in hand can be gated without re-deriving its // Finality.AuthorizesExport, so a raw cert in hand can be gated without re-deriving its
// tier from context. A nil cert is not export-grade. // tier from context. A nil cert is not export-grade.
@@ -524,7 +506,7 @@ func (c *QuorumCert) AuthorizesExport() bool {
// signed ACCEPT over Position. It is portable (gossipable), verifiable by any // signed ACCEPT over Position. It is portable (gossipable), verifiable by any
// node holding the VoteVerifier, and deterministic to assemble. // node holding the VoteVerifier, and deterministic to assemble.
// //
// It is NOT a signature — there is no aggregate field, because nothing is // It is not a signature — there is no aggregate field, because nothing is
// aggregated. The cert carries the per-voter signed records; verification is // aggregated. The cert carries the per-voter signed records; verification is
// the predicate in Verify. // the predicate in Verify.
type QuorumCert struct { type QuorumCert struct {
@@ -533,17 +515,16 @@ type QuorumCert struct {
// Type names the cert's role (QCFinality). Bound into every vote message. // Type names the cert's role (QCFinality). Bound into every vote message.
Type QCType Type QCType
// Tier is the finality rung this cert attests — Nova (a bare-majority // Tier is the finality rung this cert attests — Nova (a bare-majority
// LOCAL-EXECUTION cert) or Quasar (a strict ⅔-by-stake EXPORT cert). It selects // local-execution cert) or Quasar (a strict ⅔-by-stake export cert). It selects
// which threshold VerifyWeighted enforces, so ONE cert type carries both accept // which threshold VerifyWeighted enforces, so one cert type carries both accept
// (Nova) and export (Quasar) finality with no ambiguity about what a given cert // (Nova) and export (Quasar) finality with no ambiguity about what a given cert
// proves. It is DELIBERATELY not bound into the per-vote signed message: a vote is // proves. It is deliberately not bound into the per-vote signed message: a vote is
// tier-agnostic ("I accept this block"), and the SAME accept vote counts toward // tier-agnostic ("I accept this block"), and one accept vote counts toward both a
// both a Nova majority and a Quasar supermajority. The tier therefore cannot be // Nova majority and a Quasar supermajority. The tier therefore cannot be forged
// forged UPWARD — VerifyWeighted re-derives the real threshold from the // upward — VerifyWeighted re-derives the real threshold from the authoritative
// authoritative validator set, so relabeling a Nova cert as Quasar is rejected // validator set, so relabeling a Nova cert as Quasar is rejected (it will not clear
// (it will not clear ⅔-by-stake) and relabeling a Quasar cert as Nova only // ⅔-by-stake) and relabeling a Quasar cert as Nova only under-claims. Only Nova and
// under-claims (harmless). Only Nova and Quasar are valid here; Horizon is the PQ // Quasar are valid here; Horizon is the PQ seal layer, not a QuorumCert.
// seal layer, not a QuorumCert.
Tier Finality Tier Finality
// Position is the consensus position every vote binds to. // Position is the consensus position every vote binds to.
Position VotePosition Position VotePosition
@@ -556,13 +537,13 @@ type QuorumCert struct {
} }
// AssembleQuorumCert builds a finality cert from collected signed ACCEPT votes. // AssembleQuorumCert builds a finality cert from collected signed ACCEPT votes.
// Permissionless and deterministic: NO secrets, NO randomness. Given the same // Permissionless and deterministic: no secrets, no randomness. Given the same
// votes and position the same cert comes out. // votes and position the same cert comes out.
// //
// It sorts votes strictly-increasing by NodeID (dropping duplicate NodeIDs — // It sorts votes strictly-increasing by NodeID (dropping duplicate NodeIDs —
// last-writer-wins is not used; a duplicate NodeID is an error so a cert can // last-writer-wins is not used; a duplicate NodeID is an error so a cert can
// never double-count), and rejects a structurally impossible cert (no votes, // never double-count), and rejects a structurally impossible cert (no votes,
// zero threshold, a non-accept vote). It does NOT verify signatures — // zero threshold, a non-accept vote). It does not verify signatures —
// assembly is orthogonal to verification (a relaying node assembles; verifiers // assembly is orthogonal to verification (a relaying node assembles; verifiers
// verify). A subsequent Verify is the gate. // verify). A subsequent Verify is the gate.
// //
@@ -620,8 +601,8 @@ func AssembleQuorumCert(pos VotePosition, tier Finality, threshold uint32, votes
} }
// Verify checks the cert against verifier. Returns nil iff every predicate // Verify checks the cert against verifier. Returns nil iff every predicate
// clause holds; otherwise a typed error naming the FIRST failure. NEVER panics // clause holds; otherwise a typed error naming the first failure. It does not
// and NEVER does unbounded work — an adversarial cert (duplicate voter, bad // panic and does no unbounded work — an adversarial cert (duplicate voter, bad
// signature, sub-threshold, wrong position) yields a clean error. // signature, sub-threshold, wrong position) yields a clean error.
// //
// Predicate (the engine-level projection of quasar's weighted-quorum predicate): // Predicate (the engine-level projection of quasar's weighted-quorum predicate):
@@ -631,7 +612,7 @@ func AssembleQuorumCert(pos VotePosition, tier Finality, threshold uint32, votes
// (2) threshold (alpha) > 0 // (2) threshold (alpha) > 0
// (3) at least one vote // (3) at least one vote
// for each vote, in order: // for each vote, in order:
// (4) node ids are STRICTLY INCREASING (distinct, anti-double-count) // (4) node ids are strictly increasing (distinct, anti-double-count)
// (5) vote is ACCEPT // (5) vote is ACCEPT
// (6) signature verifies under verifier over CanonicalVoteMessage(Position) // (6) signature verifies under verifier over CanonicalVoteMessage(Position)
// then // then
@@ -12,6 +12,7 @@ package chain
import ( import (
"bytes" "bytes"
"context" "context"
"errors"
"testing" "testing"
"github.com/luxfi/ids" "github.com/luxfi/ids"
@@ -245,12 +246,13 @@ func TestInversion_DuplicateVoterRejected(t *testing.T) {
raw := &QuorumCert{ raw := &QuorumCert{
Version: QuorumCertVersion, Version: QuorumCertVersion,
Type: QCFinality, Type: QCFinality,
Tier: Quasar,
Position: pos, Position: pos,
Threshold: 2, Threshold: 2,
Votes: []SignedVote{{NodeID: v.NodeID, Accept: true, Signature: v.Signature}, {NodeID: v.NodeID, Accept: true, Signature: v.Signature}}, Votes: []SignedVote{{NodeID: v.NodeID, Accept: true, Signature: v.Signature}, {NodeID: v.NodeID, Accept: true, Signature: v.Signature}},
} }
if err := raw.Verify(vs, 1); err == nil { if err := raw.Verify(vs, 1); !errors.Is(err, ErrQCNotStrictlyIncreasing) {
t.Fatal("a cert with non-increasing (duplicate) voters must fail verification") t.Fatalf("a cert with non-increasing (duplicate) voters must fail the strictly-increasing clause, got: %v", err)
} }
} }
+4 -3
View File
@@ -4,6 +4,7 @@
package chain package chain
import ( import (
"errors"
"testing" "testing"
"github.com/luxfi/ids" "github.com/luxfi/ids"
@@ -108,14 +109,14 @@ func TestQuorumCert_DuplicateVoterRejected(t *testing.T) {
// Hand-craft a cert whose votes are NOT strictly increasing → Verify fails. // Hand-craft a cert whose votes are NOT strictly increasing → Verify fails.
bad := &QuorumCert{ bad := &QuorumCert{
Version: QuorumCertVersion, Type: QCFinality, Position: pos, Threshold: 1, Version: QuorumCertVersion, Type: QCFinality, Tier: Quasar, Position: pos, Threshold: 1,
Votes: []SignedVote{ Votes: []SignedVote{
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)}, {NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)}, // dup {NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)}, // dup
}, },
} }
if err := bad.Verify(vs, bad.Position.Height); err == nil { if err := bad.Verify(vs, bad.Position.Height); !errors.Is(err, ErrQCNotStrictlyIncreasing) {
t.Fatal("Verify must reject non-strictly-increasing voters") t.Fatalf("Verify must reject non-strictly-increasing voters on the strictly-increasing clause, got: %v", err)
} }
} }
+54 -67
View File
@@ -1,38 +1,32 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved. // Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms. // See the file LICENSE for licensing terms.
// certified_descendant_head_test.go — the head = certified+1 false-positive halt. // certified_descendant_head_test.go — a certified VM head that already contains the steer
// target is benign, not a double-finalization.
// //
// THE INCIDENT (devnet v1.36.33, all five validators). Each node hit, exactly once: // A node whose accepted head is certified at height h+1 while the engine steers it at the
// certified block at height h is on one chain, holding both blocks. Nothing would be
// orphaned. Two separate properties have to hold for the engine to see that, one on each
// side of the steer:
// //
// error VM accepted head is CONSENSUS-CERTIFIED and conflicts with the newly finalized // - The producer. acceptWithCertCore releases t.mu across every VM call-out, so a second
// block — refusing to orphan it orphanedHeight=1364 // finalize can complete inside that window and advance both the ledger and the VM to
// fatal SetPreference would orphan a CONSENSUS-CERTIFIED block — refusing (fail-closed) // blockID+1. Steering afterwards at the stale local blockID is a backwards steer, which
// error="cannot orphan finalized block at height: 1364 to common block at height: 1363" // the VM's accepted-irreversibility guard (evm/core/blockchain.go: commonBlock <
// lastAccepted) correctly refuses. The steer belongs at the live build anchor, which is
// never below the VM's accepted head — and, when this node does not hold that anchor, at
// the block VM.Accept has just applied.
// //
// at three distinct height pairs (1258/1259, 1363/1364, 1364/1365), ALWAYS with // - The classifier. "The head is the ledger's certified canonical at its own height" is
// certified = head − 1, and with every surviving node holding byte-identical blocks at // trivially true for every healthy node whose head is certified, so on its own it says
// every one of those heights: no fork anywhere. os.Exit(1) on a benign state. // nothing about a second finalization. A certified head may only be orphaned once the
// steer target is established as not itself the ledger's certified canonical at its own
// height — that is, once the two blocks are known to be certified on different chains.
// //
// TWO defects, one crash: // The safety property is unchanged and still fail-closed: a certified head is dropped only
//
// 1. PRODUCER. acceptWithCertCore releases t.mu across every VM call-out, then steers the
// VM with its STALE local blockID. A finalize that completed in that window has already
// advanced the ledger AND the EVM to blockID+1, so the steer is BACKWARDS and the EVM's
// accepted-irreversibility guard (evm/core/blockchain.go: commonBlock < lastAccepted)
// correctly refuses it. Fix: steer at the LIVE build anchor (PreferredBuildTip), which
// is never below the VM's accepted head.
//
// 2. CLASSIFIER. reconcileVMToCertified asked only "is the head the ledger's certified
// canonical at ITS OWN height?" — trivially TRUE for every healthy node whose head is
// certified — and reported a double-finalization. It never established that `certified`
// was at that same height. Fix: a certified head is only orphaned when `certified` is
// NOT itself the ledger's certified canonical at its own height.
//
// The safety property is UNCHANGED and still fail-closed: a certified head is dropped only
// for a block our own ledger certifies on the same one chain. See // for a block our own ledger certifies on the same one chain. See
// TestReconcile_CertifiedHead_HaltsFailClosed (orphan_reconcile_test.go), which now proves // TestReconcile_CertifiedHead_HaltsFailClosed (orphan_reconcile_test.go), which proves the
// the halt with BOTH blocks readable — the ledger, not an unreadable id, decides. // halt with both blocks readable — the ledger, not an unreadable id, decides.
package chain package chain
import ( import (
@@ -42,7 +36,7 @@ import (
"github.com/luxfi/ids" "github.com/luxfi/ids"
) )
// twoHeightLedger builds a certified ledger holding a CONTIGUOUS two-height chain: // twoHeightLedger builds a certified ledger holding a contiguous two-height chain:
// byHeight[h] = lower and byHeight[h+1] = upper, tip = upper. This is exactly the state a // byHeight[h] = lower and byHeight[h+1] = upper, tip = upper. This is exactly the state a
// node is in after a second finalize completes while an earlier one is still between its // node is in after a second finalize completes while an earlier one is still between its
// VM call-outs. // VM call-outs.
@@ -53,13 +47,10 @@ func twoHeightLedger(lower ids.ID, h uint64, upper ids.ID) FinalityLedger {
return led return led
} }
// TestReconcile_CertifiedDescendantHead_NoHalt is the incident, reduced. The VM's accepted // TestReconcile_CertifiedDescendantHead_NoHalt: the VM's accepted head is the certified
// head is the CERTIFIED block at 1364; the steer target is the CERTIFIED block at 1363 — // block at h+1 and the steer target is the certified block at h — its own parent on the one
// its own parent on the one certified chain. SetPreference refuses (backwards steer), but // certified chain. SetPreference refuses the backwards steer, but nothing would be orphaned:
// nothing would be orphaned: the VM already CONTAINS 1363. The engine must not halt, and // the VM already contains h. The engine must neither halt nor touch the VM head.
// must not touch the VM head.
//
// Pre-fix this returned false ⇒ os.Exit(1) ⇒ five dead validators.
func TestReconcile_CertifiedDescendantHead_NoHalt(t *testing.T) { func TestReconcile_CertifiedDescendantHead_NoHalt(t *testing.T) {
e := newTestEngine() e := newTestEngine()
@@ -76,12 +67,12 @@ func TestReconcile_CertifiedDescendantHead_NoHalt(t *testing.T) {
}} }}
if !e.reconcileVMToCertified(context.Background(), vm, certified, errOrphan) { if !e.reconcileVMToCertified(context.Background(), vm, certified, errOrphan) {
t.Fatal("a VM head that is the CERTIFIED DESCENDANT of the finalized block is benign " + t.Fatal("a VM head that is the certified descendant of the finalized block is benign " +
"(head = certified+1 on the one certified chain, nothing to orphan) — the engine must " + "(head = certified+1 on the one certified chain, nothing to orphan) — the engine may " +
"NOT halt fail-closed") "not halt fail-closed here")
} }
if vm.reconcileCalls != 0 { if vm.reconcileCalls != 0 {
t.Fatalf("the certified head must be LEFT ALONE (it already contains the certified block), "+ t.Fatalf("the certified head must be left alone; it already contains the certified block, "+
"got %d ReconcilePreference calls", vm.reconcileCalls) "got %d ReconcilePreference calls", vm.reconcileCalls)
} }
if vm.head != head { if vm.head != head {
@@ -89,7 +80,7 @@ func TestReconcile_CertifiedDescendantHead_NoHalt(t *testing.T) {
} }
} }
// TestReconcile_CertifiedAncestorHead_NoHalt is the mirror: the head is certified BELOW the // TestReconcile_CertifiedAncestorHead_NoHalt is the mirror: the head is certified below the
// steer target, both on the one certified chain. Steering forward orphans nothing. // steer target, both on the one certified chain. Steering forward orphans nothing.
func TestReconcile_CertifiedAncestorHead_NoHalt(t *testing.T) { func TestReconcile_CertifiedAncestorHead_NoHalt(t *testing.T) {
e := newTestEngine() e := newTestEngine()
@@ -107,20 +98,18 @@ func TestReconcile_CertifiedAncestorHead_NoHalt(t *testing.T) {
}} }}
if !e.reconcileVMToCertified(context.Background(), vm, certified, errOrphan) { if !e.reconcileVMToCertified(context.Background(), vm, certified, errOrphan) {
t.Fatal("a certified head BELOW the steer target (both on the one certified chain) is benign — must not halt") t.Fatal("a certified head below the steer target (both on the one certified chain) is benign — must not halt")
} }
if vm.reconcileCalls != 0 { if vm.reconcileCalls != 0 {
t.Fatalf("nothing to reconcile, got %d calls", vm.reconcileCalls) t.Fatalf("nothing to reconcile, got %d calls", vm.reconcileCalls)
} }
} }
// TestAcceptWithCert_StaleSteerIsNotIssued is the PRODUCER fix, end to end through the sole // TestAcceptWithCert_StaleSteerIsNotIssued pins the producer property end to end through the
// finalizer. The VM block's Accept advances the ledger to H+1 — a deterministic injection of // sole finalizer. The VM block's Accept advances the ledger to H+1 — a deterministic
// the exact race window acceptWithCertCore documents (t.mu released across the VM call-outs). // injection of the race window acceptWithCertCore documents, where t.mu is released across
// The engine must then steer at the LIVE anchor (H+1), never at its stale local H. // the VM call-outs. The engine must then steer at the live anchor H+1, never at its stale
// // local H, which is backwards and lands in the VM's orphan refusal.
// Pre-fix the engine issued SetPreference(H) — backwards, into the EVM's orphan refusal and
// (with the pre-fix classifier) into os.Exit(1).
func TestAcceptWithCert_StaleSteerIsNotIssued(t *testing.T) { func TestAcceptWithCert_StaleSteerIsNotIssued(t *testing.T) {
e := newTestEngine() e := newTestEngine()
ctx := context.Background() ctx := context.Background()
@@ -172,9 +161,9 @@ func TestAcceptWithCert_StaleSteerIsNotIssued(t *testing.T) {
t.Fatalf("expected exactly one SetPreference, got %d (%v)", len(vm.steers), vm.steers) t.Fatalf("expected exactly one SetPreference, got %d (%v)", len(vm.steers), vm.steers)
} }
if vm.steers[0] == lower { if vm.steers[0] == lower {
t.Fatalf("STALE BACKWARDS STEER: SetPreference(%s) at height 1 while the ledger and the VM "+ t.Fatalf("stale backwards steer: SetPreference(%s) at height 1 while the ledger and the VM "+
"are already at height 2 (%s) — this is the refusal the EVM turns into "+ "are already at height 2 (%s) — the VM refuses this as orphaning a finalized block",
"\"cannot orphan finalized block\"", lower, upper) lower, upper)
} }
if vm.steers[0] != upper { if vm.steers[0] != upper {
t.Fatalf("expected the steer at the live build anchor %s, got %s", upper, vm.steers[0]) t.Fatalf("expected the steer at the live build anchor %s, got %s", upper, vm.steers[0])
@@ -207,25 +196,23 @@ func (m *steerRecordingVM) SetPreference(_ context.Context, id ids.ID) error {
return nil return nil
} }
// TestAcceptWithCert_UnheldAnchorFallsBackToAccepted is the SINGLE-STORE INVARIANT at the // TestAcceptWithCert_UnheldAnchorFallsBackToAccepted pins the single-store invariant at the
// finalize steer — the property ava gets for free by steering at Consensus.Preference() // finalize steer: the engine may only name a block its own VM provably holds. An engine that
// (ava snow/engine/snowman/voter.go, engine.go), a block it only ever obtained by // steers at a preference it obtained solely by verifying into the VM gets this for free; one
// verifying INTO the VM. // that steers at a value derived from the consensus DAG does not.
// //
// PreferredBuildTip is a consensus-DAG value, so a node that fell behind can name a tip // PreferredBuildTip is such a DAG value, so a node that fell behind can name a tip its own VM
// its own VM does not hold. Steering there is silently lossy, not loud: the proposervm // does not hold. Steering there is silently lossy rather than loud: the proposervm keeps its
// keeps its prior preference and returns nil (node/vms/proposervm/vm.go SetPreference), // prior preference and returns nil (node/vms/proposervm/vm.go SetPreference), so the engine's
// so the engine's own just-accepted blockID never gets steered either and the VM keeps // own just-accepted blockID never gets steered either and the VM keeps building on a head
// building on a head OLDER than the block consensus just finalized. // older than the block consensus just finalized. The fallback is blockID, which VM.Accept has
// // just applied and the VM therefore provably holds.
// Pre-fix the engine steered at the unheld anchor unconditionally. It must fall back to
// blockID, which VM.Accept has just applied and the VM therefore provably holds.
func TestAcceptWithCert_UnheldAnchorFallsBackToAccepted(t *testing.T) { func TestAcceptWithCert_UnheldAnchorFallsBackToAccepted(t *testing.T) {
e := newTestEngine() e := newTestEngine()
ctx := context.Background() ctx := context.Background()
lower := ids.GenerateTestID() // finalized here at height 1 — the VM HOLDS this lower := ids.GenerateTestID() // finalized here at height 1 — the VM holds this
upper := ids.GenerateTestID() // the DAG anchor at height 2 — this node does NOT hold it upper := ids.GenerateTestID() // the DAG anchor at height 2 — this node does not hold it
cb := &Block{id: lower, parentID: ids.Empty, height: 1} cb := &Block{id: lower, parentID: ids.Empty, height: 1}
if err := e.consensus.AddBlock(ctx, cb); err != nil { if err := e.consensus.AddBlock(ctx, cb); err != nil {
@@ -269,8 +256,8 @@ func TestAcceptWithCert_UnheldAnchorFallsBackToAccepted(t *testing.T) {
t.Fatalf("expected exactly one SetPreference, got %d (%v)", len(vm.steers), vm.steers) t.Fatalf("expected exactly one SetPreference, got %d (%v)", len(vm.steers), vm.steers)
} }
if vm.steers[0] == upper { if vm.steers[0] == upper {
t.Fatalf("STEERED AT AN UNHELD BLOCK: SetPreference(%s) names a block this VM does not "+ t.Fatalf("steered at an unheld block: SetPreference(%s) names a block this VM does not "+
"hold; the proposervm answers with a warn + nil, so the just-accepted %s is never "+ "hold; the proposervm answers with a warn and nil, so the just-accepted %s is never "+
"steered either and the VM keeps building on its old head", upper, lower) "steered either and the VM keeps building on its old head", upper, lower)
} }
if vm.steers[0] != lower { if vm.steers[0] != lower {
+51 -63
View File
@@ -1,35 +1,29 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved. // Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms. // See the file LICENSE for licensing terms.
// certified_steer_unreadable_test.go — an UNREADABLE steer target is not a safety violation. // certified_steer_unreadable_test.go — an unreadable steer target is not a safety violation.
// //
// THE INCIDENT (lux-testnet luxd-0, node v1.36.58). The pod crash-looped on: // reconcileVMToCertified can halt the node, so its classifier owes a three-valued answer.
// `false` has to mean "proven conflicting" and nothing else, because `false` is the single
// value routed to a fatal. Two states prove nothing at all and must not be reported as it:
// //
// error VM accepted head is CONSENSUS-CERTIFIED and the finalized block is NOT on the // - The target cannot be read. While the engine is stopping, every VM call-out fails with
// certified chain — refusing to orphan it orphanedHeight=12781 // the context's error; reconcileVMToCertified already declines to crash for that reason
// fatal SetPreference would orphan a CONSENSUS-CERTIFIED block — refusing (fail-closed) // on its other read paths (LastAccepted, GetBlock(head)). Folding a GetBlock error into a
// error="context canceled" // bare `false` makes an unreadable block indistinguishable from a proven double-
// finalization, so an orderly stop becomes a fatal on a node whose finality ledger was
// never in question.
// - The ledger holds no cert at the target's height. A double-finalization needs two certs
// at one height; with none there, nothing is proven. That state is ordinary rather than
// exotic: a steer target is normally the build tip, which PreferredBuildTip defines as
// the deepest verified block extending the finalized chain — above the frontier and
// uncertified by construction — and byHeight is bounded by pruneBelowWindow, so an
// aged-out ancestor below the frontier reads identically.
// //
// Every neighbouring VM call-out failed with the same `context canceled`: the engine was // The safety property is unchanged: see TestReconcile_CertifiedHead_HaltsFailClosed
// SHUTTING DOWN. reconcileVMToCertified declines to crash for exactly that reason on both of // (orphan_reconcile_test.go), which halts with both blocks readable and a real conflicting
// its other read paths (LastAccepted, GetBlock(head)) — but certifiedAtItsHeight folded its // cert, and TestCertifiedAtItsHeight_ThreeValued below, which pins every outcome so a
// GetBlock error into a bare `false`, and `false` was the ONE value routed to os.Exit(1). So // refactor cannot quietly re-collapse them.
// an unreadable block was indistinguishable from a proven double-finalization, and an orderly
// stop became a fatal on a node whose finality ledger was never in question.
//
// Red review then found the SAME category error one level down, and it is the more likely
// mechanism here: the predicate also answered `false` when the ledger holds NO cert at the
// target's height. A double-finalization needs TWO certs at ONE height; with none there,
// nothing is proven. That state is not exotic — a steer target is normally the build tip,
// which PreferredBuildTip defines as the deepest verified block EXTENDING the finalized chain
// (above the frontier, uncertified by construction), and byHeight is bounded by
// pruneBelowWindow so an aged-out ancestor reads identically. Either would os.Exit(1) a
// healthy validator.
//
// `false` now means PROVEN CONFLICTING and nothing else. The safety property is UNCHANGED:
// see TestReconcile_CertifiedHead_HaltsFailClosed (orphan_reconcile_test.go), which halts with
// BOTH blocks readable and a real conflicting cert, and TestCertifiedAtItsHeight_ThreeValued
// below, which pins every outcome so a future refactor cannot quietly re-collapse them.
package chain package chain
import ( import (
@@ -43,7 +37,7 @@ import (
// ctxHonoringOrphanVM fails GetBlock with the context's error once the context is cancelled — // ctxHonoringOrphanVM fails GetBlock with the context's error once the context is cancelled —
// the real shutdown shape. Blocks already in the map are served from "cache" regardless, so a // the real shutdown shape. Blocks already in the map are served from "cache" regardless, so a
// test can make the HEAD readable while the STEER TARGET requires a live read that fails. // test can make the head readable while the steer target requires a live read that fails.
type ctxHonoringOrphanVM struct { type ctxHonoringOrphanVM struct {
*orphanVMBase *orphanVMBase
cached map[ids.ID]*mockBlock // served even after cancellation cached map[ids.ID]*mockBlock // served even after cancellation
@@ -66,30 +60,28 @@ func (m *ctxHonoringOrphanVM) ReconcilePreference(_ context.Context, id ids.ID)
return nil return nil
} }
// TestReconcile_CertifiedHead_UnreadableSteerTarget_NoHalt: the VM's head IS the ledger's // TestReconcile_CertifiedHead_UnreadableSteerTarget_NoHalt: the VM's head is the ledger's
// certified canonical at its height, and the steer target cannot be read at all. Nothing is // certified canonical at its height, and the steer target cannot be read at all. Nothing is
// proven about the target, so nothing may be orphaned and the node must NOT halt. // proven about the target, so nothing may be orphaned and the node may not halt.
//
// Pre-fix: GetBlock error ⇒ false ⇒ "NOT on the certified chain" ⇒ os.Exit(1).
func TestReconcile_CertifiedHead_UnreadableSteerTarget_NoHalt(t *testing.T) { func TestReconcile_CertifiedHead_UnreadableSteerTarget_NoHalt(t *testing.T) {
e := newTestEngine() e := newTestEngine()
head := ids.GenerateTestID() // certified at 12781 — the VM's accepted head head := ids.GenerateTestID() // certified at 12781 — the VM's accepted head
certified := ids.GenerateTestID() // the steer target — UNREADABLE certified := ids.GenerateTestID() // the steer target — not readable at all
e.consensus.ledger = seedLedger(head, head, 12781) e.consensus.ledger = seedLedger(head, head, 12781)
vm := &reconcilingOrphanVM{orphanVMBase: &orphanVMBase{ vm := &reconcilingOrphanVM{orphanVMBase: &orphanVMBase{
head: head, head: head,
// `certified` is deliberately ABSENT ⇒ GetBlock returns "block not found". // `certified` is deliberately absent, so GetBlock returns "block not found".
blocks: map[ids.ID]*mockBlock{head: mb(head, 12781)}, blocks: map[ids.ID]*mockBlock{head: mb(head, 12781)},
}} }}
if !e.reconcileVMToCertified(context.Background(), vm, certified, errOrphan) { if !e.reconcileVMToCertified(context.Background(), vm, certified, errOrphan) {
t.Fatal("an UNREADABLE steer target proves no safety violation — the engine must NOT " + t.Fatal("an unreadable steer target proves no safety violation — the engine may not " +
"halt fail-closed (only a block we read and the ledger proves uncertified may halt)") "halt fail-closed (only a block we read and the ledger proves uncertified may halt)")
} }
if vm.reconcileCalls != 0 { if vm.reconcileCalls != 0 {
t.Fatalf("an unclassifiable state must leave the VM head ALONE, got %d ReconcilePreference calls", t.Fatalf("an unclassifiable state must leave the VM head alone, got %d ReconcilePreference calls",
vm.reconcileCalls) vm.reconcileCalls)
} }
if vm.head != head { if vm.head != head {
@@ -97,9 +89,9 @@ func TestReconcile_CertifiedHead_UnreadableSteerTarget_NoHalt(t *testing.T) {
} }
} }
// TestReconcile_CertifiedHead_CancelledContext_NoHalt is the incident verbatim: the engine is // TestReconcile_CertifiedHead_CancelledContext_NoHalt: the engine is shutting down, so the
// shutting down, so the steer target's read fails with context.Canceled while the head is // steer target's read fails with context.Canceled while the head is still served from cache.
// still served from cache. This is the state that killed luxd-0 on every restart. // A stop is not evidence of anything about the ledger.
func TestReconcile_CertifiedHead_CancelledContext_NoHalt(t *testing.T) { func TestReconcile_CertifiedHead_CancelledContext_NoHalt(t *testing.T) {
e := newTestEngine() e := newTestEngine()
@@ -119,8 +111,8 @@ func TestReconcile_CertifiedHead_CancelledContext_NoHalt(t *testing.T) {
cancel() // the engine is stopping — exactly what failBootstrapChain does cancel() // the engine is stopping — exactly what failBootstrapChain does
if !e.reconcileVMToCertified(ctx, vm, certified, errOrphan) { if !e.reconcileVMToCertified(ctx, vm, certified, errOrphan) {
t.Fatal("a cancelled context is a SHUTDOWN, not a double-finalization — the engine must " + t.Fatal("a cancelled context is a shutdown, not a double-finalization — the engine may " +
"NOT os.Exit(1) while stopping") "not halt while stopping")
} }
if vm.reconcileCalls != 0 { if vm.reconcileCalls != 0 {
t.Fatalf("nothing may be reconciled on an unreadable classification, got %d calls", vm.reconcileCalls) t.Fatalf("nothing may be reconciled on an unreadable classification, got %d calls", vm.reconcileCalls)
@@ -130,13 +122,10 @@ func TestReconcile_CertifiedHead_CancelledContext_NoHalt(t *testing.T) {
} }
} }
// TestReconcile_CertifiedHead_TargetAboveFrontier_NoHalt: the steer target is READABLE and // TestReconcile_CertifiedHead_TargetAboveFrontier_NoHalt: the steer target is readable and
// sits ABOVE the finalized frontier, so the ledger holds no cert at its height. That is not a // sits above the finalized frontier, so the ledger holds no cert at its height. That is not a
// double-finalization — it is what a build tip normally IS (PreferredBuildTip is defined as // double-finalization — it is what a build tip normally is, PreferredBuildTip being defined as
// the deepest verified block EXTENDING the finalized chain). Nothing is orphaned; no halt. // the deepest verified block extending the finalized chain. Nothing is orphaned; no halt.
//
// Pre-fix the missing byHeight entry read as `false` ⇒ "NOT on the certified chain" ⇒
// os.Exit(1) on a perfectly healthy validator.
func TestReconcile_CertifiedHead_TargetAboveFrontier_NoHalt(t *testing.T) { func TestReconcile_CertifiedHead_TargetAboveFrontier_NoHalt(t *testing.T) {
e := newTestEngine() e := newTestEngine()
@@ -153,17 +142,17 @@ func TestReconcile_CertifiedHead_TargetAboveFrontier_NoHalt(t *testing.T) {
}} }}
if !e.reconcileVMToCertified(context.Background(), vm, buildTip, errOrphan) { if !e.reconcileVMToCertified(context.Background(), vm, buildTip, errOrphan) {
t.Fatal("a steer target ABOVE the finalized frontier carries no conflicting cert — " + t.Fatal("a steer target above the finalized frontier carries no conflicting cert — " +
"absence of a cert at its height is not evidence of a second finalization, and the " + "absence of a cert at its height is not evidence of a second finalization, and the " +
"engine must NOT halt") "engine may not halt")
} }
if vm.head != head { if vm.head != head {
t.Fatalf("VM head must be untouched, got %s want %s", vm.head, head) t.Fatalf("VM head must be untouched, got %s want %s", vm.head, head)
} }
} }
// TestReconcile_CertifiedHead_TargetPrunedFromWindow_NoHalt: the steer target is READABLE and // TestReconcile_CertifiedHead_TargetPrunedFromWindow_NoHalt: the steer target is readable and
// BELOW the frontier, but its height has aged out of byHeight (bounded by pruneBelowWindow). // below the frontier, but its height has aged out of byHeight, which pruneBelowWindow bounds.
// The entry is gone, not contradicted. Same category, same answer: no halt. // The entry is gone, not contradicted. Same category, same answer: no halt.
func TestReconcile_CertifiedHead_TargetPrunedFromWindow_NoHalt(t *testing.T) { func TestReconcile_CertifiedHead_TargetPrunedFromWindow_NoHalt(t *testing.T) {
e := newTestEngine() e := newTestEngine()
@@ -181,18 +170,17 @@ func TestReconcile_CertifiedHead_TargetPrunedFromWindow_NoHalt(t *testing.T) {
}} }}
if _, ok := e.consensus.ledger.At(3); ok { if _, ok := e.consensus.ledger.At(3); ok {
t.Fatal("precondition: height 3 must NOT be in byHeight for this test") t.Fatal("precondition: height 3 must be absent from byHeight for this test")
} }
if !e.reconcileVMToCertified(context.Background(), vm, ancient, errOrphan) { if !e.reconcileVMToCertified(context.Background(), vm, ancient, errOrphan) {
t.Fatal("a steer target whose height was PRUNED from the equivocation window carries " + t.Fatal("a steer target whose height was pruned from the equivocation window carries " +
"no conflicting cert — the engine must NOT halt on a missing entry") "no conflicting cert — the engine may not halt on a missing entry")
} }
} }
// TestCertifiedAtItsHeight_ThreeValued pins the predicate's contract: `false` means PROVEN // TestCertifiedAtItsHeight_ThreeValued pins the predicate's contract: `false` means "proven
// CONFLICTING and nothing else. Both "unreadable" and "no cert at this height" must report an // conflicting" and nothing else. Both "unreadable" and "no cert at this height" report an
// error. A future refactor that collapses either back into a bare `false` fails here rather // error instead. A refactor that collapses either back into a bare `false` fails here.
// than in production at 3am.
func TestCertifiedAtItsHeight_ThreeValued(t *testing.T) { func TestCertifiedAtItsHeight_ThreeValued(t *testing.T) {
onChain := ids.GenerateTestID() onChain := ids.GenerateTestID()
offChain := ids.GenerateTestID() offChain := ids.GenerateTestID()
@@ -205,7 +193,7 @@ func TestCertifiedAtItsHeight_ThreeValued(t *testing.T) {
head: onChain, head: onChain,
blocks: map[ids.ID]*mockBlock{ blocks: map[ids.ID]*mockBlock{
onChain: mb(onChain, 7), onChain: mb(onChain, 7),
offChain: mb(offChain, 7), // same height, NOT the ledger's canonical there offChain: mb(offChain, 7), // same height, but not the ledger's canonical there
}, },
} }
ctx := context.Background() ctx := context.Background()
@@ -214,19 +202,19 @@ func TestCertifiedAtItsHeight_ThreeValued(t *testing.T) {
t.Fatalf("a block the ledger certifies at its own height ⇒ (true, nil), got (%v, %v)", ok, err) t.Fatalf("a block the ledger certifies at its own height ⇒ (true, nil), got (%v, %v)", ok, err)
} }
if ok, err := e.certifiedAtItsHeight(ctx, vm, offChain); err != nil || ok { if ok, err := e.certifiedAtItsHeight(ctx, vm, offChain); err != nil || ok {
t.Fatalf("a cert EXISTS at that height naming a different canonical ⇒ (false, nil) — the "+ t.Fatalf("a cert exists at that height naming a different canonical ⇒ (false, nil) — the "+
"halt-authorising answer — got (%v, %v)", ok, err) "halt-authorising answer — got (%v, %v)", ok, err)
} }
ok, err := e.certifiedAtItsHeight(ctx, vm, missing) ok, err := e.certifiedAtItsHeight(ctx, vm, missing)
if err == nil { if err == nil {
t.Fatal("an UNREADABLE block must report an ERROR, not (false, nil) — conflating the two " + t.Fatal("an unreadable block must report an error, not (false, nil) — conflating the two " +
"is what turned a shutdown into a fatal") "would route an orderly shutdown to a fatal")
} }
if ok { if ok {
t.Fatalf("an unreadable block must never report certified=true, got %v", ok) t.Fatalf("an unreadable block must never report certified=true, got %v", ok)
} }
// NO CERT AT THIS HEIGHT is the other "nothing is proven" state, and it must not be // "No cert at this height" is the other "nothing is proven" state, and it must not be
// reported as the halt-authorising `false` either. // reported as the halt-authorising `false` either.
above := ids.GenerateTestID() above := ids.GenerateTestID()
vm.blocks[above] = mb(above, 9) // readable, above the frontier — byHeight has nothing at 9 vm.blocks[above] = mb(above, 9) // readable, above the frontier — byHeight has nothing at 9
+77 -84
View File
@@ -24,18 +24,18 @@ var (
// bypasses FinalizeBranch by design (an import is an out-of-band // bypasses FinalizeBranch by design (an import is an out-of-band
// reconcile, not an α-of-K finalize), so the monotonic invariant the finalize // reconcile, not an α-of-K finalize), so the monotonic invariant the finalize
// path enforces must be re-asserted here explicitly: a backward // path enforces must be re-asserted here explicitly: a backward
// import must NOT silently regress local finalized height (which would let a // import must not silently regress local finalized height (which would let a
// shorter imported chain un-finalize blocks the node already finalized). // shorter imported chain un-finalize blocks the node already finalized).
ErrSyncStateRegression = errors.New("chain: SyncState refused — import height is below the already-finalized height (would regress finalized history)") ErrSyncStateRegression = errors.New("chain: SyncState refused — import height is below the already-finalized height (would regress finalized history)")
// ErrSyncStateEmptyWithHeight is returned by SyncState when an EMPTY import // ErrSyncStateEmptyWithHeight is returned by SyncState when an empty import head
// head (lastAcceptedID == ids.Empty) is paired with a POSITIVE height (INFO-6). // (lastAcceptedID == ids.Empty) is paired with a positive height. An empty head is
// An empty head is the genesis/teardown reset, which is meaningful only at // the genesis/teardown reset, meaningful only at height 0; above it the pair is
// height 0; an empty head at height>0 is contradictory. If allowed it would set // contradictory. Allowing it would set finalizedTip=Empty while leaving
// finalizedTip=Empty while LEAVING finalizedHeight/finalizedByHeight stale (the // finalizedHeight/finalizedByHeight stale (the non-empty seed branch is skipped)
// non-empty seed branch is skipped) and PRUNE blocks below the positive height — // and prune blocks below the positive height, so the tip and the height would
// the exact finalizedTip-vs-finalizedHeight desync ForcePreference was hardened // describe different chains. SyncState refuses it fail-closed before mutating any
// against. SyncState refuses it fail-closed before mutating any state. // state.
ErrSyncStateEmptyWithHeight = errors.New("chain: SyncState refused — empty import head paired with a non-zero height (an empty reset is genesis/teardown at height 0; this would desync finalizedTip from finalizedHeight and prune live blocks)") ErrSyncStateEmptyWithHeight = errors.New("chain: SyncState refused — empty import head paired with a non-zero height (an empty reset is genesis/teardown at height 0; this would desync finalizedTip from finalizedHeight and prune live blocks)")
// ErrEpochRegression is returned by the receive-side epoch gate when a // ErrEpochRegression is returned by the receive-side epoch gate when a
@@ -91,7 +91,7 @@ type ChainConsensus struct {
// superset of a bare majority, so every Quasar block is already a Nova block, and this // superset of a bare majority, so every Quasar block is already a Nova block, and this
// frontier is always a prefix of the certified Nova chain. It advances ONLY via // frontier is always a prefix of the certified Nova chain. It advances ONLY via
// PromoteQuasar, which requires the promoted block to MATCH the Nova ledger's accepted // PromoteQuasar, which requires the promoted block to MATCH the Nova ledger's accepted
// canonical at that height — so the "no two conflicting Quasar certs" INVARIANT is // canonical at that height — so the "no two conflicting Quasar certs" invariant is
// inherited from the Nova ledger's single-canonical-per-height gate. GetQuasarTip reads // inherited from the Nova ledger's single-canonical-per-height gate. GetQuasarTip reads
// it; it is the ONLY tip bridges / DEX settlement / cross-chain messages may consume. // it; it is the ONLY tip bridges / DEX settlement / cross-chain messages may consume.
quasar quasarFrontier quasar quasarFrontier
@@ -176,7 +176,7 @@ func (c *ChainConsensus) applyCertLocked(cert Cert) (Plan, error) {
if err != nil { if err != nil {
return Plan{}, err return Plan{}, err
} }
c.ledger = led // THE ONLY way finality advances — one value assignment after a pure fold c.ledger = led // the only way finality advances — one value assignment after a pure fold
c.applyPlan(plan) // DAG-side effects only (accepted/rejected/tips); never finality c.applyPlan(plan) // DAG-side effects only (accepted/rejected/tips); never finality
return plan, nil return plan, nil
} }
@@ -184,7 +184,7 @@ func (c *ChainConsensus) applyCertLocked(cert Cert) (Plan, error) {
// applyPlan applies the fold's plan to the live DAG: mark the Accept path accepted and // applyPlan applies the fold's plan to the live DAG: mark the Accept path accepted and
// drop it from the build tips; mark the Reject (losing-sibling) subtrees rejected and // drop it from the build tips; mark the Reject (losing-sibling) subtrees rejected and
// remove them from the live DAG/tips — accept-preferred-child plus transitive reject // remove them from the live DAG/tips — accept-preferred-child plus transitive reject
// on the DAG side. It NEVER touches c.ledger; finality already advanced in the fold. // on the DAG side. It never touches c.ledger; finality already advanced in the fold.
// Caller holds c.mu. // Caller holds c.mu.
func (c *ChainConsensus) applyPlan(plan Plan) { func (c *ChainConsensus) applyPlan(plan Plan) {
for _, id := range plan.Accept { for _, id := range plan.Accept {
@@ -238,59 +238,56 @@ func (c *ChainConsensus) Stats() map[string]interface{} {
// This is called by the syncer after admin_importChain to reconcile consensus // This is called by the syncer after admin_importChain to reconcile consensus
// state with the EVM state database. // state with the EVM state database.
// //
// MONOTONIC GUARD (defence-in-depth): SyncState bypasses FinalizeBranch by // Monotonicity is re-asserted here, defence in depth. SyncState bypasses
// design — an import is an out-of-band reconcile with the VM's last-accepted // FinalizeBranch by design — an import is an out-of-band reconcile with the VM's
// head, NOT an α-of-K finalize, so it legitimately seeds finalizedTip/Height // last-accepted head, not an α-of-K finalize, so it legitimately seeds
// directly. But "bypasses the finalize path" must NOT mean "bypasses the // finalizedTip/Height directly. Bypassing the finalize path does not mean bypassing
// monotonic invariant": a backward import (height below the already-finalized // the monotonic invariant: a backward import (height below the already-finalized
// height) is refused with ErrSyncStateRegression and leaves all finalized state // height) is refused with ErrSyncStateRegression and leaves all finalized state
// untouched. Without this guard a shorter/older imported chain could silently // untouched. Otherwise a shorter/older imported chain could silently regress
// regress finalizedHeight and un-finalize blocks the node already finalized — // finalizedHeight and un-finalize blocks the node already finalized, re-opening the
// re-opening the very fork window the per-height guard closes. A re-import at the // fork window the per-height guard closes. A re-import at the same height with the
// SAME height with the SAME block is idempotent; a forward import advances. The // same block is idempotent; a forward import advances. The only backward move
// only allowed move-backward is the genesis/empty reset (lastAcceptedID==Empty), // allowed is the genesis/empty reset (lastAcceptedID==Empty), valid only at height 0
// which is valid ONLY at height 0 — an empty head at height>0 is contradictory // — an empty head above it is contradictory and refused with
// and refused with ErrSyncStateEmptyWithHeight (INFO-6), since it would desync // ErrSyncStateEmptyWithHeight, since it would leave finalizedTip and
// finalizedTip from finalizedHeight and prune live blocks. // finalizedHeight describing different chains and prune live blocks.
func (c *ChainConsensus) SyncState(lastAcceptedID ids.ID, height uint64) error { func (c *ChainConsensus) SyncState(lastAcceptedID ids.ID, height uint64) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
// Refuse an empty import head paired with a positive height (INFO-6). An empty // Refuse an empty import head paired with a positive height. An empty head is the
// head is the genesis/teardown reset — valid only at height 0. At height>0 it // genesis/teardown reset — valid only at height 0. Above it, it would assign
// would assign finalizedTip=Empty while skipping the seed branch (so // finalizedTip=Empty while skipping the seed branch (so
// finalizedHeight/finalizedByHeight stay stale) AND prune blocks below the // finalizedHeight/finalizedByHeight stay stale) and prune blocks below the height,
// height, desyncing finalizedTip from finalizedHeight (the desync the per-height // leaving the tip and the height describing different chains. Fail closed before
// guard and ForcePreference exist to prevent). Fail-closed BEFORE any mutation. // any mutation. The sole caller, SyncStateFromVM, pairs Empty with height 0, so
// Unreachable on the live path (the sole caller, SyncStateFromVM, pairs Empty // this is unreachable today; it is guarded so a future caller cannot open the gap.
// with height 0), but guarded so a future caller cannot open the desync.
if lastAcceptedID == ids.Empty && height > 0 { if lastAcceptedID == ids.Empty && height > 0 {
return fmt.Errorf("%w: refused empty head at height %d", ErrSyncStateEmptyWithHeight, height) return fmt.Errorf("%w: refused empty head at height %d", ErrSyncStateEmptyWithHeight, height)
} }
// Refuse a backward regression of CERTIFIED finalized history. Only enforced once // Refuse a backward regression of CERTIFIED finalized history. Only enforced once
// a cert has finalized and for a concrete (non-empty) import head; an empty reset // a cert has finalized and for a concrete (non-empty) import head; an empty reset
// is a deliberate teardown, not a regression. A hint can NEVER un-finalize a // is a deliberate teardown, not a regression. A hint can never un-finalize a
// QC-backed block: an import below the certified height is refused outright. // QC-backed block: an import below the certified height is refused outright.
if c.ledger.set && lastAcceptedID != ids.Empty && height < c.ledger.height { if c.ledger.set && lastAcceptedID != ids.Empty && height < c.ledger.height {
return fmt.Errorf("%w: import height %d < finalized height %d (block %s)", return fmt.Errorf("%w: import height %d < finalized height %d (block %s)",
ErrSyncStateRegression, height, c.ledger.height, lastAcceptedID) ErrSyncStateRegression, height, c.ledger.height, lastAcceptedID)
} }
// NOTE (incident-1082814 PART-A): the pre-fix code ALSO ran an equal-height // There is deliberately no equal-height "different block ⇒ equivocation" check
// "different block ⇒ equivocation" check here, because SyncState used to SEED // here. An import is a non-authoritative hint, not a finality seed, so there is
// FINALITY from vm.LastAccepted. It no longer does — the import is a // nothing for it to equivocate against: equivocation is decided exclusively by the
// non-authoritative HINT — so there is nothing to equivocate against and that // cert fold over canonical commitments (Finalize). Were a local seed to enter
// check is deliberately gone. Equivocation is decided EXCLUSIVELY by the cert fold // finality, a locally-chosen sibling could manufacture a finalized entry that a
// over canonical commitments (Finalize), never from a local seed. This is the // peer's legitimate cert then reads as a conflict — refusing the honest chain
// single most important line of the fix: a locally-seeded sibling can no longer // forever, since the local entry never moves.
// manufacture a finalized entry that a peer's legitimate cert then "conflicts"
// with.
// Apply the import as a RECOVERY HINT — a build anchor only, never finality // Apply the import as a recovery hint — a build anchor only, never finality.
// (PART-A). vm.LastAccepted is the local node's boot/import snapshot; after a crash // vm.LastAccepted is the local node's boot/import snapshot; after a crash it can
// it can name an UNCERTIFIED local block whose inner block the network actually // name an uncertified local block whose inner block the network actually finalized
// finalized under a different envelope. As a HINT, Height() stays (0,false) until a // under a different envelope. As a hint, Height() stays (0,false) until a real
// real cert, byHeight stays empty, and no cert can ever equivocate against it. // cert, byHeight stays empty, and no cert can equivocate against it.
if lastAcceptedID != ids.Empty { if lastAcceptedID != ids.Empty {
// Preserve any CERTIFIED history (whole-value copy) and update only the hint // Preserve any CERTIFIED history (whole-value copy) and update only the hint
// fields — a hint must never wipe a QC-backed frontier. When no certified // fields — a hint must never wipe a QC-backed frontier. When no certified
@@ -337,11 +334,10 @@ func (c *ChainConsensus) GetFinalizedTip() ids.ID {
// GetQuasarTip returns the canonical execution commitment of the highest EXPORT-FINAL // GetQuasarTip returns the canonical execution commitment of the highest EXPORT-FINAL
// (Quasar, ⅔-by-stake) block — the ONLY tip a bridge / DEX settlement / cross-chain // (Quasar, ⅔-by-stake) block — the ONLY tip a bridge / DEX settlement / cross-chain
// message / validator-set transition may treat as deterministically final. It is // message / validator-set transition may treat as deterministically final. It is
// ids.Empty until the first ⅔-stake certificate forms, and it NEVER returns the Nova // ids.Empty until the first ⅔-stake certificate forms, and it never returns the Nova
// (accept) tip: a Nova block is locally executed but a lone equivocator can fork a bare // (accept) tip: a Nova block is locally executed but a lone equivocator can fork a bare
// majority, so it is reorgable and non-exportable until it reaches Quasar. This is the // majority, so it is reorgable and non-exportable until it reaches Quasar. Gating export
// rename of the former GetCertifiedTip, re-pointed from the Nova fold result to the // on this frontier rather than on the accept frontier is the two-tier ladder's whole point.
// trailing Quasar frontier — THE export-gating invariant of the two-tier ladder.
func (c *ChainConsensus) GetQuasarTip() ids.ID { func (c *ChainConsensus) GetQuasarTip() ids.ID {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@@ -350,11 +346,10 @@ func (c *ChainConsensus) GetQuasarTip() ids.ID {
// GetNovaTip returns the canonical execution commitment of the highest LOCALLY ACCEPTED // GetNovaTip returns the canonical execution commitment of the highest LOCALLY ACCEPTED
// (Nova) block — the accepted/execution head that drove VM.Accept. It is the correct tip // (Nova) block — the accepted/execution head that drove VM.Accept. It is the correct tip
// for local build/preference and for equivocation evidence, but it authorizes LOCAL // for local build/preference and for equivocation evidence, but it authorizes local
// execution ONLY: it is reorgable-in-theory until it reaches Quasar and MUST NOT be // execution only: it is reorgable in theory until it reaches Quasar and must not be
// exported (use GetQuasarTip for any bridge / settlement / cross-chain decision). This // exported (use GetQuasarTip for any bridge / settlement / cross-chain decision). The
// carries the old GetCertifiedTip semantics (the Nova ledger's canonical fold result), // tier is in the name so no caller can mistake accept for export.
// renamed to name the tier explicitly so no caller can mistake accept for export.
func (c *ChainConsensus) GetNovaTip() ids.ID { func (c *ChainConsensus) GetNovaTip() ids.ID {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@@ -379,10 +374,11 @@ func (c *ChainConsensus) QuasarHeight() (uint64, bool) {
// did not accept: // did not accept:
// //
// - advanced=true → the frontier moved to a new (strictly higher) height. // - advanced=true → the frontier moved to a new (strictly higher) height.
// - err != nil (ErrHeightAlreadyFinalized) → the export cert's canonical CONFLICTS with the // - err != nil (ErrHeightAlreadyFinalized) → the export cert's canonical conflicts with the
// canonical Nova already accepted at that height. That is a provable ⅔-stake // canonical Nova already accepted at that height. That is a provable ⅔-stake
// equivocation — it needs >⅓ stake to double-sign, beyond the f<⅓ assumption — so the // equivocation — it needs >⅓ stake to double-sign, beyond the f<⅓ assumption — so the
// caller HALTS fail-closed and surfaces it as slashable evidence. NEVER advances on error. // caller halts fail-closed and surfaces it as slashable evidence. The frontier does not
// advance on error.
// - advanced=false, err=nil → idempotent/no-op: the height is at/below the current Quasar // - advanced=false, err=nil → idempotent/no-op: the height is at/below the current Quasar
// frontier (a duplicate/late cert), or the Nova ledger has not yet (or no longer, past the // frontier (a duplicate/late cert), or the Nova ledger has not yet (or no longer, past the
// equivocation window) recorded this height — in which case it is an ancestor of the Nova // equivocation window) recorded this height — in which case it is an ancestor of the Nova
@@ -419,13 +415,13 @@ func (c *ChainConsensus) PromoteQuasar(cert Cert) (advanced bool, err error) {
novaCanonical, have := c.ledger.At(cert.Height) novaCanonical, have := c.ledger.At(cert.Height)
if !have { if !have {
// Nova has not (yet / no longer within the equivocation window) recorded this height. // Nova has not (yet / no longer within the equivocation window) recorded this height.
// Do NOT advance: either Nova will catch up and the promotion retries, or the height // Do not advance: either Nova will catch up and the promotion retries, or the height
// aged out and is an ancestor of the Nova tip (already implicitly final). Not an error. // aged out and is an ancestor of the Nova tip (already implicitly final). Not an error.
return false, nil return false, nil
} }
if novaCanonical != canonical { if novaCanonical != canonical {
// A ⅔-stake export cert for a canonical the Nova ledger did NOT accept at this height — // A ⅔-stake export cert for a canonical the Nova ledger did not accept at this height
// the two-tier INVARIANT breach. Fail closed; the caller halts and slashes. // breaks the two-tier invariant. Fail closed; the caller halts and slashes.
return false, fmt.Errorf("%w: quasar export cert canonical %s conflicts with nova-accepted canonical %s at height %d", return false, fmt.Errorf("%w: quasar export cert canonical %s conflicts with nova-accepted canonical %s at height %d",
ErrHeightAlreadyFinalized, canonical, novaCanonical, cert.Height) ErrHeightAlreadyFinalized, canonical, novaCanonical, cert.Height)
} }
@@ -437,9 +433,9 @@ func (c *ChainConsensus) PromoteQuasar(cert Cert) (advanced bool, err error) {
} }
// GetFinalizedHeight returns the current finalized height and whether any block // GetFinalizedHeight returns the current finalized height and whether any block
// has been finalized yet. The engine uses this to gate an incoming cert on // has been finalized yet. The engine uses this to gate an incoming cert on height
// height (reject a cert at or below the last-finalized height — MED-5) BEFORE // — a cert at or below the last-finalized height is rejected — before running the
// running the cert through the per-height guard. // cert through the per-height guard.
func (c *ChainConsensus) GetFinalizedHeight() (uint64, bool) { func (c *ChainConsensus) GetFinalizedHeight() (uint64, bool) {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@@ -452,18 +448,15 @@ func (c *ChainConsensus) GetFinalizedHeight() (uint64, bool) {
// so a caller acting here must be prepared to reorg"), so this is a BUILD / CATCH-UP / // so a caller acting here must be prepared to reorg"), so this is a BUILD / CATCH-UP /
// REPORTING bound only. // REPORTING bound only.
// //
// FORBIDDEN AS A SIGN GATE. It was previously named GetNovaAcceptedFloor and fed the equivocation // Not a signing floor. Welding a validator's permanent sign-refusal floor to a reorgable
// sign gate, which welded a validator's PERMANENT sign-refusal floor to a reorgable height: // height strands that validator: heights accepted on a bare Nova majority that was never
// a node that accepted 11364..11367 on a bare Nova majority which was never ⅔-certified could // ⅔-certified could never be signed at again, including the rebuild every honest peer
// never again sign at or below 11367 — including the honest rebuild every peer agreed on. // agreed on. "Refusing more is always fail-safe" is false on the signing path — over-refusal
// That is the mainnet-1098192 / testnet-11367 permanent halt, and it is why "refusing more is // there is not caution but a liveness fault that no restart, resync or peer can clear. Use
// always fail-safe" is FALSE here: over-refusal on the signing path is not caution, it is a // GetQuasarSigningFloor for anything that permanently closes a height.
// liveness fault that no restart, resync, or peer can clear. Use GetQuasarSigningFloor for
// anything that permanently closes a height.
// //
// HARD CONSTRAINT (PART-A): read-only. It NEVER writes to, and its value NEVER enters, // Read-only: its value never enters byHeight, the equivocation index, or the authoritative
// byHeight / the equivocation index / the authoritative ledger.Height() — it reads the hint // ledger.Height(). It reads the hint's height only, never the hint's (untrustworthy) identity.
// HEIGHT only, never the hint's (untrustworthy) identity.
func (c *ChainConsensus) GetNovaAcceptedFloor() uint64 { func (c *ChainConsensus) GetNovaAcceptedFloor() uint64 {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@@ -522,17 +515,17 @@ func (c *ChainConsensus) K() int {
return c.k return c.k
} }
// Reclamp updates the committee sample size k and quorum alpha to track a CHANGED live // Reclamp updates the committee sample size k and quorum alpha to track a changed live
// validator set. The committee is otherwise fixed at NewChainConsensus (the construction-time // validator set. The committee is otherwise fixed at NewChainConsensus (the construction-time
// bftCommittee clamp); without a re-clamp a chain that LAUNCHED single-validator would keep k // bftCommittee clamp); without a re-clamp a chain that launched single-validator would keep k
// stuck at 1, and after it added validators via its PoS staking contract the lone validator // stuck at 1, and after it added validators via its PoS staking contract the lone validator
// would still take the K==1 path (synthesize a 1-of-1 cert / accept a single-signer stake cert // would still take the K==1 path (synthesize a 1-of-1 cert / accept a single-signer stake cert
// at alpha=1) and finalize UNILATERALLY — a cross-node fork against validators 2..N who require // at alpha=1) and finalize unilaterally — a cross-node fork against validators 2..N who require
// a real k=N quorum (RED HIGH, the 1→N decentralization path). beta (the confidence threshold) // a real k=N quorum. That is the failure mode on the path from one validator to many. beta (the
// is committee-size-independent and is left unchanged. A degenerate update (k<1, alpha<1, or // confidence threshold) is committee-size-independent and is left unchanged. A degenerate update
// alpha>k) is ignored so a mis-derived call can never open a sub-quorum. The engine only ever // (k<1, alpha<1, or alpha>k) is ignored so a mis-derived call can never open a sub-quorum. The
// calls this to move the committee UP toward the live count (reclampCommitteeLocked, up-only), // engine only ever calls this to move the committee up toward the live count
// so a transient low read can never shrink the quorum here. // (reclampCommitteeLocked, up-only), so a transient low read can never shrink the quorum here.
func (c *ChainConsensus) Reclamp(k, alpha int) { func (c *ChainConsensus) Reclamp(k, alpha int) {
if k < 1 || alpha < 1 || alpha > k { if k < 1 || alpha < 1 || alpha > k {
return return
+7 -2
View File
@@ -20,6 +20,7 @@
package chain package chain
import ( import (
"errors"
"testing" "testing"
"github.com/luxfi/ids" "github.com/luxfi/ids"
@@ -103,12 +104,16 @@ func TestEpochBinding_CrossEpochCertRejected(t *testing.T) {
certLaundered := &QuorumCert{ certLaundered := &QuorumCert{
Version: certA.Version, Version: certA.Version,
Type: certA.Type, Type: certA.Type,
Tier: certA.Tier,
Position: VotePosition{ChainID: chainID, Height: 10, Round: 0, BlockID: blockID, ParentID: ids.Empty, ValidatorSetRoot: rootEpochB}, Position: VotePosition{ChainID: chainID, Height: 10, Round: 0, BlockID: blockID, ParentID: ids.Empty, ValidatorSetRoot: rootEpochB},
Threshold: certA.Threshold, Threshold: certA.Threshold,
Votes: certA.Votes, // same signatures, different claimed epoch Votes: certA.Votes, // same signatures, different claimed epoch
} }
if err := certLaundered.Verify(vs, certLaundered.Position.Height); err == nil { // Name the clause. Verify's clauses are ordered, so a bare err != nil is
t.Fatal("MEDIUM: a cert re-presented under a DIFFERENT validator-set-root must FAIL verification (cross-epoch laundering)") // satisfied by any earlier structural refusal and stops exercising the
// binding — carrying the real tier is what makes the signature clause run.
if err := certLaundered.Verify(vs, certLaundered.Position.Height); !errors.Is(err, ErrQCSigInvalid) {
t.Fatalf("MEDIUM: a cert re-presented under a DIFFERENT validator-set-root must fail the SIGNATURE clause (cross-epoch laundering), got: %v", err)
} }
// And the canonical messages for the two epochs differ (the binding is real, // And the canonical messages for the two epochs differ (the binding is real,
+16 -23
View File
@@ -1,30 +1,23 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved. // Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms. // See the file LICENSE for licensing terms.
// frontier_rebase_test.go — the ancestry walk must re-test the frontier AFTER a // frontier_rebase_test.go — the ancestry walk re-tests the frontier after a canonical
// canonical rebase. // rebase.
// //
// pathFromTip walks parent links from the certified block down to the finalized tip, // pathFromTip walks parent links from the certified block down to the finalized tip,
// looping while cur != led.tip. When the exact outer envelope a cert names is untracked, // looping while cur != led.tip. A cert may name an outer envelope this node never
// the walk stands a LOCAL canonical-equivalent wrapper in its place and assigns cur = // tracked; when it does, the walk substitutes a local canonical-equivalent wrapper and
// localID. That assignment moves cur mid-body, so the loop condition no longer holds for // assigns cur = localID. That assignment moves cur mid-body, past the loop condition, so
// the value being tested by the checks below it. When the local wrapper IS the finalized // every check below it tests a value the condition was never applied to. If the
// tip, execution reached `curHeight <= led.height` with cur == led.tip and returned // substituted wrapper is itself the finalized tip, the walk falls into
// ErrConflictsWithFinalizedBranch — refusing a cert that extends the frontier by exactly // `curHeight <= led.height` holding cur == led.tip and reports
// one block, and naming the tip itself as the "losing/pruned sibling branch" it descends // ErrConflictsWithFinalizedBranch: a cert that extends the frontier by exactly one block
// from. // is refused, with the tip named as the losing sibling branch it supposedly descends from.
// //
// Observed on lux-testnet luxd-0, which refused every incoming cert at a static tip: // The refusal is self-contradictory — the ancestor reached and the finalized tip are one
// // id — and self-sustaining: refusing the cert leaves the tip where it was, so the next
// incoming cert: REFUSED by finality guard (no VM.Accept) // cert walks the same path to the same refusal. A frontier rebase must therefore re-test
// error="chain: cert-selected block does not extend the finalized frontier (it descends // the loop condition against the substituted value before any check consumes it.
// from a losing/pruned sibling branch): tH96v3tgug2SqQS1uPK6BRo2nUNV6r32SP5RZitUtsQwSHb7Q
// ancestry reaches oYkSNtSbAaYHfQECmHS1P5VZcrcobANbVLLzcKj2xMheztvPU (height 36060)
// not finalized tip oYkSNtSbAaYHfQECmHS1P5VZcrcobANbVLLzcKj2xMheztvPU"
//
// The reached ancestor and the finalized tip are the same id. The refusal is self-
// contradictory, and it is self-sustaining: the tip never moves, so the next cert takes
// the same path, so the validator is stranded permanently.
package chain package chain
@@ -36,8 +29,8 @@ import (
) )
// TestPathFromTip_RebaseOntoTip_Finalizes pins the walk: when the canonical rebase lands // TestPathFromTip_RebaseOntoTip_Finalizes pins the walk: when the canonical rebase lands
// on the finalized tip itself, the path is complete and the cert finalizes. The pre-fix // on the finalized tip itself, the path is complete and the cert finalizes. A walk that
// walk returned ErrConflictsWithFinalizedBranch here. // does not re-test the frontier after the rebase refuses it as a conflicting branch.
func TestPathFromTip_RebaseOntoTip_Finalizes(t *testing.T) { func TestPathFromTip_RebaseOntoTip_Finalizes(t *testing.T) {
const tipHeight = 36060 const tipHeight = 36060
+32 -30
View File
@@ -12,11 +12,11 @@
// peer verify+vote → BroadcastVote ─▶ peer.HandleIncomingVote // peer verify+vote → BroadcastVote ─▶ peer.HandleIncomingVote
// α-of-K reached → GossipCert ─▶ peer.HandleIncomingCert → finalize // α-of-K reached → GossipCert ─▶ peer.HandleIncomingCert → finalize
// //
// Nodes run CONCURRENTLY (one delivery goroutine per node), so finalization is // Nodes run concurrently (one delivery goroutine per node), so finalization is
// EMERGENT: no test hand-feeds a quorum. A test triggers exactly one build and // emergent: no test hand-feeds a quorum. A test triggers exactly one build and
// asserts that the honest majority independently converges on a SINGLE finalized // asserts that the honest majority independently converges on a single finalized
// block per height — the real property the down/wedged/forked-proposer fix must // block per height, which is the property that has to hold whether the odd proposer
// deliver. Run under -race. // is down, wedged or forked. Run under -race.
// //
// Faults are injected at the bus (a node can be made DOWN — dropped from delivery // Faults are injected at the bus (a node can be made DOWN — dropped from delivery
// — or its outbound can be dropped to model a WEDGED-but-present proposer) and at // — or its outbound can be dropped to model a WEDGED-but-present proposer) and at
@@ -148,9 +148,9 @@ func newHonestBlock(parentID ids.ID, parentStateRoot ids.ID, height uint64, payl
} }
// newForkedBlock builds a divergent-execution block: same parent/height/payload as // newForkedBlock builds a divergent-execution block: same parent/height/payload as
// an honest block would, but a TAMPERED state root, so honest execution Verify // an honest block would, but a tampered state root, so honest execution Verify
// rejects it. This models a forked proposer (e.g. mainnet luxd-3) that produces a // rejects it. This models a forked proposer that produces a well-formed wrapper over
// well-formed wrapper over a divergent inner execution. // a divergent inner execution.
func newForkedBlock(parentID ids.ID, parentStateRoot ids.ID, height uint64, payload string) *simBlock { func newForkedBlock(parentID ids.ID, parentStateRoot ids.ID, height uint64, payload string) *simBlock {
b := newHonestBlock(parentID, parentStateRoot, height, payload) b := newHonestBlock(parentID, parentStateRoot, height, payload)
b.stateRoot = hashID("TAMPERED", b.stateRoot[:]) // != expectedStateRoot → Verify fails b.stateRoot = hashID("TAMPERED", b.stateRoot[:]) // != expectedStateRoot → Verify fails
@@ -293,15 +293,15 @@ type simBus struct {
// link, when non-nil, gates delivery on a per-(from,to,kind) basis so a test can // link, when non-nil, gates delivery on a per-(from,to,kind) basis so a test can
// model a transient network PARTITION (a link that drops block/vote gossip between // model a transient network PARTITION (a link that drops block/vote gossip between
// two groups for a window). nil ⇒ every link up (the default full-broadcast bus). // two groups for a window). nil ⇒ every link up (the default full-broadcast bus).
// This is what lets a test reproduce the ASYMMETRIC sibling arrival that splits the // This is what lets a test reproduce asymmetric sibling arrival, where the α-of-K vote
// α-of-K vote across siblings — the production down-proposer + WAN-latency condition // splits across siblings — a down proposer plus wide-area latency, which a uniform
// the broadcast bus (uniform, instant delivery) cannot express. // instant broadcast bus cannot express.
link func(from, to ids.NodeID, kind busMsgKind) bool link func(from, to ids.NodeID, kind busMsgKind) bool
// delay, when non-nil, returns a per-message-per-target delivery latency. It turns // delay, when non-nil, returns a per-message-per-target delivery latency. It turns the
// the uniform instant bus into an ASYNC bus with jitter/reorder — the WAN condition // uniform instant bus into an async bus with jitter and reorder: node A sees B's
// where node A sees B's prevote before C's and every node's round-view advances on a // prevote before C's, and each node's round view advances on its own arrival order.
// DIFFERENT message-arrival order. This is the async-gossip regime the live fleet // Consensus that only converges under uniform delivery stalls here, so the regime is
// stalls in and the synchronous bus (0/200) cannot express. nil ⇒ instant (default). // worth expressing. nil ⇒ instant (default).
delay func() time.Duration delay func() time.Duration
} }
@@ -539,14 +539,15 @@ func newSimNet(t *testing.T, n int, params config.Parameters) *simNet {
VoteSigner: vs.signerFor(i), VoteSigner: vs.signerFor(i),
StakeSource: vs, StakeSource: vs,
ValidatorSetRoot: nil, ValidatorSetRoot: nil,
// Catchup wires the engine's runtime auto-recovery seam (requestCatchup → // Catchup gives the engine its runtime auto-recovery path (requestCatchup →
// RequestAncestors). Without it (nil) a healed/behind node can never re-fetch the // RequestAncestors). With it nil, a healed but behind node can never re-fetch the
// gap — the harness could only prove permanent-down liveness, never REJOIN. The // gap, so the harness could prove liveness while a node stays down but never a
// bus-backed transport models the FIXED node layer (peer-select #3 always reaches a // rejoin. The bus-backed transport models a working node layer: peer selection
// serving peer; have-block-but-unfinalized #5 triggers the fetch; #4 contiguity // reaches a serving peer, holding a block without a cert for it triggers the fetch,
// applied oldest-first) by serving the requester the finalized gap WITH its real // and the gap is applied oldest-first so it stays contiguous. It serves the requester
// certs, so re-convergence is EMERGENT (the node's own engine drives the fetch). // the finalized gap with its real certs, so re-convergence is emergent — the node's
// Inert for the existing permanent-down tests (a down node never calls requestCatchup). // own engine drives the fetch. Inert while a node stays down, since a down node never
// calls requestCatchup.
Catchup: &busCatchup{net: net, selfIdx: i}, Catchup: &busCatchup{net: net, selfIdx: i},
} }
rt := NewRuntime(cfg) rt := NewRuntime(cfg)
@@ -627,7 +628,7 @@ func (net *simNet) headsAtHeight(h uint64) map[ids.ID]int {
// quasarEverywhere reports whether EVERY up node has EXPORTED (reached Quasar) at least to blk's // quasarEverywhere reports whether EVERY up node has EXPORTED (reached Quasar) at least to blk's
// height with blk's canonical as its export tip, and whether any up node exported a DIFFERENT // height with blk's canonical as its export tip, and whether any up node exported a DIFFERENT
// canonical as its export tip (an export fork — THE INVARIANT breach). For a single-block test // canonical as its export tip (an export fork, which breaks the invariant). For a single-block test
// the export tip at blk.height IS blk's canonical, so comparing quasarTip is exact. // the export tip at blk.height IS blk's canonical, so comparing quasarTip is exact.
func (net *simNet) quasarEverywhere(blk *simBlock) (all bool, fork bool) { func (net *simNet) quasarEverywhere(blk *simBlock) (all bool, fork bool) {
all = true all = true
@@ -648,7 +649,7 @@ func (net *simNet) quasarEverywhere(blk *simBlock) (all bool, fork bool) {
} }
// anyQuasarAtHeight reports the distinct EXPORT (Quasar) tips any up node has reached at >= h — // anyQuasarAtHeight reports the distinct EXPORT (Quasar) tips any up node has reached at >= h —
// used to assert that NO two conflicting export tips ever coexist (THE INVARIANT). // used to assert that no two conflicting export tips ever coexist, the export invariant.
// quasarTipHeights reports the exported tips by height, so a liveness failure says how far // quasarTipHeights reports the exported tips by height, so a liveness failure says how far
// the export frontier lagged instead of only printing opaque IDs. // the export frontier lagged instead of only printing opaque IDs.
func (net *simNet) quasarTipHeights() map[uint64]int { func (net *simNet) quasarTipHeights() map[uint64]int {
@@ -692,10 +693,11 @@ func (net *simNet) upCount() int {
} }
// prodParams5 is the production-shaped 5-validator BFT param set: K=5, α=4 // prodParams5 is the production-shaped 5-validator BFT param set: K=5, α=4
// (⌊2·5/3⌋+1). With one validator down/wedged/forked the remaining 4 are the // (⌊2·5/3⌋+1). With one validator down, wedged or forked, the remaining 4 are exactly
// EXACT quorum (zero margin) — the mainnet condition. RoundTO is parked long so // the quorum and no more — the zero-margin case, where losing a single further vote
// the background re-poll ticker does not interfere with the emergent assertion // stops finalization. RoundTO is parked long so the background re-poll ticker does not
// (finalization is driven by real gossip, not the ticker). // interfere with the emergent assertion (finalization is driven by real gossip, not the
// ticker).
func prodParams5() config.Parameters { func prodParams5() config.Parameters {
p := params5() p := params5()
p.K = 5 p.K = 5
+103 -102
View File
@@ -1,28 +1,29 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved. // Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms. // See the file LICENSE for licensing terms.
// multinode_proposer_test.go — the down/wedged/forked-designated-proposer // multinode_proposer_test.go — liveness and safety when the designated proposer
// LIVENESS + SAFETY suite, proven on the REAL multi-node harness // for a height is down, wedged, or forked.
// (multinode_harness_test.go): N independent *Runtime engines wired through an
// in-process gossip bus, finalizing EMERGENTLY from real block gossip, real
// signed vote broadcast, and real α-of-K cert assembly/verify — no synthetic
// quorum is hand-fed.
// //
// The mainnet-unblock property under test: a height's designated proposer is // The suite runs on the multi-node harness (multinode_harness_test.go): N
// DOWN, WEDGED (present but non-productive), or FORKED (emits a divergent- // independent *Runtime engines wired through an in-process gossip bus,
// execution block); a SUBSTITUTE builds the canonical block; and the honest // finalizing from real block gossip, real signed vote broadcast, and real α-of-K
// majority independently converges on that single block. The three faults are // cert assembly and verification. No synthetic quorum is hand-fed, so
// DISTINCT scenarios with distinct assertions. // finalization here is emergent rather than asserted into being.
// //
// SAFETY is co-tested in the SAME harness (the liveness retry must not lower the // The property under test: when a height's designated proposer is down (silent
// BFT threshold): sub-quorum, forged cert, and a post-finalization sibling all // in both directions), wedged (present but non-productive), or forked (emitting
// FAIL to finalize. BFT ≥ avalanche (f < n/3): at K=5, α=4, f=1 the four healthy // a divergent-execution block), a substitute builds the canonical block and the
// validators are the EXACT quorum (zero margin) — the mainnet condition. // honest majority independently converges on that single block. The three faults
// are distinct scenarios with distinct assertions.
// //
// fails-before/passes-after: each test names the exact code revert that turns it // Safety is co-tested in the same harness, because a liveness retry must never
// RED in its doc comment; the mechanisms live in v1.33.0 (never-abandon-own // lower the BFT threshold: a sub-quorum, a forged cert, and a post-finalization
// re-poll, per-height finalize guard, canonical-keyed equivocation) plus the // sibling all fail to finalize. With f < n/3, at K=5, α=4, f=1 the four healthy
// engine.go build-path re-solicit alignment added with this suite. // validators are the exact quorum with zero margin, so the retry has to complete
// that quorum without manufacturing the missing vote.
//
// Each test names the mechanism its assertion rests on, so a reader can see what
// would have to break for the test to stop meaning anything.
package chain package chain
import ( import (
@@ -35,17 +36,18 @@ import (
const emergeTO = 8 * time.Second // generous bound for emergent finalization under -race const emergeTO = 8 * time.Second // generous bound for emergent finalization under -race
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// BASELINE — the harness itself finalizes emergently. If this cannot go green, // Baseline — the harness itself finalizes emergently. If this cannot pass, no
// no fault scenario below is meaningful. // fault scenario below is meaningful.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// TestMultiNode_HealthyProposer_EmergentFinalization: 5 up validators, node 0 is // TestMultiNode_HealthyProposer_EmergentFinalization: 5 up validators, node 0 is
// the proposer. It builds ONE block and solicits; the other four verify, broadcast // the proposer. It builds one block and solicits; the other four verify, broadcast
// signed votes, a cert assembles at α=4, gossips, and ALL FIVE finalize the SAME // signed votes, a cert assembles at α=4, gossips, and all five finalize the same
// block — with no test-injected votes. Proves the emergent vote/cert topology. // block, with no test-injected votes. This is the emergent vote/cert topology
// every scenario below assumes.
// //
// fails-before: revert integration.go followVerifiedBlock's BroadcastVote (so // The assertion rests on followers broadcasting their own votes (integration.go,
// followers never broadcast their votes) → no cert ever assembles → RED. // followVerifiedBlock): with that broadcast absent, no cert can ever assemble.
func TestMultiNode_HealthyProposer_EmergentFinalization(t *testing.T) { func TestMultiNode_HealthyProposer_EmergentFinalization(t *testing.T) {
net := newSimNet(t, 5, prodParams5()) net := newSimNet(t, 5, prodParams5())
blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "healthy-h1") blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "healthy-h1")
@@ -60,25 +62,24 @@ func TestMultiNode_HealthyProposer_EmergentFinalization(t *testing.T) {
t.Fatalf("emergent finalization failed: not all 5 nodes finalized %s at height 1 (heads=%v)", blk.ID(), heads) t.Fatalf("emergent finalization failed: not all 5 nodes finalized %s at height 1 (heads=%v)", blk.ID(), heads)
} }
if heads := net.headsAtHeight(1); len(heads) != 1 { if heads := net.headsAtHeight(1); len(heads) != 1 {
t.Fatalf("SINGLE-HEAD violated: %d distinct heads at height 1: %v", len(heads), heads) t.Fatalf("single head violated: %d distinct heads at height 1: %v", len(heads), heads)
} }
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// GATE: 4/5 with ONE DOWN proposer. The designated proposer (node 0) is DOWN // 4/5 with a down proposer. The designated proposer (node 0) is down — crashed or
// (crashed/partitioned — inbound AND outbound dropped). A substitute (node 1) // partitioned, inbound and outbound both dropped. A substitute (node 1) builds the
// builds the canonical block; the four reachable validators are the EXACT α=4 // canonical block; the four reachable validators are the exact α=4 quorum, zero
// quorum (zero margin) and must finalize WITHOUT the down node. Self-healing: no // margin, and must finalize without the down node: no reboot, no manual step.
// reboot, no manual step.
// //
// fails-before: revert engine.go rePollAllPending's own-proposal exemption (let // Two mechanisms carry this. rePollAllPending exempts a node's own proposal from
// maxRePollAttempts abandon the substitute's undecided own block) AND drop the // maxRePollAttempts, so a substitute never abandons its own undecided block; and
// build-path re-solicit → the 4th honest vote that arrives late is never // the build path re-solicits. At zero margin the fourth honest vote is routinely
// re-solicited → the zero-margin quorum never completes → RED. // the late one, and a quorum that is never re-solicited never completes.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
func TestMultiNode_DownProposer_SubstituteFinalizes(t *testing.T) { func TestMultiNode_DownProposer_SubstituteFinalizes(t *testing.T) {
net := newSimNet(t, 5, prodParams5()) net := newSimNet(t, 5, prodParams5())
net.down(0) // designated proposer is DOWN net.down(0) // the designated proposer is down
sub := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "substitute-h1") sub := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "substitute-h1")
net.build(1, sub) // node 1 substitutes net.build(1, sub) // node 1 substitutes
@@ -88,33 +89,33 @@ func TestMultiNode_DownProposer_SubstituteFinalizes(t *testing.T) {
all, fork := net.finalizedEverywhere(sub) all, fork := net.finalizedEverywhere(sub)
return all && !fork return all && !fork
}) { }) {
t.Fatalf("DOWN-PROPOSER HALT: the 4 reachable validators did not finalize the substitute block %s "+ t.Fatalf("halt under a down proposer: the 4 reachable validators did not finalize the substitute block %s "+
"at the zero-margin α=4 quorum (heads=%v). The chain failed to self-heal past the down proposer.", "at the zero-margin α=4 quorum (heads=%v); the chain failed to self-heal past the down proposer",
sub.ID(), net.headsAtHeight(1)) sub.ID(), net.headsAtHeight(1))
} }
// The down node finalized nothing (it received nothing). // The down node finalized nothing (it received nothing).
if _, ok := net.nodes[0].rt.FinalizedBlockAtHeight(1); ok { if _, ok := net.nodes[0].rt.FinalizedBlockAtHeight(1); ok {
t.Fatal("a DOWN node (all inbound dropped) must not have finalized anything") t.Fatal("a down node (all inbound dropped) must not have finalized anything")
} }
if heads := net.headsAtHeight(1); len(heads) != 1 { if heads := net.headsAtHeight(1); len(heads) != 1 {
t.Fatalf("SINGLE-HEAD violated under a down proposer: %v", heads) t.Fatalf("single head violated under a down proposer: %v", heads)
} }
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// GATE: 4/5 with ONE WEDGED-BUT-PRESENT proposer. Node 0 is the designated // 4/5 with a wedged-but-present proposer. Node 0 is the designated proposer and is
// proposer and is PRESENT (its inbound is live — it receives gossip, tracks, // present — its inbound is live, so it receives gossip, tracks, verifies, and will
// verifies, and will finalize via cert) but WEDGED: its OUTBOUND is silenced, so // finalize via cert — but wedged: its outbound is silenced, so the block it builds
// the block it "builds" never propagates and its votes never reach peers. This is // never propagates and its votes never reach peers. That is what separates wedged
// distinct from DOWN: a wedged-present node still CONVERGES on the canonical block // from down. A wedged-present node still converges on the canonical block through
// (it finalizes via the inbound cert), it just cannot drive its own proposal. // the inbound cert; it simply cannot drive its own proposal.
// //
// The substitute (node 1) builds the canonical block; nodes 1..4 finalize it at // The substitute (node 1) builds the canonical block; nodes 1..4 finalize it at
// α=4; and node 0 ALSO finalizes it (proving "present"), while node 0's wedged // α=4; node 0 finalizes it too, which is what "present" means here, while node 0's
// block finalizes NOWHERE. // own wedged block finalizes nowhere.
// //
// fails-before: same revert as the down gate (own-proposal never-abandon + // Rests on the same two mechanisms as the down case: a node never abandons its own
// build-path re-solicit) → RED. // proposal, and the build path re-solicits.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
func TestMultiNode_WedgedPresentProposer_SubstituteFinalizes(t *testing.T) { func TestMultiNode_WedgedPresentProposer_SubstituteFinalizes(t *testing.T) {
net := newSimNet(t, 5, prodParams5()) net := newSimNet(t, 5, prodParams5())
@@ -135,38 +136,39 @@ func TestMultiNode_WedgedPresentProposer_SubstituteFinalizes(t *testing.T) {
all, fork := net.finalizedEverywhere(canonical) all, fork := net.finalizedEverywhere(canonical)
return all && !fork return all && !fork
}) { }) {
t.Fatalf("WEDGED-PROPOSER HALT: validators did not finalize the substitute's canonical block %s "+ t.Fatalf("halt under a wedged proposer: validators did not finalize the substitute's canonical block %s "+
"past the wedged-but-present proposer (heads=%v).", canonical.ID(), net.headsAtHeight(1)) "past the wedged-but-present proposer (heads=%v)", canonical.ID(), net.headsAtHeight(1))
} }
// "Present": the wedged node finalized the canonical block via inbound cert. // "Present": the wedged node finalized the canonical block via inbound cert.
if got, ok := net.nodes[0].rt.FinalizedBlockAtHeight(1); !ok || got != canonical.ID() { if got, ok := net.nodes[0].rt.FinalizedBlockAtHeight(1); !ok || got != canonical.ID() {
t.Fatalf("a WEDGED-BUT-PRESENT node must still CONVERGE on the canonical block via the inbound cert "+ t.Fatalf("a wedged-but-present node must still converge on the canonical block via the inbound cert "+
"(got %v, ok=%v) — this is what distinguishes it from a down node", got, ok) "(got %v, ok=%v) — this is what distinguishes it from a down node", got, ok)
} }
// The wedged block finalized nowhere. // The wedged block finalized nowhere.
if net.headsAtHeight(1)[wedged.ID()] != 0 { if net.headsAtHeight(1)[wedged.ID()] != 0 {
t.Fatal("the wedged proposer's own (non-propagated) block must finalize NOWHERE") t.Fatal("the wedged proposer's own (non-propagated) block must finalize nowhere")
} }
if heads := net.headsAtHeight(1); len(heads) != 1 { if heads := net.headsAtHeight(1); len(heads) != 1 {
t.Fatalf("SINGLE-HEAD violated under a wedged proposer: %v", heads) t.Fatalf("single head violated under a wedged proposer: %v", heads)
} }
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// GATE: FORKED proposer (the mainnet luxd-3 reality). Node 3 is FORKED: it emits // A forked proposer. Node 3 emits a divergent-execution block — a well-formed
// a divergent-execution block (a well-formed wrapper over a tampered state root) // wrapper over a tampered state root — and actively gossips it to everyone. Every
// and actively gossips it to everyone. Every honest node PARSES it, RE-EXECUTES, // honest node parses it, re-executes it, and rejects it, because its claimed state
// and REJECTS it (its claimed state root != the deterministic execution result) — // root does not equal the deterministic execution result. So it is never tracked,
// so it is never tracked, never voted, never finalized (no early out-of-turn // never voted, never finalized: acceptance is never granted out of turn on the
// acceptance). The substitute (node 1) builds the canonical block, which honest // strength of the wrapper alone. The substitute (node 1) builds the canonical
// nodes finalize. Exactly one head; no double-finalize. // block, which honest nodes finalize. Exactly one head, no double-finalize.
// //
// This is the engine-boundary half of the forked-proposer matrix: verify binds // This is the engine-boundary half of the forked-proposer matrix: verify binds the
// the INNER execution (state root), not just the outer wrapper. The proposervm // inner execution (the state root), not just the outer wrapper. The proposervm
// inner-block-Verify half is proven in the node package. // inner-block Verify half is proven in the node package.
// //
// fails-before: make simBlock.Verify ignore the state root (accept any) → the // The assertion rests on simBlock.Verify binding that state root. Were verify to
// forked block is tracked+voted and can double-finalize → RED. // accept any claimed root, the forked block would be tracked, voted, and could
// double-finalize.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
func TestMultiNode_ForkedProposer_DivergentRejected_CanonicalFinalizes(t *testing.T) { func TestMultiNode_ForkedProposer_DivergentRejected_CanonicalFinalizes(t *testing.T) {
net := newSimNet(t, 5, prodParams5()) net := newSimNet(t, 5, prodParams5())
@@ -183,16 +185,16 @@ func TestMultiNode_ForkedProposer_DivergentRejected_CanonicalFinalizes(t *testin
all, fork := net.finalizedEverywhere(canonical) all, fork := net.finalizedEverywhere(canonical)
return all && !fork return all && !fork
}) { }) {
t.Fatalf("FORKED-PROPOSER HALT: honest validators did not finalize the canonical block %s past the "+ t.Fatalf("halt under a forked proposer: honest validators did not finalize the canonical block %s past the "+
"forked proposer (heads=%v).", canonical.ID(), net.headsAtHeight(1)) "forked proposer (heads=%v)", canonical.ID(), net.headsAtHeight(1))
} }
// The forked (divergent) block must have finalized NOWHERE — not even transiently. // The forked (divergent) block must have finalized nowhere — not even transiently.
if net.headsAtHeight(1)[forked.ID()] != 0 { if net.headsAtHeight(1)[forked.ID()] != 0 {
t.Fatal("SAFETY VIOLATION: a divergent-execution (forked) block finalized somewhere — honest execution " + t.Fatal("safety: a divergent-execution (forked) block finalized somewhere — honest execution " +
"Verify must reject it before any vote (no early out-of-turn acceptance).") "verify must reject it before any vote is cast for it")
} }
if heads := net.headsAtHeight(1); len(heads) != 1 { if heads := net.headsAtHeight(1); len(heads) != 1 {
t.Fatalf("SINGLE-HEAD violated under a forked proposer: %d heads %v", len(heads), heads) t.Fatalf("single head violated under a forked proposer: %d heads %v", len(heads), heads)
} }
// Every honest node's execution rejected the forked block (never tracked toward a cert). // Every honest node's execution rejected the forked block (never tracked toward a cert).
for i, n := range net.nodes { for i, n := range net.nodes {
@@ -206,19 +208,19 @@ func TestMultiNode_ForkedProposer_DivergentRejected_CanonicalFinalizes(t *testin
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// SAFETY: no double-finalize / single head per height / sibling convergence. // Safety: no double-finalize, one head per height, sibling convergence. Height 1
// Height 1 finalizes to block A (canonical). A conflicting sibling B at the SAME // finalizes to block A. A conflicting sibling B at that same height 1 is then built
// height 1 is then built + gossiped by another substitute. Every node has already // and gossiped by another substitute. Every node has already finalized A there, so
// finalized A at height 1, so B is REFUSED everywhere (the per-height guard keyed // B is refused everywhere by the per-height guard keyed on the canonical
// on the canonical commitment) and reported as equivocation — never a second head. // commitment, and reported as equivocation — never as a second head.
// //
// This is the deterministic form of "sibling convergence": once a height is // This is the deterministic form of sibling convergence: once a height is decided
// decided, the network is LOCKED to that head; a late competing sibling cannot // the network is locked to that head, and a late competing sibling cannot fork it,
// fork it, no matter that honest nodes would otherwise vote for any verified block. // however willing honest nodes are to vote for any verified block.
// //
// fails-before: revert the topology.go HandleIncomingCert height gate (`cert. // Two guards carry it: HandleIncomingCert returns early for a cert at or below the
// Position.Height <= fh` early-return) and the FinalizeBranch per-height guard → // finalized height, and FinalizeBranch holds a per-height guard. Without both, B's
// B's cert double-finalizes height 1 → RED. // cert double-finalizes height 1.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
func TestMultiNode_NoDoubleFinalize_LateSiblingRejected(t *testing.T) { func TestMultiNode_NoDoubleFinalize_LateSiblingRejected(t *testing.T) {
net := newSimNet(t, 5, prodParams5()) net := newSimNet(t, 5, prodParams5())
@@ -238,21 +240,21 @@ func TestMultiNode_NoDoubleFinalize_LateSiblingRejected(t *testing.T) {
time.Sleep(1500 * time.Millisecond) time.Sleep(1500 * time.Millisecond)
if net.headsAtHeight(1)[b.ID()] != 0 { if net.headsAtHeight(1)[b.ID()] != 0 {
t.Fatal("SAFETY VIOLATION: a conflicting sibling finalized at an already-decided height (double-finalize/fork).") t.Fatal("safety: a conflicting sibling finalized at an already-decided height (double-finalize/fork)")
} }
if heads := net.headsAtHeight(1); len(heads) != 1 || heads[a.ID()] == 0 { if heads := net.headsAtHeight(1); len(heads) != 1 || heads[a.ID()] == 0 {
t.Fatalf("height 1 must remain LOCKED to branch A on every node; heads=%v", heads) t.Fatalf("height 1 must remain locked to branch A on every node; heads=%v", heads)
} }
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// SAFETY: a genuine SUB-QUORUM never finalizes. Only 3 validators are up (nodes // Safety: a genuine sub-quorum never finalizes. Only nodes 0 and 1 are up and node
// 0,1,2); node 1 builds. 3 < α=4, so no cert can ever assemble no matter how long // 1 builds. Two of five is below NovaQuorum(5)=3, so no cert can assemble however
// the block is re-solicited. Liveness retry re-SOLICITS; it can never manufacture // long the block is re-solicited. A liveness retry re-solicits; it can never
// the 4th vote. // manufacture the missing vote.
// //
// fails-before: lower α below the live-up count (e.g. α=3) → the 3 up nodes // The assertion depends on the threshold itself, not on the retry: were the accept
// finalize → RED (proving the assertion actually depends on the threshold). // count lowered to the live-up count, this same set would finalize.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
func TestMultiNode_BelowMajorityNeverAccepts(t *testing.T) { func TestMultiNode_BelowMajorityNeverAccepts(t *testing.T) {
net := newSimNet(t, 5, prodParams5()) net := newSimNet(t, 5, prodParams5())
@@ -263,11 +265,11 @@ func TestMultiNode_BelowMajorityNeverAccepts(t *testing.T) {
blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "subquorum-h1") blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "subquorum-h1")
net.build(1, blk) net.build(1, blk)
// Re-solicit far past any backoff; a BELOW-MAJORITY online set must STILL never accept. Under // Re-solicit far past any backoff; a below-majority online set must still never accept. The
// v1.36 the accept floor is the MAJORITY (a 3-of-5 majority DOES accept — "survive 3/5"), so // accept floor is the majority, so a 3-of-5 set does accept and the fail-closed floor sits at
// the fail-closed floor moves to 2-of-5: below the majority, no Nova cert, no acceptance. // 2 of 5: below the majority there is no Nova cert and so no acceptance.
if waitFor(2*time.Second, func() bool { _, ok := net.nodes[1].rt.FinalizedBlockAtHeight(1); return ok }) { if waitFor(2*time.Second, func() bool { _, ok := net.nodes[1].rt.FinalizedBlockAtHeight(1); return ok }) {
t.Fatal("SAFETY VIOLATION: a below-majority set (2 of 5, below NovaQuorum=3) accepted — the retry lowered the threshold.") t.Fatal("safety: a below-majority set (2 of 5, below NovaQuorum=3) accepted — the retry lowered the threshold")
} }
if heads := net.headsAtHeight(1); len(heads) != 0 { if heads := net.headsAtHeight(1); len(heads) != 0 {
t.Fatalf("no block may accept below the Nova majority; heads=%v", heads) t.Fatalf("no block may accept below the Nova majority; heads=%v", heads)
@@ -275,11 +277,10 @@ func TestMultiNode_BelowMajorityNeverAccepts(t *testing.T) {
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// SAFETY: sustained liveness across heights (no reset / no DB-wipe path). The // Safety: sustained liveness across heights, with no reset and no state-wipe path.
// network finalizes height 1, then builds and finalizes height 2 ON TOP of it — // The network finalizes height 1, then builds and finalizes height 2 on top of it,
// proving finalization is durable and the chain keeps producing past a fault // so finalization is durable and the chain keeps producing past a fault without
// WITHOUT any reset. Models the self-heal-and-continue property the 7-gate // any reset. A node never needs its state wiped to make progress.
// requires (a node never needs a DB wipe to make progress).
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
func TestMultiNode_SustainedLiveness_TwoHeights(t *testing.T) { func TestMultiNode_SustainedLiveness_TwoHeights(t *testing.T) {
net := newSimNet(t, 5, prodParams5()) net := newSimNet(t, 5, prodParams5())
+143 -140
View File
@@ -1,18 +1,20 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved. // Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms. // See the file LICENSE for licensing terms.
// nova_quasar_matrix_test.go — the v1.36 TWO-TIER adversarial matrix (the go-live gate). // nova_quasar_matrix_test.go — the two-tier adversarial matrix.
// //
// One ⅔ accept threshold is split into two orthogonal tiers on the finality ladder: // A single ⅔ accept threshold is split into two orthogonal tiers on the finality ladder:
// //
// NOVA = LOCAL ACCEPT — NovaQuorum(n)=⌊n/2⌋+1 distinct signers. Drives VM.Accept. // Nova = local accept — NovaQuorum(n)=⌊n/2⌋+1 distinct signers. Drives VM.Accept.
// Crash-fault-safe (majority intersection), NOT Byzantine-safe. // Crash-fault-safe by majority intersection, not Byzantine-safe.
// QUASAR = EXPORT — strict >⅔ of STAKE by distinct signers. Byzantine-fork-safe. // Quasar = export — strictly more than ⅔ of stake by distinct signers, and so
// The ONLY tier bridges / DEX settlement / cross-chain consume. // Byzantine-fork-safe. The only tier bridges, DEX settlement and
// cross-chain consumers may read.
// //
// THE INVARIANT (asserted throughout): across every scenario below, no two CONFLICTING // The invariant asserted throughout: across every scenario below, no two conflicting
// blocks ever both reach QUASAR at one height. Nova may fork under equivocation (expected, // blocks both reach Quasar at one height. Nova may fork under equivocation — that is
// non-exportable); Quasar may not (that needs >⅓ stake double-signing, beyond f<⅓). // expected, and such a fork is not exportable. Quasar may not, since a second export cert
// would take more than ⅓ of stake double-signing, which is beyond the f<⅓ fault bound.
package chain package chain
import ( import (
@@ -35,11 +37,11 @@ const exportTO = 25 * time.Second
// latency exceed any bounded convergence settle window — a condition that never occurs on a // latency exceed any bounded convergence settle window — a condition that never occurs on a
// real network (gossip ms, settle hundreds of ms) — and also stresses the FIPS-140 ed25519 // real network (gossip ms, settle hundreds of ms) — and also stresses the FIPS-140 ed25519
// module's concurrent Sign/Verify (all crypto/ed25519 routes through it in Go 1.26) shared by // module's concurrent Sign/Verify (all crypto/ed25519 routes through it in Go 1.26) shared by
// the convergence goroutine and the vote handlers. The TWO-TIER SAFETY invariants asserted here // the convergence goroutine and the vote handlers. The two-tier safety invariants asserted
// (Nova at the majority, Quasar only at ⅔, no two conflicting Quasars, export gating) are ALSO // here — Nova at the majority, Quasar only at ⅔, no two conflicting Quasars, export gating —
// asserted by the SINGLE-ENGINE matrix tests (Threshold / WeightedStake / SingleValidator / // are also asserted by the single-engine matrix tests (Threshold, WeightedStake,
// Equivocation / DegradedRPC), which are deterministic and DO run under -race. Mirrors the // SingleValidator, Equivocation, DegradedRPC), which are deterministic and do run under
// convergence-storm gate discipline in race_flag_race_test.go. // -race. Mirrors the convergence-storm discipline in race_flag_race_test.go.
func skipMultinodeUnderRace(t *testing.T) { func skipMultinodeUnderRace(t *testing.T) {
t.Helper() t.Helper()
if underRace { if underRace {
@@ -70,7 +72,7 @@ func nMatrixParams(n int) config.Parameters {
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// (e) n ∈ {2..5} weighted+equal: Nova at majority; Quasar only at strict >⅔ stake. // n ∈ {2..5}, weighted and equal: Nova at the majority, Quasar only at strict >⅔ stake.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// TestNovaQuasarMatrix_Threshold_NovaMajority_QuasarTwoThirds is the core threshold ladder for // TestNovaQuasarMatrix_Threshold_NovaMajority_QuasarTwoThirds is the core threshold ladder for
@@ -108,8 +110,8 @@ func TestNovaQuasarMatrix_Threshold_NovaMajority_QuasarTwoThirds(t *testing.T) {
} }
// TestNovaQuasarMatrix_WeightedStake_NovaMajorityQuasarSupermajority proves the tiers are // TestNovaQuasarMatrix_WeightedStake_NovaMajorityQuasarSupermajority proves the tiers are
// orthogonal on the stake axis and that both read the SAME unit: Nova ignites on a bare MAJORITY // orthogonal on the stake axis and that both read the same unit: Nova ignites on a bare majority
// of stake, Quasar on a ⅔ SUPERMAJORITY of it. A four-node head-count holding 40% clears neither; // of stake, Quasar on a ⅔ supermajority of it. A four-node head-count holding 40% clears neither;
// the 60% holder's vote clears both, and because it trails, it arrives as a late attestation. // the 60% holder's vote clears both, and because it trails, it arrives as a late attestation.
func TestNovaQuasarMatrix_WeightedStake_NovaMajorityQuasarSupermajority(t *testing.T) { func TestNovaQuasarMatrix_WeightedStake_NovaMajorityQuasarSupermajority(t *testing.T) {
vs := newTestValidatorSet(5) vs := newTestValidatorSet(5)
@@ -119,7 +121,7 @@ func TestNovaQuasarMatrix_WeightedStake_NovaMajorityQuasarSupermajority(t *testi
e, chainID := newQuorumEngineOpts(t, dyn5(), vs, 0, rec, WithStakeWeighting(skew)) e, chainID := newQuorumEngineOpts(t, dyn5(), vs, 0, rec, WithStakeWeighting(skew))
blk := newTestBlock(1, ids.Empty, "weighted") blk := newTestBlock(1, ids.Empty, "weighted")
// Track as a NON-own block so node 0 (the 60% holder) does NOT auto-vote — the accepting // Track it as a block node 0 did not propose, so the 60% holder does not auto-vote — the accepting
// coalition is exactly the four 10%-stake nodes. // coalition is exactly the four 10%-stake nodes.
cb := &Block{id: blk.id, parentID: blk.parentID, height: blk.height, timestamp: blk.timestamp.Unix(), data: blk.bytes} cb := &Block{id: blk.id, parentID: blk.parentID, height: blk.height, timestamp: blk.timestamp.Unix(), data: blk.bytes}
_ = e.consensus.AddBlock(context.Background(), cb) _ = e.consensus.AddBlock(context.Background(), cb)
@@ -132,9 +134,9 @@ func TestNovaQuasarMatrix_WeightedStake_NovaMajorityQuasarSupermajority(t *testi
for _, i := range []int{1, 2, 3, 4} { for _, i := range []int{1, 2, 3, 4} {
e.ReceiveVote(vs.signedVote(i, pos)) e.ReceiveVote(vs.signedVote(i, pos))
} }
// NOVA: 40% is not a majority of stake — a head-count of four does not accept. // Nova: 40% is not a majority of stake — a head-count of four does not accept.
mustNotFinalize(t, e, blk, 2*time.Second, "count majority / 40% stake → no Nova accept") mustNotFinalize(t, e, blk, 2*time.Second, "count majority / 40% stake → no Nova accept")
// QUASAR: 40% stake < ⅔ — no export either. // Quasar: 40% of stake is below ⅔ — no export either.
mustNotQuasar(t, e, blk, 400*time.Millisecond, "40% stake → no export") mustNotQuasar(t, e, blk, 400*time.Millisecond, "40% stake → no export")
// The 60%-stake holder votes (late). 40%+60% = 100% ⇒ Nova ignites and Quasar exports. // The 60%-stake holder votes (late). 40%+60% = 100% ⇒ Nova ignites and Quasar exports.
@@ -172,14 +174,14 @@ func TestNovaQuasarMatrix_SingleValidatorSelfIgnites(t *testing.T) {
func chainID(e *Transitive) ids.ID { return e.chainID } func chainID(e *Transitive) ids.ID { return e.chainID }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// (a)(b)(d) multinode: 3/5 produces Nova, Quasar only at ⅔, export gates on Quasar. // Multinode: 3/5 produces Nova, Quasar only at ⅔, and export reads the Quasar tier.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// TestNovaQuasarMatrix_ThreeOfFive_NovaNoQuasar is gate (a): with 2 of 5 validators DOWN, the 3 // TestNovaQuasarMatrix_ThreeOfFive_NovaNoQuasar: with 2 of 5 validators down, the 3 live
// live validators PRODUCE (Nova accepts + converges everywhere), but the export tier does NOT // validators produce — Nova accepts and converges everywhere — but the export tier does not
// advance (3/5 = 60% stake ≤ ⅔). Gate (d): the Quasar tip stays Empty (export consumers see no // advance, because 3/5 is 60% of stake and that is not more than ⅔. The Quasar tip therefore
// finalized block) even though the Nova/accept tip has advanced. This is the owner's mandate: // stays Empty, so an export consumer sees no finalized block, even though the Nova accept tip
// "survive 3/5, majority is enough — production continues, certification pauses." // has advanced. A majority is enough to keep producing; certification pauses until ⅔ attest.
func TestNovaQuasarMatrix_ThreeOfFive_NovaNoQuasar(t *testing.T) { func TestNovaQuasarMatrix_ThreeOfFive_NovaNoQuasar(t *testing.T) {
skipMultinodeUnderRace(t) skipMultinodeUnderRace(t)
net := newSimNet(t, 5, prodParams5()) net := newSimNet(t, 5, prodParams5())
@@ -194,9 +196,9 @@ func TestNovaQuasarMatrix_ThreeOfFive_NovaNoQuasar(t *testing.T) {
all, fork := net.finalizedEverywhere(blk) all, fork := net.finalizedEverywhere(blk)
return all && !fork return all && !fork
}) { }) {
t.Fatalf("NOVA LIVENESS: 3-of-5 must accept+converge %s, heads=%v up=%d", blk.ID(), net.headsAtHeight(1), net.upCount()) t.Fatalf("nova liveness: 3-of-5 must accept and converge on %s, heads=%v up=%d", blk.ID(), net.headsAtHeight(1), net.upCount())
} }
// QUASAR: certification pauses — no up node exports, and the export tip stays Empty. // Quasar: certification pauses — no up node exports, and the export tip stays Empty.
if waitFor(2*time.Second, func() bool { if waitFor(2*time.Second, func() bool {
for _, n := range net.nodes { for _, n := range net.nodes {
if !n.reachable() { if !n.reachable() {
@@ -208,24 +210,24 @@ func TestNovaQuasarMatrix_ThreeOfFive_NovaNoQuasar(t *testing.T) {
} }
return false return false
}) { }) {
t.Fatal("EXPORT SAFETY: 3-of-5 (60% stake ≤ ⅔) must NOT reach Quasar — certification must pause") t.Fatal("export safety: 3-of-5 (60% stake, not more than ⅔) must not reach Quasar — certification must pause")
} }
// Gate (d): the export tip a bridge/DEX reads is Empty; the Nova/accept tip is NOT. // The export tip a bridge or DEX reads is Empty; the Nova accept tip is not.
for i, n := range net.nodes { for i, n := range net.nodes {
if !n.reachable() { if !n.reachable() {
continue continue
} }
if tip := n.quasarTip(); tip != ids.Empty { if tip := n.quasarTip(); tip != ids.Empty {
t.Fatalf("node %d exposes a non-empty export (Quasar) tip %s under 3/5 — export must gate on Quasar", i, tip) t.Fatalf("node %d exposes a non-empty export (Quasar) tip %s under 3/5 — export must read the Quasar tier", i, tip)
} }
if n.novaTip() == ids.Empty { if n.novaTip() == ids.Empty {
t.Fatalf("node %d has an empty Nova/accept tip under 3/5 — production must have continued", i) t.Fatalf("node %d has an empty Nova accept tip under 3/5 — production must have continued", i)
} }
} }
} }
// TestNovaQuasarMatrix_FourOfFive_ExportsQuasar is gate (b): with 4 of 5 up (80% stake > ⅔), the // TestNovaQuasarMatrix_FourOfFive_ExportsQuasar: with 4 of 5 up (80% of stake, more than ⅔),
// block reaches Quasar and every up node exports the SAME block (no export fork). // the block reaches Quasar and every up node exports the same block — no export fork.
func TestNovaQuasarMatrix_FourOfFive_ExportsQuasar(t *testing.T) { func TestNovaQuasarMatrix_FourOfFive_ExportsQuasar(t *testing.T) {
skipMultinodeUnderRace(t) skipMultinodeUnderRace(t)
net := newSimNet(t, 5, prodParams5()) net := newSimNet(t, 5, prodParams5())
@@ -234,39 +236,39 @@ func TestNovaQuasarMatrix_FourOfFive_ExportsQuasar(t *testing.T) {
blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "four-of-five-quasar") blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "four-of-five-quasar")
net.build(0, blk) net.build(0, blk)
// NOVA accept converges. // Nova accept converges.
if !waitFor(emergeTO, func() bool { if !waitFor(emergeTO, func() bool {
all, fork := net.finalizedEverywhere(blk) all, fork := net.finalizedEverywhere(blk)
return all && !fork return all && !fork
}) { }) {
t.Fatalf("NOVA LIVENESS: 4-of-5 must accept+converge %s, heads=%v", blk.ID(), net.headsAtHeight(1)) t.Fatalf("nova liveness: 4-of-5 must accept and converge on %s, heads=%v", blk.ID(), net.headsAtHeight(1))
} }
// QUASAR export FORMS: 4-of-5 (80% stake > ⅔) reaches the export tier. // The Quasar export forms: 4-of-5 (80% of stake, more than ⅔) reaches the export tier.
// //
// The guarantee is at the FRONTIER, not per-block, and the test must assert the one the // The guarantee lives at the FRONTIER, not per block. promoteQuasar leaves a block whose own
// design actually makes. promoteQuasar: a block whose Nova cert did not itself carry ⅔ // Nova cert did not itself carry ⅔ of stake as Nova-only; a later fully-voted block then
// stake stays Nova-only, and "a later fully-voted block advances the export frontier past // carries the export frontier past it, and a Quasar tip finalizes all of its ancestors, so
// the gap (a Quasar tip finalizes all its ancestors), so no per-block retroactive // no retroactive per-block promotion is needed for liveness.
// promotion is needed for liveness."
// //
// Demanding that THIS block export, while building no successor, asserted the stronger // Which is why this waits on the frontier rather than on this one block. The threshold that
// per-block property. The relevant threshold is NOT α: assembleCertLocked freezes the Nova // matters here is not α: assembleCertLocked freezes the Nova cert at NovaQuorum(5)=3 verified
// cert at NovaQuorum(5)=3 verified votes, and α plays no part in cert assembly at all — // votes, and α plays no part in cert assembly at all. That is worth stating, because
// which matters because prodParams5 sets α=4 and twoThirdsCount(5) is also 4, so "completed // prodParams5 sets α=4 and twoThirdsCount(5) is also 4, so reading α as the export gate makes
// on the bare α without ⅔" would be arithmetically impossible and sends the next reader down // "a cert completed on the bare α without ⅔" look arithmetically impossible and leads a
// a dead end. A cert that freezes at 3 has 60% of stake, below the ⅔ export floor, so that // reader nowhere. A cert frozen at 3 carries 60% of stake, below the ⅔ export floor, so that
// block never exports and the frontier has nothing to ride. Driving successive heights is not // block never exports on its own and the frontier needs a successor to ride. Driving
// a weaker assertion: the frontier advancing past height 1 finalizes height 1 with it, which // successive heights is not the weaker claim: the frontier advancing past height 1 finalizes
// is exactly what an export consumer (bridge, DEX settlement) observes. // height 1 along with it, which is exactly what an export consumer — a bridge, DEX settlement
// — observes.
ok, chain := waitForExportFrontier(net, blk, exportTO) ok, chain := waitForExportFrontier(net, blk, exportTO)
if !ok { if !ok {
t.Fatalf("EXPORT LIVENESS: 4-of-5 (80%% stake > ⅔) must export %s, tips=%v", blk.ID().String(), net.quasarTips()) t.Fatalf("export liveness: 4-of-5 (80%% stake > ⅔) must export %s, tips=%v", blk.ID().String(), net.quasarTips())
} }
// THE INVARIANT: no two CONFLICTING blocks both reach Quasar at one height. Assert it AT THE // The invariant: no two conflicting blocks both reach Quasar at one height. Assert it at the
// HEIGHT, not at the tip. A Quasar tip finalizes all its ancestors, so once the frontier is // HEIGHT, not at the tip. A Quasar tip finalizes all its ancestors, so once the frontier is
// above blk the tip is legitimately a DESCENDANT of blk — comparing the tip to blk would // above blk the tip is legitimately a descendant of blk, and comparing the tip to blk would
// flag honest frontier progress as a fork. What must never happen is a node exporting a // flag honest frontier progress as a fork. What must not happen is a node exporting a
// DIFFERENT canonical at blk's own height. // different canonical at blk's own height.
for i, n := range net.nodes { for i, n := range net.nodes {
if !n.reachable() { if !n.reachable() {
continue continue
@@ -275,43 +277,43 @@ func TestNovaQuasarMatrix_FourOfFive_ExportsQuasar(t *testing.T) {
if !exported || qh < blk.height { if !exported || qh < blk.height {
continue // has not exported through blk's height yet — nothing to contradict continue // has not exported through blk's height yet — nothing to contradict
} }
// (a) the frontier itself is ON the canonical line. quasarFrontier is a SINGLE // First, the frontier itself is on the canonical line. quasarFrontier is a single
// {height, canonical, envelope} with no per-height index, and PromoteQuasar is not its // {height, canonical, envelope} with no per-height index, and PromoteQuasar is not its
// only writer — SyncQuasarFrontier seeds it from the VM's durable export record with no // only writer: SyncQuasarFrontier seeds it from the VM's durable export record with no
// Nova-ledger check and will accept an empty canonical. So a height above blk proves // Nova-ledger check, and will accept an empty canonical. So a height above blk proves
// nothing about WHAT was exported; read the tip directly. // nothing about what was exported; read the tip directly.
// No ids.Empty exemption. A node reporting an export height at or above blk with an EMPTY // There is no ids.Empty exemption. A node reporting an export height at or above blk with
// canonical is the precise shape SyncQuasarFrontier can seed, and excusing it here would // an empty canonical is the precise shape SyncQuasarFrontier can seed, and excusing it
// skip the one case this check cites as its reason to exist. // here would skip the one case this check exists for.
if tip := n.quasarTip(); !chain[tip] { if tip := n.quasarTip(); !chain[tip] {
t.Fatalf("INVARIANT: node %d reports export height >= %d but its tip %s is NOT on blk's "+ t.Fatalf("invariant: node %d reports export height >= %d but its tip %s is not on blk's "+
"canonical line (ids.Empty means the frontier advanced without naming a canonical)", "canonical line (ids.Empty means the frontier advanced without naming a canonical)",
i, blk.height, tip) i, blk.height, tip)
} }
// (b) and nothing conflicting was certified at blk's own height. A Quasar tip finalizes // Second, nothing conflicting was certified at blk's own height. A Quasar tip finalizes
// its ancestors, so this is where "no two conflicting Quasars at one height" is readable. // its ancestors, so this is where "no two conflicting Quasars at one height" is readable.
got, held := n.rt.FinalizedBlockAtHeight(blk.height) got, held := n.rt.FinalizedBlockAtHeight(blk.height)
if !held { if !held {
t.Fatalf("INVARIANT: node %d exported through height %d but holds no finalized block at %d", t.Fatalf("invariant: node %d exported through height %d but holds no finalized block at %d",
i, qh, blk.height) i, qh, blk.height)
} }
if got != blk.ID() { if got != blk.ID() {
t.Fatalf("INVARIANT: node %d exported a CONFLICTING canonical %s at height %d (want %s)", t.Fatalf("invariant: node %d exported a conflicting canonical %s at height %d (want %s)",
i, got, blk.height, blk.ID()) i, got, blk.height, blk.ID())
} }
} }
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// (f) 3/5 partition, BOTH sides: majority side produces Nova; neither exports Quasar; // A 3/5 partition, seen from both sides: the majority side produces Nova, neither side
// on heal a single Quasar chain (no two conflicting Quasars). // exports Quasar, and the heal yields a single Quasar chain.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// TestNovaQuasarMatrix_Partition_MajorityProducesNeitherExports splits a 5-net into a 3-side // TestNovaQuasarMatrix_Partition_MajorityProducesNeitherExports splits a 5-net into a 3-side
// {0,1,2} and a 2-side {3,4}. The majority side PRODUCES (Nova advances); NEITHER side reaches // {0,1,2} and a 2-side {3,4}. The majority side produces — Nova advances — and neither side
// Quasar (3/5 = 60% ≤ ⅔ on one side, 2/5 below even the Nova majority on the other). On heal, // reaches Quasar: 3/5 is 60% of stake, not more than ⅔, and 2/5 is below even the Nova
// once all 5 are up a fresh block reaches Quasar — a SINGLE export chain, never two conflicting // majority. On heal, with all 5 up, a fresh block reaches Quasar on a single export chain,
// Quasars. // never two conflicting ones.
func TestNovaQuasarMatrix_Partition_MajorityProducesNeitherExports(t *testing.T) { func TestNovaQuasarMatrix_Partition_MajorityProducesNeitherExports(t *testing.T) {
skipMultinodeUnderRace(t) skipMultinodeUnderRace(t)
net := newSimNet(t, 5, prodParams5()) net := newSimNet(t, 5, prodParams5())
@@ -325,16 +327,16 @@ func TestNovaQuasarMatrix_Partition_MajorityProducesNeitherExports(t *testing.T)
all, fork := net.finalizedEverywhere(blk1) all, fork := net.finalizedEverywhere(blk1)
return all && !fork return all && !fork
}) { }) {
t.Fatalf("majority side must PRODUCE Nova during the partition, heads=%v", net.headsAtHeight(1)) t.Fatalf("the majority side must produce Nova during the partition, heads=%v", net.headsAtHeight(1))
} }
// Neither side exports (the 3-side holds 60% ≤ ⅔). // Neither side exports (the 3-side holds 60%, not more than ⅔).
if tips := net.quasarTips(); len(tips) != 0 { if tips := net.quasarTips(); len(tips) != 0 {
t.Fatalf("no side may export during the partition (neither holds ⅔), got export tips=%v", tips) t.Fatalf("no side may export during the partition (neither holds ⅔), got export tips=%v", tips)
} }
// HEAL: bring {3,4} back. They missed height 1, so the first post-heal blocks pull them up via // Heal: bring {3,4} back. They missed height 1, so the first post-heal blocks pull them up via
// catch-up (fetch the missing ancestor). Build a short run so the fleet re-converges and the // catch-up (fetch the missing ancestor). Build a short run so the fleet re-converges and the
// 4/5-of-5 supermajority forms an export cert on the SINGLE chain. // supermajority forms an export cert on the single chain.
net.nodes[3].setUp(true) net.nodes[3].setUp(true)
net.nodes[4].setUp(true) net.nodes[4].setUp(true)
parentID := blk1.ID() parentID := blk1.ID()
@@ -343,11 +345,10 @@ func TestNovaQuasarMatrix_Partition_MajorityProducesNeitherExports(t *testing.T)
// can be stated as "the tip is ON this chain" instead of pinning it to one ID. // can be stated as "the tip is ON this chain" instead of pinning it to one ID.
chain := map[ids.ID]bool{blk1.ID(): true} chain := map[ids.ID]bool{blk1.ID(): true}
// target is the FIRST post-heal block. Export lags production by a variable number of // target is the FIRST post-heal block. Export lags production by a variable number of
// blocks, so pinning the requirement to the NEWEST one asserted a timing detail rather // blocks, so requiring the NEWEST height to export asserts a timing detail rather than the
// than the claim above it — it left nothing built behind the frontier and failed ~12% of // claim above it, and leaves nothing built behind the frontier for the frontier to ride.
// the time whenever any companion test ran (fleet at h3, newest h4, assert waited out // "Export resumes" is proven by any post-heal height exporting, with the later blocks as
// exportTO). "Export RESUMES" is proven by any post-heal height exporting, with the two // slack; that no side exported during the partition is asserted above.
// later blocks as slack; that no side exported DURING the partition is asserted above.
var target *simBlock var target *simBlock
// exported: has any reachable node exported a POST-HEAL height yet? // exported: has any reachable node exported a POST-HEAL height yet?
exported := func() bool { exported := func() bool {
@@ -364,16 +365,13 @@ func TestNovaQuasarMatrix_Partition_MajorityProducesNeitherExports(t *testing.T)
} }
return false return false
} }
// Keep PRODUCING until export resumes. Export needs a fresh ⅔ poll, and the two healed // Keep producing until export resumes. Export needs a fresh ⅔ poll, and the two healed nodes
// nodes may still have been catching up during the last one — leaving 3 of 5 voting, which // may still have been catching up during the last one, leaving 3 of 5 voting — below ⅔.
// is below ⅔. Waiting cannot conjure a new poll, so a passive wait after the final block // Waiting cannot conjure a new poll: export only ever advances on a NEW fully-voted block,
// could never recover and simply timed out (~12% of runs, tipHeights=map[] — nothing // since promoteQuasar does no retroactive promotion. So a passive wait after the final block
// exported at all). A real chain resumes export as blocks arrive; so does this one. // waits for an event that, by construction, nothing can now cause. A real chain resumes
// Drive heights until the export frontier moves, bounded by exportTO rather than by an // export as blocks arrive, and so does this one: drive heights until the export frontier
// arbitrary height cap. The cap plus a trailing non-driving waitFor was the defect: if the // moves, bounded by exportTO rather than by a height cap.
// frontier had not moved by height 8, the test then sat for 25s building NOTHING, and export
// only ever advances on a NEW fully-voted block (promoteQuasar does no retroactive
// promotion). It was waiting for an event that, by construction, nothing could now cause.
exportDeadline := time.Now().Add(exportTO) exportDeadline := time.Now().Add(exportTO)
for h := uint64(2); !exported() && time.Now().Before(exportDeadline); h++ { for h := uint64(2); !exported() && time.Now().Before(exportDeadline); h++ {
blk := newHonestBlock(parentID, parentStateRoot, h, "partition-heal-h") blk := newHonestBlock(parentID, parentStateRoot, h, "partition-heal-h")
@@ -382,7 +380,7 @@ func TestNovaQuasarMatrix_Partition_MajorityProducesNeitherExports(t *testing.T)
all, fork := net.finalizedEverywhere(blk) all, fork := net.finalizedEverywhere(blk)
return all && !fork return all && !fork
}) { }) {
t.Fatalf("after heal, height %d must re-converge (Nova) across the healed fleet, heads=%v", h, net.headsAtHeight(h)) t.Fatalf("after the heal, height %d must re-converge (Nova) across the healed fleet, heads=%v", h, net.headsAtHeight(h))
} }
parentID = blk.ID() parentID = blk.ID()
parentStateRoot = blk.stateRoot parentStateRoot = blk.stateRoot
@@ -391,40 +389,43 @@ func TestNovaQuasarMatrix_Partition_MajorityProducesNeitherExports(t *testing.T)
target = blk target = blk
} }
} }
// A post-heal block reaches Quasar (all 5 up ⇒ 80%+ stake > ⅔): export RESUMES after the // A post-heal block reaches Quasar (all 5 up, so more than 80% of stake, above ⅔): export
// partition healed. (Liveness; the no-conflicting-Quasar INVARIANT is proven deterministically // resumes once the partition heals. This is the liveness half; the no-conflicting-Quasar
// by TestNovaQuasarMatrix_Equivocation_TwoNovaNeverTwoQuasar.) // invariant is proven deterministically by
// TestNovaQuasarMatrix_Equivocation_TwoNovaNeverTwoQuasar.
if !exported() { if !exported() {
wantH := uint64(2) wantH := uint64(2)
if target != nil { if target != nil {
wantH = target.height wantH = target.height
} }
t.Fatalf("after heal, the fleet must export a post-heal block (want height >= %d), tipHeights=%v tips=%v", t.Fatalf("after the heal, the fleet must export a post-heal block (want height >= %d), tipHeights=%v tips=%v",
wantH, net.quasarTipHeights(), net.quasarTips()) wantH, net.quasarTipHeights(), net.quasarTips())
} }
// No CONFLICTING export tip anywhere — the export frontier is a single chain across the heal. // No conflicting export tip anywhere — the export frontier is a single chain across the heal.
for i, n := range net.nodes { for i, n := range net.nodes {
if !n.reachable() { if !n.reachable() {
continue continue
} }
if qh, ok := n.quasarHeight(); ok && qh >= target.height { if qh, ok := n.quasarHeight(); ok && qh >= target.height {
if tip := n.quasarTip(); tip != ids.Empty && !chain[tip] { if tip := n.quasarTip(); tip != ids.Empty && !chain[tip] {
t.Fatalf("INVARIANT after heal: node %d exported a CONFLICTING tip %s (not on the canonical chain)", i, tip) t.Fatalf("invariant after the heal: node %d exported a conflicting tip %s (not on the canonical chain)", i, tip)
} }
} }
} }
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// (h) behind-node / restart (the luxd-4 case): a NON-EVENT — majority keeps producing // One node behind or restarting is a non-event: the majority keeps producing Nova, the
// Nova; the rejoining node catches up; export resumes. No stall. // rejoining node catches up, and export resumes. No stall.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// TestNovaQuasarMatrix_BehindNodeRejoin_NonEvent kills one of five nodes, proves the remaining 4 // TestNovaQuasarMatrix_BehindNodeRejoin_NonEvent takes one of five nodes down, proves the
// keep PRODUCING (Nova) across several heights (the majority never stalls), then brings the node // remaining 4 keep producing Nova across several heights so the majority never stalls, then
// back and proves it CATCHES UP to the tip AND the fleet resumes EXPORTING (Quasar) — the // brings the node back and proves it catches up to the tip while the fleet keeps exporting.
// pulling-luxd-4 scenario is a non-event. (That 4-of-5 exports during the outage is proven by // Taking one node out of a five-node set is a non-event by construction, and this is what that
// TestNovaQuasarMatrix_FourOfFive_ExportsQuasar; here the focus is Nova production + rejoin.) // costs in practice. (That 4-of-5 exports while the node is away is proven by
// TestNovaQuasarMatrix_FourOfFive_ExportsQuasar; here the subject is Nova production and the
// rejoin.)
func TestNovaQuasarMatrix_BehindNodeRejoin_NonEvent(t *testing.T) { func TestNovaQuasarMatrix_BehindNodeRejoin_NonEvent(t *testing.T) {
skipMultinodeUnderRace(t) skipMultinodeUnderRace(t)
net := newSimNet(t, 5, prodParams5Fast()) net := newSimNet(t, 5, prodParams5Fast())
@@ -441,15 +442,15 @@ func TestNovaQuasarMatrix_BehindNodeRejoin_NonEvent(t *testing.T) {
all, fork := net.finalizedEverywhere(blk) all, fork := net.finalizedEverywhere(blk)
return all && !fork return all && !fork
}) { }) {
t.Fatalf("NOVA HALT at height %d with node 4 down (4-of-5 up) — the majority must keep producing, heads=%v", h, net.headsAtHeight(h)) t.Fatalf("nova halted at height %d with node 4 down (4-of-5 up) — the majority must keep producing, heads=%v", h, net.headsAtHeight(h))
} }
parentID = blk.ID() parentID = blk.ID()
parentStateRoot = blk.stateRoot parentStateRoot = blk.stateRoot
last = blk last = blk
} }
// REJOIN: node 4 comes back and must CATCH UP to the tip (the fetch-missing-ancestor path), // Rejoin: node 4 comes back and must catch up to the tip via the fetch-missing-ancestor path,
// finalizing the block the fleet already accepted — no stall. // finalizing the block the fleet already accepted — with no stall.
net.nodes[4].setUp(true) net.nodes[4].setUp(true)
// Nudge production so gossip/catch-up flows to node 4. // Nudge production so gossip/catch-up flows to node 4.
next := newHonestBlock(last.ID(), last.stateRoot, heights+1, "rejoin-after") next := newHonestBlock(last.ID(), last.stateRoot, heights+1, "rejoin-after")
@@ -459,25 +460,26 @@ func TestNovaQuasarMatrix_BehindNodeRejoin_NonEvent(t *testing.T) {
return ok && got == last.ID() return ok && got == last.ID()
}) { }) {
gotTip, gotH, _ := net.nodes[4].finalizedTip() gotTip, gotH, _ := net.nodes[4].finalizedTip()
t.Fatalf("REJOIN STALL: node 4 did not catch up to height %d block %s after rejoining (its tip=%s@%d) — "+ t.Fatalf("rejoin stalled: node 4 did not catch up to height %d block %s after rejoining (its tip=%s@%d) — "+
"the behind-node rejoin must be a non-event", last.height, last.ID(), gotTip, gotH) "a behind-node rejoin must be a non-event", last.height, last.ID(), gotTip, gotH)
} }
// The rejoin is a NON-EVENT: Nova never stalled while node 4 was down, and node 4 caught up on // The rejoin is a non-event: Nova never stalled while node 4 was down, and node 4 caught up on
// return. Export RESUMPTION at ⅔ and the no-two-conflicting-Quasars INVARIANT are proven // return. Export resumption at ⅔ and the no-two-conflicting-Quasars invariant are proven
// (deterministically, -race-clean) by TestNovaQuasarMatrix_FourOfFive_ExportsQuasar and // deterministically, and -race-clean, by TestNovaQuasarMatrix_FourOfFive_ExportsQuasar and
// TestNovaQuasarMatrix_Equivocation_TwoNovaNeverTwoQuasar. // TestNovaQuasarMatrix_Equivocation_TwoNovaNeverTwoQuasar.
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// (c)(g) THE INVARIANT under equivocation: two Nova certs possible, NEVER two Quasars. // The invariant under equivocation: two Nova certs are possible, two Quasars are not.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// TestNovaQuasarMatrix_Equivocation_TwoNovaNeverTwoQuasar constructs the equivocation at the cert // TestNovaQuasarMatrix_Equivocation_TwoNovaNeverTwoQuasar constructs the equivocation at the cert
// layer (the deterministic form of the storm): two conflicting blocks A and B at one height. With // layer — the deterministic form of the storm — with two conflicting blocks A and B at one
// k equivocators, a second NOVA (bare-majority) cert for B is reachable at a LOWER k than a second // height. With k equivocators, a second Nova (bare-majority) cert for B is reachable at a lower k
// QUASAR (⅔) cert — Nova is crash-fault-safe only, so a second Nova cert under equivocation is // than a second Quasar (⅔) cert: Nova is crash-fault-safe only, so a second Nova cert under
// EXPECTED and non-exportable. THE INVARIANT: a second QUASAR cert requires k ≥ 2α−n = >⅓ stake // equivocation is expected, and it is not exportable. The invariant: a second Quasar cert takes
// double-signing, so below that NO two conflicting export certs ever coexist. // k ≥ 2α−n, which is more than ⅓ of stake double-signing, so below that no two conflicting export
// certs can coexist.
func TestNovaQuasarMatrix_Equivocation_TwoNovaNeverTwoQuasar(t *testing.T) { func TestNovaQuasarMatrix_Equivocation_TwoNovaNeverTwoQuasar(t *testing.T) {
const n = 5 const n = 5
const alpha = 4 // ⅔ export count for n=5 const alpha = 4 // ⅔ export count for n=5
@@ -530,27 +532,28 @@ func TestNovaQuasarMatrix_Equivocation_TwoNovaNeverTwoQuasar(t *testing.T) {
return err == nil && cert.Verify(e.voteVerifier, 0) == nil return err == nil && cert.Verify(e.voteVerifier, 0) == nil
} }
// A reaches an EXPORTABLE quorum honestly: {0,1,2,3}. // A reaches an exportable quorum honestly: {0,1,2,3}.
addRaw(A.id, 1, 2, 3) addRaw(A.id, 1, 2, 3)
if !quasarCertOK(A.id) { if !quasarCertOK(A.id) {
t.Fatal("A must hold a valid ⅔ (Quasar) export cert") t.Fatal("A must hold a valid ⅔ (Quasar) export cert")
} }
// Give B exactly 2 equivocators {1,2} (who also signed A) + 1 honest-B voter {4}. That is a // Give B exactly 2 equivocators {1,2}, who also signed A, plus 1 honest B voter {4}. That is a
// NOVA majority (3) for B → a SECOND Nova cert exists (expected under equivocation). // Nova majority (3) for B, so a second Nova cert exists — expected under equivocation.
addRaw(B.id, 1, 2, 4) addRaw(B.id, 1, 2, 4)
if !novaCertOK(B.id) { if !novaCertOK(B.id) {
t.Fatal("with 2 equivocators + 1 honest, B must reach a second NOVA cert (bare majority) — expected, non-exportable") t.Fatal("with 2 equivocators plus 1 honest voter, B must reach a second Nova cert (bare majority) — expected, and not exportable")
} }
// THE INVARIANT: B has only 3 distinct signers (< α=4), so NO second QUASAR (export) cert can // The invariant: B has only 3 distinct signers, below α=4, so no second Quasar (export) cert
// form. Two conflicting export certs would need k ≥ 2α−n = 3 equivocators (>⅓ stake). // can form. Two conflicting export certs would take k ≥ 2α−n = 3 equivocators, more than ⅓ of
// stake.
if quasarCertOK(B.id) { if quasarCertOK(B.id) {
t.Fatal("INVARIANT BREACH: a second conflicting QUASAR (export) cert formed with only 2 equivocators (<⅓ stake)") t.Fatal("invariant breached: a second conflicting Quasar (export) cert formed with only 2 equivocators (below ⅓ stake)")
} }
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// (i) degraded-mode RPC visibility. // Degraded-mode RPC visibility.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// TestNovaQuasarMatrix_DegradedRPC asserts the two-tier RPC snapshot never reports a Nova height // TestNovaQuasarMatrix_DegradedRPC asserts the two-tier RPC snapshot never reports a Nova height
@@ -567,7 +570,7 @@ func TestNovaQuasarMatrix_DegradedRPC(t *testing.T) {
e.ReceiveVote(vs.signedVote(1, pos)) e.ReceiveVote(vs.signedVote(1, pos))
e.ReceiveVote(vs.signedVote(2, pos)) // 3 of 5 = 60% stake e.ReceiveVote(vs.signedVote(2, pos)) // 3 of 5 = 60% stake
// DEGRADED: Nova accepted, but ⅔ is not reachable — quasarHeight trails novaHeight. // Degraded: Nova accepted, but ⅔ is not reachable — quasarHeight trails novaHeight.
if !waitFor(2*time.Second, func() bool { return e.IsAccepted(blk.id) }) { if !waitFor(2*time.Second, func() bool { return e.IsAccepted(blk.id) }) {
t.Fatal("precondition: 3/5 must Nova-accept") t.Fatal("precondition: 3/5 must Nova-accept")
} }
@@ -580,13 +583,13 @@ func TestNovaQuasarMatrix_DegradedRPC(t *testing.T) {
t.Fatalf("novaHeight must have advanced to the accepted block, got %d", s.NovaHeight) t.Fatalf("novaHeight must have advanced to the accepted block, got %d", s.NovaHeight)
} }
if s.QuasarHeight >= blk.height { if s.QuasarHeight >= blk.height {
t.Fatalf("quasarHeight must NOT report a Nova-only block as certified, got %d", s.QuasarHeight) t.Fatalf("quasarHeight must not report a Nova-only block as certified, got %d", s.QuasarHeight)
} }
if s.ResponsiveStakePct <= 0 || s.ResponsiveStakePct > 0.61 { if s.ResponsiveStakePct <= 0 || s.ResponsiveStakePct > 0.61 {
t.Fatalf("responsiveStakePct must be ~0.60 at 3/5, got %v", s.ResponsiveStakePct) t.Fatalf("responsiveStakePct must be ~0.60 at 3/5, got %v", s.ResponsiveStakePct)
} }
// RECOVER: a 4th vote (80% > ⅔) clears degraded and advances the export height. // Recovery: a 4th vote (80% > ⅔) clears degraded and advances the export height.
e.ReceiveVote(vs.signedVote(3, pos)) e.ReceiveVote(vs.signedVote(3, pos))
if !waitFor(2*time.Second, func() bool { if !waitFor(2*time.Second, func() bool {
s := e.FinalityStatus() s := e.FinalityStatus()
@@ -600,14 +603,14 @@ func TestNovaQuasarMatrix_DegradedRPC(t *testing.T) {
// waitForExportFrontier waits for the Quasar EXPORT FRONTIER on any reachable node to reach at // waitForExportFrontier waits for the Quasar EXPORT FRONTIER on any reachable node to reach at
// least `blk`'s height, driving successive honest heights on top of `blk` while it waits. // least `blk`'s height, driving successive honest heights on top of `blk` while it waits.
// //
// This is the liveness property the two-tier design actually guarantees. promoteQuasar emits an // This is the liveness property the two-tier design guarantees. promoteQuasar emits an export
// export cert only for a block whose OWN Nova cert carried >⅔ of stake; a block finalized on the // cert only for a block whose own Nova cert carried more than ⅔ of stake. A block finalized on
// NovaQuorum majority — assembleCertLocked freezes at NovaQuorum(5)=3, and α does not gate cert // the bare NovaQuorum majority — assembleCertLocked freezes at NovaQuorum(5)=3, and α does not
// assembly — without that supermajority attesting stays Nova-only, permanently and by design: // gate cert assembly — stays Nova-only, permanently and by design: no per-block retroactive
// "no per-block retroactive promotion is needed for liveness" precisely because a later // promotion is needed for liveness, precisely because a later fully-voted block carries the
// fully-voted block carries the frontier past it, and a Quasar tip finalizes all its ancestors. // frontier past it, and a Quasar tip finalizes all of its ancestors. So demanding that one
// A test that builds ONE block and demands that block export is therefore asserting a property // particular block export, with no successor built, asks for a property the engine never
// the engine never promised, and it fails on exactly the runs where the first cert froze at 3. // promised, and it does not hold on any run whose first cert froze at the majority.
// //
// Driving heights is what a live fleet does, so this asserts export liveness the way an export // Driving heights is what a live fleet does, so this asserts export liveness the way an export
// consumer experiences it: the frontier moves, and everything at or below it is exported. // consumer experiences it: the frontier moves, and everything at or below it is exported.
@@ -643,7 +646,7 @@ func waitForExportFrontier(net *simNet, blk *simBlock, timeout time.Duration) (b
next := newHonestBlock(parentID, parentRoot, height+1, "export-frontier") next := newHonestBlock(parentID, parentRoot, height+1, "export-frontier")
net.build(builder, next) net.build(builder, next)
// Advance the cursor ONLY on a converged height. Bumping height while leaving the parent // Advance the cursor only on a converged height. Bumping height while leaving the parent
// behind would build every later block on a stale parent, and a genuine export stall // behind would build every later block on a stale parent, and a genuine export stall
// would then be reported as a pile of unconvergeable garbage instead of as a stall. // would then be reported as a pile of unconvergeable garbage instead of as a stall.
if !waitFor(emergeTO, func() bool { if !waitFor(emergeTO, func() bool {
+61 -55
View File
@@ -1,24 +1,27 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved. // Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms. // See the file LICENSE for licensing terms.
// orphan_reconcile_test.go — the build-ahead / diverged-VM-head reconcile. // orphan_reconcile_test.go — reconciling a VM head that built ahead of, or diverged from,
// the consensus finality ledger.
// //
// THE INCIDENT (live 5-node strict-PQ net under load). A node's inner EVM accepted head // The two components hold different heads, and only one of them is the authority. A node's
// ran AHEAD of, or diverged from, the consensus finality ledger: when the engine steered // inner EVM accepts blocks locally, so its accepted head can run ahead of the finality
// the VM (SetPreference) onto the block it had just finalized, the EVM refused with "cannot // ledger, or sit on a sibling the ledger did not certify. When the engine then steers the VM
// orphan finalized block at height N to common block at height N-1" (core/blockchain.go: // onto the block consensus just finalized, the EVM's reorg guard refuses the SetPreference
// reorg guard). The engine responded with log.Crit → os.Exit(1): a healthy validator was // with "cannot orphan finalized block at height N to common block at height N-1". That
// killed even though its CONSENSUS finality was correct. // refusal says the VM would lose a block it locally accepted; it says nothing about whether
// consensus finality is correct, and treating it as fatal kills a node whose finality is
// correct.
// //
// THE FIX under test: reconcileVMToCertified replaces the unconditional fatal. It classifies // reconcileVMToCertified therefore classifies the VM's diverged head against the finality
// the VM's diverged head against the finality LEDGER (the sole authority) and: // ledger, which is the sole authority on what is final, and:
// - drops an UNCERTIFIED provisional tip (above the finalized frontier, or a losing // - drops an uncertified provisional tip — one above the finalized frontier, or a losing
// sibling of a finalized block) by reconciling the VM to the certified block — no crash; // sibling of a finalized block — by reconciling the VM to the certified block;
// - HALTS fail-closed ONLY when the tip the VM would orphan is itself the consensus- // - halts fail-closed when the tip the VM would orphan is itself the consensus-certified
// certified block at its height (a two-blocks-at-one-height double-finalization). // block at its height, since that is two blocks certified at one height.
// //
// SAFETY INVARIANT proven here: a tip is dropped ONLY when its canonical is provably NOT in // The safety invariant proven here: a tip is dropped only when its canonical is provably
// byHeight, so NO certified block is ever orphaned to gain liveness. // absent from byHeight, so no certified block is ever orphaned to buy liveness.
package chain package chain
import ( import (
@@ -34,8 +37,8 @@ import (
var errOrphan = errors.New("cannot orphan finalized block at height: 7 to common block at height: 6") var errOrphan = errors.New("cannot orphan finalized block at height: 7 to common block at height: 6")
// orphanVMBase is a BlockBuilder whose SetPreference always refuses with the orphan error // orphanVMBase is a BlockBuilder whose SetPreference always refuses with the orphan error
// and whose LastAccepted returns a caller-controlled diverged head. It does NOT implement // and whose LastAccepted returns a caller-controlled diverged head. It does not implement
// PreferenceReconciler (the no-live-reconcile case). // PreferenceReconciler — the case where no live reconcile is available.
type orphanVMBase struct { type orphanVMBase struct {
head ids.ID head ids.ID
blocks map[ids.ID]*mockBlock blocks map[ids.ID]*mockBlock
@@ -80,10 +83,10 @@ func mb(id ids.ID, height uint64) *mockBlock { return &mockBlock{id: id, height:
// --- direct classification tests: the BFT-safety core -------------------------------- // --- direct classification tests: the BFT-safety core --------------------------------
// TestReconcile_UncertifiedSibling_Reconciles: the VM's head is a LOSING SIBLING of the // TestReconcile_UncertifiedSibling_Reconciles: the VM's head is a losing sibling of the
// finalized block at the same height (its canonical is NOT the ledger's certified canonical // finalized block at the same height — its canonical is not the ledger's certified canonical
// there). It is uncertified ⇒ safe to drop ⇒ the VM is reconciled to the certified block and // there. Uncertified, so safe to drop: the VM is reconciled to the certified block and the
// the node does NOT halt. // node does not halt.
func TestReconcile_UncertifiedSibling_Reconciles(t *testing.T) { func TestReconcile_UncertifiedSibling_Reconciles(t *testing.T) {
e := newTestEngine() e := newTestEngine()
@@ -91,7 +94,7 @@ func TestReconcile_UncertifiedSibling_Reconciles(t *testing.T) {
certifiedCanon := certifiedOuter certifiedCanon := certifiedOuter
e.consensus.ledger = seedLedger(certifiedOuter, certifiedCanon, 7) // byHeight[7] = certifiedCanon e.consensus.ledger = seedLedger(certifiedOuter, certifiedCanon, 7) // byHeight[7] = certifiedCanon
sibling := ids.GenerateTestID() // a DIFFERENT height-7 block the VM accepted locally sibling := ids.GenerateTestID() // a different height-7 block the VM accepted locally
vm := &reconcilingOrphanVM{orphanVMBase: &orphanVMBase{ vm := &reconcilingOrphanVM{orphanVMBase: &orphanVMBase{
head: sibling, head: sibling,
blocks: map[ids.ID]*mockBlock{sibling: mb(sibling, 7)}, blocks: map[ids.ID]*mockBlock{sibling: mb(sibling, 7)},
@@ -105,13 +108,13 @@ func TestReconcile_UncertifiedSibling_Reconciles(t *testing.T) {
t.Fatalf("expected exactly one ReconcilePreference call, got %d", vm.reconcileCalls) t.Fatalf("expected exactly one ReconcilePreference call, got %d", vm.reconcileCalls)
} }
if vm.reconciledTo != certifiedOuter { if vm.reconciledTo != certifiedOuter {
t.Fatalf("VM must be reconciled TO the certified block %s, got %s", certifiedOuter, vm.reconciledTo) t.Fatalf("VM must be reconciled to the certified block %s, got %s", certifiedOuter, vm.reconciledTo)
} }
} }
// TestReconcile_BuildAheadAboveFrontier_Reconciles: the VM's head is ABOVE the finalized // TestReconcile_BuildAheadAboveFrontier_Reconciles: the VM's head is above the finalized
// frontier — the node built + accepted ahead of the quorum (the exact task scenario). Above // frontier, because the node built and accepted ahead of the quorum. Above the frontier there
// the frontier there is no byHeight entry ⇒ uncertified ⇒ safe ⇒ reconcile, no halt. // is no byHeight entry, so the head is uncertified and safe to drop: reconcile, no halt.
func TestReconcile_BuildAheadAboveFrontier_Reconciles(t *testing.T) { func TestReconcile_BuildAheadAboveFrontier_Reconciles(t *testing.T) {
e := newTestEngine() e := newTestEngine()
@@ -133,21 +136,20 @@ func TestReconcile_BuildAheadAboveFrontier_Reconciles(t *testing.T) {
} }
} }
// TestReconcile_CertifiedHead_HaltsFailClosed is the SAFETY assertion. The VM's head IS the // TestReconcile_CertifiedHead_HaltsFailClosed is the safety assertion. The VM's head is itself
// consensus-certified block at its height, and the newly finalized block is a DIFFERENT // the consensus-certified block at its height, and the newly finalized block is a different
// canonical there — a genuine two-blocks-at-one-height double-finalization. The engine MUST // canonical there — two blocks certified at one height. The engine has to refuse to reconcile
// refuse to reconcile (return false ⇒ caller halts fail-closed) and MUST NOT orphan the // (return false, so the caller halts fail-closed) rather than orphan the certified block.
// certified block.
func TestReconcile_CertifiedHead_HaltsFailClosed(t *testing.T) { func TestReconcile_CertifiedHead_HaltsFailClosed(t *testing.T) {
e := newTestEngine() e := newTestEngine()
head := ids.GenerateTestID() // the VM's accepted head at height 7 head := ids.GenerateTestID() // the VM's accepted head at height 7
e.consensus.ledger = seedLedger(head, head, 7) // byHeight[7] = head (head IS certified) e.consensus.ledger = seedLedger(head, head, 7) // byHeight[7] = head (head IS certified)
certifiedOther := ids.GenerateTestID() // a DIFFERENT block claimed final at 7 certifiedOther := ids.GenerateTestID() // a different block claimed final at 7
// BOTH blocks are readable, so the halt is decided by the LEDGER (byHeight[7] is head's // Both blocks are readable, so the halt is decided by the ledger — byHeight[7] holds head's
// canonical, not certifiedOther's) and not by an unreadable id — this is the genuine // canonical, not certifiedOther's — and not by an unreadable id. That keeps this the genuine
// two-blocks-at-one-height shape, not an artefact of the mock. // two-blocks-at-one-height shape rather than an artefact of the mock.
vm := &reconcilingOrphanVM{orphanVMBase: &orphanVMBase{ vm := &reconcilingOrphanVM{orphanVMBase: &orphanVMBase{
head: head, head: head,
blocks: map[ids.ID]*mockBlock{head: mb(head, 7), certifiedOther: mb(certifiedOther, 7)}, blocks: map[ids.ID]*mockBlock{head: mb(head, 7), certifiedOther: mb(certifiedOther, 7)},
@@ -155,15 +157,16 @@ func TestReconcile_CertifiedHead_HaltsFailClosed(t *testing.T) {
handled := e.reconcileVMToCertified(context.Background(), vm, certifiedOther, errOrphan) handled := e.reconcileVMToCertified(context.Background(), vm, certifiedOther, errOrphan)
if handled { if handled {
t.Fatal("SAFETY VIOLATION: engine reconciled away a CONSENSUS-CERTIFIED block (must return false / halt)") t.Fatal("safety: the engine reconciled away a consensus-certified block (it must return false and halt)")
} }
if vm.reconcileCalls != 0 { if vm.reconcileCalls != 0 {
t.Fatalf("SAFETY VIOLATION: ReconcilePreference was called for a certified head (%d times) — a certified block must NEVER be orphaned", vm.reconcileCalls) t.Fatalf("safety: ReconcilePreference was called for a certified head (%d times) — a certified block is never orphaned", vm.reconcileCalls)
} }
} }
// TestReconcile_HeadEqualsCertified_NoOp: a transient SetPreference refusal where the VM's // TestReconcile_HeadEqualsCertified_NoOp: a transient SetPreference refusal where the VM's
// head already equals the certified block. No divergence ⇒ handled, no reconcile. // head already equals the certified block. Nothing has diverged, so it is handled with no
// reconcile.
func TestReconcile_HeadEqualsCertified_NoOp(t *testing.T) { func TestReconcile_HeadEqualsCertified_NoOp(t *testing.T) {
e := newTestEngine() e := newTestEngine()
certified := ids.GenerateTestID() certified := ids.GenerateTestID()
@@ -180,21 +183,21 @@ func TestReconcile_HeadEqualsCertified_NoOp(t *testing.T) {
} }
} }
// TestReconcile_NoLiveReconcile_DefersNonFatal: a VM WITHOUT the optional PreferenceReconciler // TestReconcile_NoLiveReconcile_DefersNonFatal: a VM without the optional PreferenceReconciler
// and an uncertified diverged head. The engine must NOT crash: it keeps its correct consensus // and an uncertified diverged head. The engine keeps its correct consensus finality and defers
// finality and defers the VM head reconcile to offline recovery (handled=true, no reconcile). // the VM head reconcile to offline recovery (handled=true, no reconcile) rather than crashing.
func TestReconcile_NoLiveReconcile_DefersNonFatal(t *testing.T) { func TestReconcile_NoLiveReconcile_DefersNonFatal(t *testing.T) {
e := newTestEngine() e := newTestEngine()
certified := ids.GenerateTestID() certified := ids.GenerateTestID()
e.consensus.ledger = seedLedger(certified, certified, 7) e.consensus.ledger = seedLedger(certified, certified, 7)
sibling := ids.GenerateTestID() sibling := ids.GenerateTestID()
vm := &orphanVMBase{ // does NOT implement PreferenceReconciler vm := &orphanVMBase{ // deliberately does not implement PreferenceReconciler
head: sibling, head: sibling,
blocks: map[ids.ID]*mockBlock{sibling: mb(sibling, 7)}, blocks: map[ids.ID]*mockBlock{sibling: mb(sibling, 7)},
} }
if _, ok := interface{}(vm).(PreferenceReconciler); ok { if _, ok := interface{}(vm).(PreferenceReconciler); ok {
t.Fatal("test precondition: orphanVMBase must NOT implement PreferenceReconciler") t.Fatal("test precondition: orphanVMBase must not implement PreferenceReconciler")
} }
if !e.reconcileVMToCertified(context.Background(), vm, certified, errOrphan) { if !e.reconcileVMToCertified(context.Background(), vm, certified, errOrphan) {
t.Fatal("a VM without live reconcile must be handled non-fatally (no halt), got false") t.Fatal("a VM without live reconcile must be handled non-fatally (no halt), got false")
@@ -202,7 +205,8 @@ func TestReconcile_NoLiveReconcile_DefersNonFatal(t *testing.T) {
} }
// TestReconcile_UnreadableHead_DefersNonFatal: LastAccepted returns a head the VM cannot // TestReconcile_UnreadableHead_DefersNonFatal: LastAccepted returns a head the VM cannot
// GetBlock. Cannot classify ⇒ do NOT crash, do NOT touch the VM (nothing orphaned), defer. // GetBlock. An unclassifiable head is left alone — nothing is orphaned, nothing crashes, the
// reconcile defers.
func TestReconcile_UnreadableHead_DefersNonFatal(t *testing.T) { func TestReconcile_UnreadableHead_DefersNonFatal(t *testing.T) {
e := newTestEngine() e := newTestEngine()
certified := ids.GenerateTestID() certified := ids.GenerateTestID()
@@ -216,17 +220,18 @@ func TestReconcile_UnreadableHead_DefersNonFatal(t *testing.T) {
t.Fatal("unreadable head must be handled non-fatally (no halt)") t.Fatal("unreadable head must be handled non-fatally (no halt)")
} }
if vm.reconcileCalls != 0 { if vm.reconcileCalls != 0 {
t.Fatalf("must NOT reconcile an unclassifiable head, got %d calls", vm.reconcileCalls) t.Fatalf("an unclassifiable head must not be reconciled, got %d calls", vm.reconcileCalls)
} }
} }
// --- end-to-end through the sole finalizer ------------------------------------------- // --- end-to-end through the sole finalizer -------------------------------------------
// TestAcceptWithCert_OrphanRefusal_ReconcilesInsteadOfHalting drives the FULL finalize path // TestAcceptWithCert_OrphanRefusal_ReconcilesInsteadOfHalting drives the whole finalize path
// (acceptWithCertCore → ApplyCert → applyBranchFinalization(VM.Accept) → SetPreference) with // (acceptWithCertCore → ApplyCert → applyBranchFinalization(VM.Accept) → SetPreference) with a
// a VM that refuses SetPreference with the orphan error and whose accepted head diverged from // VM that refuses SetPreference with the orphan error and whose accepted head diverged from the
// the just-finalized block. Pre-fix this path hit log.Crit → os.Exit(1); the test asserts the // just-finalized block. The classification above is only worth anything if it is reached from
// engine now finalizes cleanly (returns nil) AND reconciled the VM — the node did not halt. // the real finalizer, so this asserts the path finalizes cleanly (returns nil) and reconciles
// the VM, rather than treating the refusal as fatal.
func TestAcceptWithCert_OrphanRefusal_ReconcilesInsteadOfHalting(t *testing.T) { func TestAcceptWithCert_OrphanRefusal_ReconcilesInsteadOfHalting(t *testing.T) {
e := newTestEngine() e := newTestEngine()
ctx := context.Background() ctx := context.Background()
@@ -238,9 +243,9 @@ func TestAcceptWithCert_OrphanRefusal_ReconcilesInsteadOfHalting(t *testing.T) {
t.Fatalf("AddBlock: %v", err) t.Fatalf("AddBlock: %v", err)
} }
// A DIVERGED VM head: a different height-1 block the VM accepted ahead/locally. SetPreference // A diverged VM head: a different height-1 block the VM accepted locally. SetPreference to
// to finalID would orphan it, so the VM refuses; the head is uncertified (byHeight[1] will be // finalID would orphan it, so the VM refuses; the head is uncertified, since byHeight[1] will
// finalID's canonical), so the engine must reconcile — not crash. // be finalID's canonical, so the engine reconciles rather than crashing.
sibling := ids.GenerateTestID() sibling := ids.GenerateTestID()
vm := &reconcilingOrphanVM{orphanVMBase: &orphanVMBase{ vm := &reconcilingOrphanVM{orphanVMBase: &orphanVMBase{
head: sibling, head: sibling,
@@ -262,12 +267,13 @@ func TestAcceptWithCert_OrphanRefusal_ReconcilesInsteadOfHalting(t *testing.T) {
Threshold: 1, Threshold: 1,
}} }}
// SOLE finalizer. Pre-fix: os.Exit(1) inside here. Post-fix: clean finalize + reconcile. // The sole finalizer: a clean finalize plus a reconcile, with the orphan refusal handled
// inside rather than escalated.
if err := e.acceptWithCertCore(ctx, finalID, cert, false); err != nil { if err := e.acceptWithCertCore(ctx, finalID, cert, false); err != nil {
t.Fatalf("acceptWithCertCore returned error (expected clean finalize+reconcile): %v", err) t.Fatalf("acceptWithCertCore returned error (expected clean finalize+reconcile): %v", err)
} }
// The engine reconciled the VM to the finalized block instead of halting. // The engine reconciled the VM to the finalized block instead of halting the node.
if vm.reconcileCalls != 1 { if vm.reconcileCalls != 1 {
t.Fatalf("expected the engine to reconcile the VM exactly once, got %d", vm.reconcileCalls) t.Fatalf("expected the engine to reconcile the VM exactly once, got %d", vm.reconcileCalls)
} }
+49 -54
View File
@@ -10,32 +10,28 @@ import (
"github.com/luxfi/ids" "github.com/luxfi/ids"
) )
// The durable sign-refusal floor is the ONE piece of state a validator can never take back: // The durable sign-refusal floor is the one piece of state a validator can never take back:
// it is fsync'd, monotonic, and no restart, resync, or peer can lower it. These tests pin the // it is fsync'd, monotonic, and no restart, resync, or peer can lower it. These tests pin the
// only sound source for it. // only sound source for it.
// //
// Quorum intersection is 2α−n. At n=5, f=1: // Quorum intersection is 2α−n. At n=5, f=1:
// //
// Nova ⌊n/2⌋+1 = 3 → 2α−n = 1 NOT > f ⇒ two conflicting Nova histories may exist // Nova ⌊n/2⌋+1 = 3 → 2α−n = 1 not > f ⇒ two conflicting Nova histories may exist
// Quasar strict ⅔ = 4 → 2α−n = 3 > f ⇒ intersection guaranteed, irreversible // Quasar strict ⅔ = 4 → 2α−n = 3 > f ⇒ intersection guaranteed, irreversible
// //
// So only a Quasar height is agreed by every honest peer. Closing a Nova height forever strands // So only a Quasar height is agreed by every honest peer. Closing a Nova height forever strands
// the validator at a height the fleet never agreed on — the mainnet-1098192 and testnet-11367 // the validator at a height the fleet never agreed on: the honest rebuild at that height can be
// halts, where a valid block was built, verified, and gossiped but was unsignable, so no // built, verified and gossiped and still be unsignable, so no certificate ever assembles and the
// certificate could ever assemble and the chain stopped with zero errors logged. // chain stops without anything failing.
// //
// Nova is NOT gated away: it keeps the ledger, VM.Accept, preference, catch-up, and rejoin. // Nova is not gated away: it keeps the ledger, VM.Accept, preference, catch-up, and rejoin. Nova
// An earlier attempt to refuse Nova certs at intake broke three heal/rejoin tests, because Nova // certs are the recovery transport, so refusing them at intake would take heal and rejoin with
// certs ARE the recovery transport. The cut is narrower and exact: Nova may move everything // them. The cut is narrower and exact: Nova may move everything except the permanent floor.
// except the permanent floor.
// TestQuasarFloor_NovaAcceptDoesNotCloseHeights is the core regression. A Nova majority accept // TestQuasarFloor_NovaAcceptDoesNotCloseHeights pins the core property. A Nova majority accept
// must advance the ledger and leave BOTH the export frontier and the durable guard floor // must advance the ledger and leave both the export frontier and the durable guard floor
// untouched. The deleted line in acceptWithCertCore was // untouched: pruning committed slots at a bare-majority height fsyncs a permanent refusal at a
// // height that may never be certified, and nothing can clear it afterwards.
// if highestAccepted > 0 { t.pruneCommittedSlotsBelow(highestAccepted) }
//
// which fsync'd a permanent refusal at a bare-majority height.
func TestQuasarFloor_NovaAcceptDoesNotCloseHeights(t *testing.T) { func TestQuasarFloor_NovaAcceptDoesNotCloseHeights(t *testing.T) {
vs := newTestValidatorSet(5) vs := newTestValidatorSet(5)
store, err := OpenVoteGuard(filepath.Join(t.TempDir(), "vote-guard")) store, err := OpenVoteGuard(filepath.Join(t.TempDir(), "vote-guard"))
@@ -56,28 +52,28 @@ func TestQuasarFloor_NovaAcceptDoesNotCloseHeights(t *testing.T) {
if h, ok := e.consensus.GetFinalizedHeight(); !ok || h != H { if h, ok := e.consensus.GetFinalizedHeight(); !ok || h != H {
t.Fatalf("nova ledger must advance to %d, got (%d,%v)", H, h, ok) t.Fatalf("nova ledger must advance to %d, got (%d,%v)", H, h, ok)
} }
// The export frontier did NOT. // The export frontier did not.
if qh, ok := e.consensus.QuasarHeight(); ok && qh >= H { if qh, ok := e.consensus.QuasarHeight(); ok && qh >= H {
t.Fatalf("a NOVA accept must not advance the export frontier: got (%d,%v)", qh, ok) t.Fatalf("a nova accept must not advance the export frontier: got (%d,%v)", qh, ok)
} }
if got := e.consensus.GetQuasarSigningFloor(); got != 0 { if got := e.consensus.GetQuasarSigningFloor(); got != 0 {
t.Fatalf("a NOVA accept must leave the quasar signing floor at 0, got %d", got) t.Fatalf("a nova accept must leave the quasar signing floor at 0, got %d", got)
} }
// And neither did the durable guard floor — the thing that can never be taken back. // And neither did the durable guard floor — the thing that can never be taken back.
if got := store.FinalizedThrough(); got != 0 { if got := store.FinalizedThrough(); got != 0 {
t.Fatalf("PERMANENT WELD: a NOVA accept fsync'd a sign-refusal floor of %d. Nova is reorgable "+ t.Fatalf("a nova accept fsync'd a sign-refusal floor of %d. Nova is reorgable "+
"(2α−n = 1, NOT > f=1), so this height may never be certified and the refusal can never be "+ "(2α−n = 1, not > f=1), so this height may never be certified and the refusal can never "+
"cleared — mainnet-1098192 / testnet-11367.", got) "be cleared", got)
} }
// Therefore the height stays signable: a restarted node can still sign the honest rebuild. // Therefore the height stays signable: a restarted node can still sign the honest rebuild.
if !e.reserveSlotForSign(H, ids.GenerateTestID()) { if !e.reserveSlotForSign(H, ids.GenerateTestID()) {
t.Fatalf("height %d is Nova-accepted but not ⅔-certified — it MUST stay signable so a restart "+ t.Fatalf("height %d is nova-accepted but not ⅔-certified — it must stay signable so a restart "+
"can re-sign the rebuild the fleet agrees on", H) "can re-sign the rebuild the fleet agrees on", H)
} }
} }
// TestQuasarFloor_PromotionAdvancesFloorAtomically proves the other half: a ⅔-by-stake cert DOES // TestQuasarFloor_PromotionAdvancesFloorAtomically proves the other half: a ⅔-by-stake cert does
// close the height, and the guard floor + binding compaction land in ONE fsync'd write. // close the height, and the guard floor and binding compaction land in one fsync'd write.
func TestQuasarFloor_PromotionAdvancesFloorAtomically(t *testing.T) { func TestQuasarFloor_PromotionAdvancesFloorAtomically(t *testing.T) {
vs := newTestValidatorSet(5) vs := newTestValidatorSet(5)
path := filepath.Join(t.TempDir(), "vote-guard") path := filepath.Join(t.TempDir(), "vote-guard")
@@ -100,7 +96,7 @@ func TestQuasarFloor_PromotionAdvancesFloorAtomically(t *testing.T) {
} }
if got := store.FinalizedThrough(); got != Q { if got := store.FinalizedThrough(); got != Q {
t.Fatalf("a ⅔-stake certified height MUST advance the durable floor: FinalizedThrough=%d want %d", got, Q) t.Fatalf("a ⅔-stake certified height must advance the durable floor: FinalizedThrough=%d want %d", got, Q)
} }
// In memory: strictly-below bindings dropped, the above-floor one kept. // In memory: strictly-below bindings dropped, the above-floor one kept.
e.slotMu.Lock() e.slotMu.Lock()
@@ -113,10 +109,10 @@ func TestQuasarFloor_PromotionAdvancesFloorAtomically(t *testing.T) {
Q, has298, has299) Q, has298, has299)
} }
if !has301 { if !has301 {
t.Fatalf("binding at %d is ABOVE the certified floor and must be RETAINED — that window is where "+ t.Fatalf("binding at %d is above the certified floor and must be retained — that window is where "+
"a restarted node still needs its per-height vote memory", Q+1) "a restarted node still needs its per-height vote memory", Q+1)
} }
// And durably. VoteGuardStore.Snapshot() is the OPEN-TIME view (never updated by Persist), // And durably. VoteGuardStore.Snapshot() is the open-time view (Persist does not update it),
// so the only honest probe of what landed on disk is a fresh open. // so the only honest probe of what landed on disk is a fresh open.
re, err := OpenVoteGuard(path) re, err := OpenVoteGuard(path)
if err != nil { if err != nil {
@@ -137,8 +133,8 @@ func TestQuasarFloor_PromotionAdvancesFloorAtomically(t *testing.T) {
} }
// TestQuasarFloor_GuardWriteFailureWithholdsExport proves the fail-closed ordering. If the guard // TestQuasarFloor_GuardWriteFailureWithholdsExport proves the fail-closed ordering. If the guard
// write fails, the floor must NOT advance in memory (memory can never claim what disk does not // write fails, the floor must not advance in memory (memory can never claim what disk does not
// carry) and the caller must NOT publish the export frontier — otherwise a crash leaves the VM's // carry) and the caller must not publish the export frontier — otherwise a crash leaves the VM's
// durable LastQuasarHeight naming a height whose compaction never landed, and the two durable // durable LastQuasarHeight naming a height whose compaction never landed, and the two durable
// records disagree about what is closed. // records disagree about what is closed.
func TestQuasarFloor_GuardWriteFailureWithholdsExport(t *testing.T) { func TestQuasarFloor_GuardWriteFailureWithholdsExport(t *testing.T) {
@@ -148,20 +144,20 @@ func TestQuasarFloor_GuardWriteFailureWithholdsExport(t *testing.T) {
const Q = uint64(77) const Q = uint64(77)
err := e.compactVoteGuardThroughQuasar(Q) err := e.compactVoteGuardThroughQuasar(Q)
if err == nil { if err == nil {
t.Fatal("compactVoteGuardThroughQuasar MUST return an error when the durable write fails — the " + t.Fatal("compactVoteGuardThroughQuasar must return an error when the durable write fails — the " +
"caller uses it to withhold the export-frontier notification (fail-closed)") "caller uses it to withhold the export-frontier notification (fail-closed)")
} }
e.slotMu.Lock() e.slotMu.Lock()
floor := e.decidedFloor floor := e.decidedFloor
e.slotMu.Unlock() e.slotMu.Unlock()
if floor != 0 { if floor != 0 {
t.Fatalf("in-memory floor advanced to %d despite a FAILED durable write — memory must never claim "+ t.Fatalf("in-memory floor advanced to %d despite a failed durable write — memory must never claim "+
"a floor disk does not carry; it must roll back", floor) "a floor disk does not carry; it must roll back", floor)
} }
} }
// TestQuasarFloor_ImportIsRecoveryHintNotDecision covers the RLP / state-sync / snapshot-clone // TestQuasarFloor_ImportIsRecoveryHintNotDecision covers the RLP / state-sync / snapshot-clone
// path. Handing a node block DATA is not proof the network certified it — and a recovery import // path. Handing a node block data is not proof the network certified it — and a recovery import
// is precisely when a fleet is below quorum and those heights may still need re-deciding. The // is precisely when a fleet is below quorum and those heights may still need re-deciding. The
// import must advance the Nova build anchor and leave the signing floor alone. // import must advance the Nova build anchor and leave the signing floor alone.
func TestQuasarFloor_ImportIsRecoveryHintNotDecision(t *testing.T) { func TestQuasarFloor_ImportIsRecoveryHintNotDecision(t *testing.T) {
@@ -172,7 +168,7 @@ func TestQuasarFloor_ImportIsRecoveryHintNotDecision(t *testing.T) {
} }
e, _ := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{}, WithVoteGuard(store)) e, _ := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{}, WithVoteGuard(store))
const imported = uint64(1098726) // the real mainnet RLP recovery height const imported = uint64(1098726) // an import head far above the local floor
if err := e.consensus.SyncState(ids.GenerateTestID(), imported); err != nil { if err := e.consensus.SyncState(ids.GenerateTestID(), imported); err != nil {
t.Fatalf("SyncState(import): %v", err) t.Fatalf("SyncState(import): %v", err)
} }
@@ -181,29 +177,28 @@ func TestQuasarFloor_ImportIsRecoveryHintNotDecision(t *testing.T) {
if got := e.consensus.GetNovaAcceptedFloor(); got != imported { if got := e.consensus.GetNovaAcceptedFloor(); got != imported {
t.Fatalf("import must advance the Nova accepted floor to %d, got %d", imported, got) t.Fatalf("import must advance the Nova accepted floor to %d, got %d", imported, got)
} }
// It did NOT move the signing floor, in memory or on disk. // It did not move the signing floor, in memory or on disk.
if got := e.consensus.GetQuasarSigningFloor(); got != 0 { if got := e.consensus.GetQuasarSigningFloor(); got != 0 {
t.Fatalf("import must not advance the quasar signing floor, got %d", got) t.Fatalf("import must not advance the quasar signing floor, got %d", got)
} }
if got := store.FinalizedThrough(); got != 0 { if got := store.FinalizedThrough(); got != 0 {
t.Fatalf("IMPORT WELD: an RLP import fsync'd a sign-refusal floor of %d. The operator supplied "+ t.Fatalf("an import fsync'd a sign-refusal floor of %d. The operator supplied block data, not a "+
"block data, not a certificate — and an import happens exactly when the fleet is below quorum.", got) "certificate — and an import happens exactly when the fleet is below quorum", got)
} }
if !e.reserveSlotForSign(imported, ids.GenerateTestID()) { if !e.reserveSlotForSign(imported, ids.GenerateTestID()) {
t.Fatalf("an imported height (%d) carries no local certificate and MUST stay signable", imported) t.Fatalf("an imported height (%d) carries no local certificate and must stay signable", imported)
} }
} }
// TestQuasarFloor_RestartKeepsFloorAtQuasarNotNova is the rolling-upgrade acceptance test in // TestQuasarFloor_RestartKeepsFloorAtQuasarNotNova is the restart-under-upgrade property. A node
// miniature, and the property the owner actually asked for. A node runs ahead on Nova, restarts, // runs ahead on Nova, restarts, and must come back with its floor at the certified height — so
// and must come back with its floor at the CERTIFIED height — so every height the fleet has not // every height the fleet has not agreed on is still available for it to re-sign.
// agreed on is still available for it to re-sign.
func TestQuasarFloor_RestartKeepsFloorAtQuasarNotNova(t *testing.T) { func TestQuasarFloor_RestartKeepsFloorAtQuasarNotNova(t *testing.T) {
vs := newTestValidatorSet(5) vs := newTestValidatorSet(5)
path := filepath.Join(t.TempDir(), "vote-guard") path := filepath.Join(t.TempDir(), "vote-guard")
const Q = uint64(11363) // last ⅔-certified height (the real testnet value) const Q = uint64(11363) // last ⅔-certified height
const novaHead = uint64(11367) const novaHead = uint64(11367) // four heights of bare-majority run-ahead above it
store1, err := OpenVoteGuard(path) store1, err := OpenVoteGuard(path)
if err != nil { if err != nil {
@@ -215,8 +210,8 @@ func TestQuasarFloor_RestartKeepsFloorAtQuasarNotNova(t *testing.T) {
if err := e1.compactVoteGuardThroughQuasar(Q); err != nil { if err := e1.compactVoteGuardThroughQuasar(Q); err != nil {
t.Fatalf("compact(Q): %v", err) t.Fatalf("compact(Q): %v", err)
} }
// Then this node runs ahead ALONE on bare Nova majorities through novaHead, binding a vote // Then this node runs ahead alone on bare Nova majorities through novaHead, binding a vote
// at each height — exactly what luxd-1 did while no cert formed. // at each height while no cert forms.
for h := Q + 1; h <= novaHead; h++ { for h := Q + 1; h <= novaHead; h++ {
if !e1.reserveSlotForSign(h, ids.GenerateTestID()) { if !e1.reserveSlotForSign(h, ids.GenerateTestID()) {
t.Fatalf("precondition: must bind own vote at %d", h) t.Fatalf("precondition: must bind own vote at %d", h)
@@ -227,14 +222,14 @@ func TestQuasarFloor_RestartKeepsFloorAtQuasarNotNova(t *testing.T) {
} }
} }
// RESTART. // Restart.
store2, err := OpenVoteGuard(path) store2, err := OpenVoteGuard(path)
if err != nil { if err != nil {
t.Fatalf("OpenVoteGuard(store2): %v", err) t.Fatalf("OpenVoteGuard(store2): %v", err)
} }
if got := store2.FinalizedThrough(); got != Q { if got := store2.FinalizedThrough(); got != Q {
t.Fatalf("ROLLING-UPGRADE BLOCKER: the durable floor came back at %d, but only %d was ⅔-certified. "+ t.Fatalf("the durable floor came back at %d, but only %d was ⅔-certified. A restarted node must "+
"A restarted node must never carry a refusal above the height its peers agree on.", got, Q) "not carry a refusal above the height its peers agree on", got, Q)
} }
e2, _ := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{}, WithVoteGuard(store2)) e2, _ := newQuorumEngineOpts(t, params5Prod(), vs, 0, &recordingGossiper{}, WithVoteGuard(store2))
@@ -242,8 +237,8 @@ func TestQuasarFloor_RestartKeepsFloorAtQuasarNotNova(t *testing.T) {
if e2.reserveSlotForSign(Q, ids.GenerateTestID()) { if e2.reserveSlotForSign(Q, ids.GenerateTestID()) {
t.Fatalf("the certified height %d must remain unsignable across the restart", Q) t.Fatalf("the certified height %d must remain unsignable across the restart", Q)
} }
// The uncertified window is NOT closed by the floor — but each height there is still guarded // The uncertified window is not closed by the floor — each height there is guarded
// INDIVIDUALLY by its retained binding. That is the whole decomplection: one signature per // individually by its retained binding. That is the whole decomplection: one signature per
// height, enforced by memory of the vote, not by a blunt permanent floor. // height, enforced by memory of the vote, not by a blunt permanent floor.
for h := Q + 1; h <= novaHead; h++ { for h := Q + 1; h <= novaHead; h++ {
bound, ok := store2.Snapshot()[SlotKey{Height: h}] bound, ok := store2.Snapshot()[SlotKey{Height: h}]
@@ -252,12 +247,12 @@ func TestQuasarFloor_RestartKeepsFloorAtQuasarNotNova(t *testing.T) {
"has no memory of its own vote and could equivocate", h) "has no memory of its own vote and could equivocate", h)
} }
if e2.reserveSlotForSign(h, ids.GenerateTestID()) { if e2.reserveSlotForSign(h, ids.GenerateTestID()) {
t.Fatalf("a CONFLICTING canonical at uncertified height %d must still be refused by the "+ t.Fatalf("a conflicting canonical at uncertified height %d must still be refused by the "+
"retained binding (bound=%s)", h, bound) "retained binding (bound=%s)", h, bound)
} }
if !e2.reserveSlotForSign(h, bound) { if !e2.reserveSlotForSign(h, bound) {
t.Fatalf("the SAME canonical this node already signed at %d must be re-signable (idempotent) — "+ t.Fatalf("the same canonical this node already signed at %d must be re-signable (idempotent) — "+
"this is how a restarted node re-issues its outstanding vote and lets the cert finally form", h) "this is how a restarted node re-issues its outstanding vote and lets the cert form", h)
} }
} }
} }
+9 -5
View File
@@ -40,11 +40,15 @@
// attestation ONLY after it ACCEPTS it; a never-accepted height returns "cannot query // attestation ONLY after it ACCEPTS it; a never-accepted height returns "cannot query
// unfinalized data". So by P1, no block at h was ever externalized — the finalization // unfinalized data". So by P1, no block at h was ever externalized — the finalization
// at h was purely INTERNAL to the consensus layer. // at h was purely INTERNAL to the consensus layer.
// P3. The cert material for h (the α-of-K precommit signatures and the assembled cert) // P3. No node holds a presentable cert for h. The in-memory homes (finalizedByCert, the
// lived ONLY in memory (finalizedByCert, the served-cert window, the roundView // served-cert window, the roundView precommit sets) clear on the restart this reconcile
// precommit sets) — all cleared on the restart this reconcile runs within. The durable // runs within, and the durable vote-guard persists bindings and a floor, never
// vote-guard persists NO signatures. So after the restart NO node holds or can // signatures. The durable cert store is bounded by the SAME condition as P1:
// reconstruct a presentable cert for h. // applyBranchFinalization returns at its first VM.Accept error and reaches
// storeServedCertLocked / persistServedCert only on a clean accept, so a cert is
// recorded — in memory or on disk — only for a tip the VM applied. Contrapositive: a
// stored cert at height h means some node applied h, hence h <= fleet-max <= safeFloor,
// hence h is not in the abandoned range at all.
// P4. From P2 (never externalized) + P3 (cert unrecoverable): no party holds two certs for // P4. From P2 (never externalized) + P3 (cert unrecoverable): no party holds two certs for
// h. Re-finalizing a fresh block at h yields exactly ONE presentable cert there. No // h. Re-finalizing a fresh block at h yields exactly ONE presentable cert there. No
// double-cert is observable. ∎ // double-cert is observable. ∎
+51 -53
View File
@@ -11,14 +11,14 @@ import (
"github.com/luxfi/ids" "github.com/luxfi/ids"
) )
// TestSyncState_RefusesBackwardRegression is the regression test for the // TestSyncState_RefusesBackwardRegression pins the SyncState monotonic-height
// SyncState monotonic-height guard. SyncState bypasses markFinalizedLocked by // rule. SyncState bypasses markFinalizedLocked by design (an import is an
// design (an import is an out-of-band reconcile, not an α-of-K finalize), so the // out-of-band reconcile, not an α-of-K finalize), so the monotonic invariant must
// monotonic invariant must be re-asserted there explicitly: a backward import // be re-asserted there explicitly: a backward import (height below the
// (height below the already-finalized height) MUST be refused and MUST leave the // already-finalized height) is refused and leaves the finalized head untouched.
// finalized head untouched. Without the guard a shorter/older imported chain // Otherwise a shorter or older imported chain silently regresses finalizedHeight
// would silently regress finalizedHeight and un-finalize already-finalized // and un-finalizes finalized blocks, re-opening the fork window the per-height
// blocks — re-opening the fork window the per-height guard closes. // guard closes.
func TestSyncState_RefusesBackwardRegression(t *testing.T) { func TestSyncState_RefusesBackwardRegression(t *testing.T) {
c := NewChainConsensus(4, 3, 2) c := NewChainConsensus(4, 3, 2)
@@ -29,12 +29,12 @@ func TestSyncState_RefusesBackwardRegression(t *testing.T) {
t.Fatalf("seed finalize at height 100: %v", err) t.Fatalf("seed finalize at height 100: %v", err)
} }
// A backward import at height 99 must be REFUSED with ErrSyncStateRegression… // A backward import at height 99 must be refused with ErrSyncStateRegression…
older := ids.GenerateTestID() older := ids.GenerateTestID()
if err := c.SyncState(older, 99); !errors.Is(err, ErrSyncStateRegression) { if err := c.SyncState(older, 99); !errors.Is(err, ErrSyncStateRegression) {
t.Fatalf("backward SyncState(h=99) should return ErrSyncStateRegression, got: %v", err) t.Fatalf("backward SyncState(h=99) should return ErrSyncStateRegression, got: %v", err)
} }
// …and must NOT have regressed any finalized state (fail-closed no-op). // …and must not have regressed any finalized state (fail-closed no-op).
if got := c.GetFinalizedTip(); got != h100 { if got := c.GetFinalizedTip(); got != h100 {
t.Fatalf("finalized tip regressed: got %s want %s (height-100 head must be untouched)", got, h100) t.Fatalf("finalized tip regressed: got %s want %s (height-100 head must be untouched)", got, h100)
} }
@@ -49,14 +49,14 @@ func TestSyncState_RefusesBackwardRegression(t *testing.T) {
} }
} }
// TestSyncState_DifferentBlockIsHintNotEquivocation is the incident-1082814 PART-A // TestSyncState_DifferentBlockIsHintNotEquivocation pins what an import may and may
// regression at the SyncState level. The PRE-FIX code SEEDED FINALITY from // not conclude. Seeding finality from vm.LastAccepted makes a re-import at an
// vm.LastAccepted, so a re-import at the already-finalized height with a DIFFERENT // already-finalized height with a different block look like equivocation, which is
// block was treated as equivocation (the fatal path). That was the BUG: a local // fatal — but a local import is not a finality source, so it cannot witness a
// import is not a finality source. Under the fix, SyncState is a NON-AUTHORITATIVE // conflict. SyncState is a non-authoritative recovery hint: a different block at a
// recovery HINT — a different block at a certified height does NOT equivocate, does // certified height does not equivocate, does not touch the certified frontier, and
// NOT touch the certified frontier, and returns cleanly. Equivocation is decided // returns cleanly. Equivocation is decided exclusively by conflicting certs over
// EXCLUSIVELY by conflicting CERTS over canonical commitments, never by a seed. // canonical commitments.
func TestSyncState_DifferentBlockIsHintNotEquivocation(t *testing.T) { func TestSyncState_DifferentBlockIsHintNotEquivocation(t *testing.T) {
c := NewChainConsensus(4, 3, 2) c := NewChainConsensus(4, 3, 2)
@@ -70,14 +70,14 @@ func TestSyncState_DifferentBlockIsHintNotEquivocation(t *testing.T) {
t.Fatalf("SyncState(same block @100) should succeed, got: %v", err) t.Fatalf("SyncState(same block @100) should succeed, got: %v", err)
} }
// DIFFERENT block at the same certified height → a HINT, NOT equivocation. The // A different block at the same certified height is a hint, not equivocation:
// pre-fix code returned ErrHeightAlreadyFinalized here (the bug). The fix accepts // accepted as non-authoritative, certified finality untouched, no
// it as a non-authoritative hint and leaves certified finality untouched. // ErrHeightAlreadyFinalized.
other := ids.GenerateTestID() other := ids.GenerateTestID()
if err := c.SyncState(other, 100); err != nil { if err := c.SyncState(other, 100); err != nil {
t.Fatalf("SyncState(different block @100) must be a clean hint, got: %v", err) t.Fatalf("SyncState(different block @100) must be a clean hint, got: %v", err)
} }
// The CERTIFIED frontier is untouched: height stays 100, the canonical tip is // The certified frontier is untouched: height stays 100, the canonical tip is
// still h100's canonical (== h100 here, a non-wrapped block). // still h100's canonical (== h100 here, a non-wrapped block).
if h, set := c.GetFinalizedHeight(); !set || h != 100 { if h, set := c.GetFinalizedHeight(); !set || h != 100 {
t.Fatalf("certified height moved on a hint import: got (%d,%v) want (100,true)", h, set) t.Fatalf("certified height moved on a hint import: got (%d,%v) want (100,true)", h, set)
@@ -87,12 +87,12 @@ func TestSyncState_DifferentBlockIsHintNotEquivocation(t *testing.T) {
} }
} }
// TestSyncState_EmptyResetYieldsCleanGenesis is the LOW-2 regression: an empty import // TestSyncState_EmptyResetYieldsCleanGenesis pins the reset as a whole value: an empty
// head (ids.Empty, height 0) on a ledger ALREADY finalized above genesis resets finality // import head (ids.Empty, height 0) on a ledger already finalized above genesis resets
// to a CLEAN genesis VALUE — tip Empty, height 0, unset — never the prior half-reset that // finality to a clean genesis — tip Empty, height 0, unset. A half reset that nulls the
// nulled the tip while leaving height/byHeight stale (a tip-vs-height desync that wedged // tip while leaving height and byHeight stale desyncs tip from height, and pathFromTip
// every future cert in pathFromTip). The proof the desync is gone: a fresh cert finalizes // then seeks a tip that is not there, wedging every future cert. So the test finalizes a
// cleanly afterward instead of wedging. // fresh cert after the reset: a usable ledger is the observable proof of a whole reset.
func TestSyncState_EmptyResetYieldsCleanGenesis(t *testing.T) { func TestSyncState_EmptyResetYieldsCleanGenesis(t *testing.T) {
c := NewChainConsensus(4, 3, 2) c := NewChainConsensus(4, 3, 2)
@@ -116,21 +116,20 @@ func TestSyncState_EmptyResetYieldsCleanGenesis(t *testing.T) {
// post-reset ledger is usable rather than permanently stuck seeking an Empty tip. // post-reset ledger is usable rather than permanently stuck seeking an Empty tip.
next := ids.GenerateTestID() next := ids.GenerateTestID()
if _, err := c.FinalizeBranch(next, 7, ids.Empty); err != nil { if _, err := c.FinalizeBranch(next, 7, ids.Empty); err != nil {
t.Fatalf("post-reset finalize wedged (the LOW-2 desync): %v", err) t.Fatalf("post-reset finalize wedged on a tip-vs-height desync: %v", err)
} }
if got := c.GetFinalizedTip(); got != next { if got := c.GetFinalizedTip(); got != next {
t.Fatalf("post-reset finalize did not advance: got %s want %s", got, next) t.Fatalf("post-reset finalize did not advance: got %s want %s", got, next)
} }
} }
// TestSyncState_ForwardImportAdvancesBuildAnchorNotFinality verifies the corrected // TestSyncState_ForwardImportAdvancesBuildAnchorNotFinality splits the two things a
// (incident-1082814 PART-A) semantics of a forward import: it advances the BUILD // forward import moves. It advances the build anchor (where the VM builds —
// ANCHOR (where the VM builds — GetFinalizedTip) so the node builds on the imported // GetFinalizedTip) so the node builds on the imported head, and it does not advance
// head, but it does NOT advance CERTIFIED finality. vm.LastAccepted / import is a // certified finality: vm.LastAccepted is a recovery hint, so the certified height
// recovery HINT, never a finality source: the certified height stays at the last // stays at the last QC-backed height and byHeight gains no entry at the imported
// QC-backed height and byHeight gains NO entry at the imported height (only a cert // height, which only a cert may write. Finality at that height arrives later through
// can write there). Finality at the imported height is established later via the // the bootstrap/cert path.
// bootstrap/cert path, not by the import claiming it.
func TestSyncState_ForwardImportAdvancesBuildAnchorNotFinality(t *testing.T) { func TestSyncState_ForwardImportAdvancesBuildAnchorNotFinality(t *testing.T) {
c := NewChainConsensus(4, 3, 2) c := NewChainConsensus(4, 3, 2)
@@ -144,24 +143,24 @@ func TestSyncState_ForwardImportAdvancesBuildAnchorNotFinality(t *testing.T) {
if err := c.SyncState(h5000, 5000); err != nil { if err := c.SyncState(h5000, 5000); err != nil {
t.Fatalf("forward SyncState(h=5000) should succeed, got: %v", err) t.Fatalf("forward SyncState(h=5000) should succeed, got: %v", err)
} }
// BUILD ANCHOR advances to the imported head (the forward hint outranks the lower // The build anchor advances to the imported head (the forward hint outranks the
// certified tip) so the VM builds where the node has state. // lower certified tip) so the VM builds where the node has state.
if got := c.GetFinalizedTip(); got != h5000 { if got := c.GetFinalizedTip(); got != h5000 {
t.Fatalf("forward import did not advance the build anchor: got %s want %s", got, h5000) t.Fatalf("forward import did not advance the build anchor: got %s want %s", got, h5000)
} }
// CERTIFIED finality does NOT advance — an import is a hint, never a finality // Certified finality does not advance — an import is a hint, never a finality
// source. The certified height stays 100 (the last QC-backed finalize). // source. The certified height stays 100 (the last QC-backed finalize).
if h, set := c.GetFinalizedHeight(); !set || h != 100 { if h, set := c.GetFinalizedHeight(); !set || h != 100 {
t.Fatalf("forward import wrongly advanced CERTIFIED height: got (%d,%v) want (100,true)", h, set) t.Fatalf("forward import wrongly advanced the certified height: got (%d,%v) want (100,true)", h, set)
} }
// No certified ledger entry was written at the imported height (only a cert can). // No certified ledger entry was written at the imported height (only a cert can).
if _, ok := c.FinalizedBlockAtHeight(5000); ok { if _, ok := c.FinalizedBlockAtHeight(5000); ok {
t.Fatalf("forward import wrote a CERTIFIED ledger entry at 5000 — a hint must not finalize") t.Fatalf("forward import wrote a certified ledger entry at 5000 — a hint must not finalize")
} }
} }
// TestSyncState_EmptyResetAllowed verifies that an explicit genesis/empty reset // TestSyncState_EmptyResetAllowed verifies that an explicit genesis/empty reset
// (lastAcceptedID == ids.Empty) is NOT treated as a regression — it is a // (lastAcceptedID == ids.Empty) is not treated as a regression — it is a
// deliberate teardown, distinct from a backward import of a concrete head. // deliberate teardown, distinct from a backward import of a concrete head.
func TestSyncState_EmptyResetAllowed(t *testing.T) { func TestSyncState_EmptyResetAllowed(t *testing.T) {
c := NewChainConsensus(4, 3, 2) c := NewChainConsensus(4, 3, 2)
@@ -179,14 +178,13 @@ func TestSyncState_EmptyResetAllowed(t *testing.T) {
} }
} }
// TestSyncState_EmptyHeadWithPositiveHeightRefused is the INFO-6 regression. An // TestSyncState_EmptyHeadWithPositiveHeightRefused pins the one import shape that
// EMPTY import head paired with a POSITIVE height is contradictory (an empty head // contradicts itself. An empty head is the genesis/teardown reset and is meaningful
// is the genesis/teardown reset, valid only at height 0). If allowed it would set // only at height 0; paired with a positive height it would set finalizedTip=Empty
// finalizedTip=Empty while the seed branch is skipped (finalizedHeight stays // while skipping the seed branch, so finalizedHeight stays stale, and it would prune
// stale) AND prune blocks below the positive height — the exact // every block below that height — the same finalizedTip-vs-finalizedHeight desync
// finalizedTip-vs-finalizedHeight desync ForcePreference was hardened against. // ForcePreference is hardened against. SyncState refuses it fail-closed, leaving
// SyncState must REFUSE it fail-closed, leaving finalized state AND the block pool // finalized state and the block pool untouched.
// untouched (no pruning).
func TestSyncState_EmptyHeadWithPositiveHeightRefused(t *testing.T) { func TestSyncState_EmptyHeadWithPositiveHeightRefused(t *testing.T) {
c := NewChainConsensus(4, 3, 2) c := NewChainConsensus(4, 3, 2)
@@ -196,7 +194,7 @@ func TestSyncState_EmptyHeadWithPositiveHeightRefused(t *testing.T) {
t.Fatalf("seed finalize at height 100: %v", err) t.Fatalf("seed finalize at height 100: %v", err)
} }
// Seed a low-height block in the pool so we can prove the refused import does // Seed a low-height block in the pool so we can prove the refused import does
// NOT prune it (the desync path would delete every block below the height). // not prune it (the desync path deletes every block below the height).
low := &Block{id: ids.GenerateTestID(), height: 50} low := &Block{id: ids.GenerateTestID(), height: 50}
if err := c.AddBlock(context.Background(), low); err != nil { if err := c.AddBlock(context.Background(), low); err != nil {
t.Fatalf("seed low block @50: %v", err) t.Fatalf("seed low block @50: %v", err)
@@ -217,7 +215,7 @@ func TestSyncState_EmptyHeadWithPositiveHeightRefused(t *testing.T) {
if existing, ok := c.FinalizedBlockAtHeight(100); !ok || existing != h100 { if existing, ok := c.FinalizedBlockAtHeight(100); !ok || existing != h100 {
t.Fatalf("refused import corrupted the per-height ledger at 100: ok=%v existing=%s", ok, existing) t.Fatalf("refused import corrupted the per-height ledger at 100: ok=%v existing=%s", ok, existing)
} }
// The low-height block MUST still be present — the refused import pruned nothing. // The low-height block must still be present — the refused import pruned nothing.
if _, ok := c.GetBlock(low.id); !ok { if _, ok := c.GetBlock(low.id); !ok {
t.Fatal("refused empty-at-height import pruned a live block below the height (the desync the guard prevents)") t.Fatal("refused empty-at-height import pruned a live block below the height (the desync the guard prevents)")
} }
+120 -125
View File
@@ -1,32 +1,32 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved. // Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms. // See the file LICENSE for licensing terms.
// topological.go — the PREFERENCE LAYER, the mutable, sibling-tolerant block tree. // topological.go — the preference layer, the mutable, sibling-tolerant block tree.
// //
// This mirrors the upstream linear-chain consensus's topological layer: the live block // This is the topological half of a linear-chain engine: the live block tree
// tree (blocks/tips), the build preference, and the vote/poll surface that drives // (blocks/tips), the build preference, and the vote/poll surface that drives each
// each block's confidence counter. It is the half of the preference tree that // block's confidence counter. It is the half that stays mutable — siblings coexist,
// stays MUTABLE — siblings coexist, votes accumulate, preference moves. The OTHER // votes accumulate, preference moves. The other half — the committed-prefix advance
// half of the preference tree — the committed-prefix advance (lastAcceptedID / // (lastAcceptedID / acceptPreferredChild / rejectTransitively, β-driven in a
// acceptPreferredChild / rejectTransitively, β-driven there) — is decomplected out: // classical engine) — is decomplected out: here it is cert-driven and lives as the
// in Lux it is CERT-driven and lives as the pure fold in ledger.go, applied by the // pure fold in ledger.go, applied by the shell in consensus.go. Same tree shape;
// shell in consensus.go. Same tree shape; finality trigger swapped from β to cert. // finality trigger swapped from β to cert.
// //
// Method roles: // Method roles:
// //
// Block / *Block -> the upstream tree-node block // Block / *Block -> the tree-node block
// AddBlock -> Add (admit a child of a known block) // AddBlock -> admit a child of a known block
// ProcessVote / Poll -> RecordPoll (accumulate votes into the per-block // ProcessVote / Poll -> accumulate votes into the per-block confidence
// confidence driver; LIVENESS only — finality is the cert) // driver; liveness only — finality is the cert
// IsAccepted / IsRejected -> block status (Decided) // IsAccepted / IsRejected -> block status (decided)
// GetBlock -> blocks[id] lookup (Processing) // GetBlock -> blocks[id] lookup (processing)
// Preference / ForcePreference -> Preference / preferredIDs (the build tail) // Preference / ForcePreference -> the build tail
// EpochHeightOf -> (Lux-only) the block's pinned P-chain epoch height // EpochHeightOf -> the block's pinned P-chain epoch height
// ancestry / blocksAncestry -> GetParent + children, exposed as the read-only // ancestry / blocksAncestry -> parent + children, exposed as the read-only
// Ancestry the pure Finalize fold reads // Ancestry the pure Finalize fold reads
// //
// The Photon -> Wave -> Focus per-block driver (engine.Driver) is the // The Photon -> Wave -> Focus per-block driver (engine.Driver) is the per-node
// per-node confidence instance — orthogonal to the tree and kept AS-IS. // confidence instance — orthogonal to the tree and untouched by it.
package chain package chain
import ( import (
@@ -37,9 +37,9 @@ import (
"github.com/luxfi/ids" "github.com/luxfi/ids"
) )
// Block represents a block in the chain — the preference tree's node (the upstream tree // Block represents a block in the chain — the preference tree's node. Tracks the
// node). Tracks the value-chain linkage (id/parent/height), the pinned // value-chain linkage (id/parent/height), the pinned P-chain epoch, the per-block
// P-chain epoch, the per-block Photon->Wave->Focus driver, and the decided flags. // Photon->Wave->Focus driver, and the decided flags.
type Block struct { type Block struct {
id ids.ID id ids.ID
parentID ids.ID parentID ids.ID
@@ -48,38 +48,37 @@ type Block struct {
data []byte data []byte
// canonicalID / parentCanonicalID / execStateRoot / payloadRoot are the inner // canonicalID / parentCanonicalID / execStateRoot / payloadRoot are the inner
// EXECUTION commitment (the incident-1082814 canonical identity). For a // execution commitment — the block's canonical identity. For a proposervm-wrapped
// proposervm-wrapped block these are the inner block's identity; for a bare block // block these are the inner block's identity; for a bare block canonicalID == id
// canonicalID == id (graceful degrade). canonicalID is what finality, the // (graceful degrade). canonicalID is what finality, the per-height equivocation
// per-height equivocation index, and the cert position all key on; the outer // index, and the cert position all key on; the outer id/parentID stay the
// id/parentID stay the transport/DAG keys. ids.Empty roots mean "not exposed". // transport/DAG keys. ids.Empty roots mean "not exposed".
canonicalID ids.ID canonicalID ids.ID
parentCanonicalID ids.ID parentCanonicalID ids.ID
execStateRoot ids.ID execStateRoot ids.ID
payloadRoot ids.ID payloadRoot ids.ID
// pChainHeight is the P-CHAIN epoch height the block's weighted validator set // pChainHeight is the P-chain epoch height the block's weighted validator set is
// is pinned to (MEDIUM-1 / CRITICAL-1): the set-root commitment, the ⅔-by-stake // pinned to: the set-root commitment, the ⅔-by-stake tally, and the per-voter
// tally, AND the per-voter pubkey resolution are ALL read from the height- // pubkey resolution all read the height-indexed validators.State at this height,
// indexed validators.State at THIS height — never the value-chain `height`. // never the value-chain `height`. The two are different quantities.
// They differ fundamentally: platformvm.GetValidatorSet interprets its argument // platformvm.GetValidatorSet interprets its argument as a P-chain height and
// as a P-CHAIN height and returns errUnfinalizedHeight when the current P-chain // returns errUnfinalizedHeight once that argument exceeds the current P-chain
// height < the argument, and the value-chain height races far ahead of the // height, and the value-chain height races ahead of the P-chain height on a busy
// P-chain height on a busy chain — feeding `height` there yields an empty set // chain. Passing `height` there therefore resolves an empty validator set, and no
// and stalls finality FOREVER (the mainnet-bricking bug this fixes). // tally over an empty set can ever reach ⅔, so finality stops for good.
// //
// Source: a proposervm signed block carries its PChainHeight // Source: a proposervm signed block carries its PChainHeight
// (block.SignedBlock.PChainHeight); pChainHeightOf reads it off the VM block at // (block.SignedBlock.PChainHeight); pChainHeightOf reads it off the VM block at
// the engine boundary. When the block does NOT expose one (the VM is not // the engine boundary. When the block does not expose one (the VM is not
// proposervm-wrapped at the engine boundary, which is the case for the current // proposervm-wrapped at the engine boundary, which is the case for the current
// in-process chain stack), this is 0 → the set is read at P-chain height 0, the // in-process chain stack), this is 0 → the set is read at P-chain height 0, the
// GENESIS validator set. That is non-empty, identical on every node (everyone // genesis validator set. That is non-empty, identical on every node (everyone
// agrees on genesis), and ≤ the current P-chain height, so finality is LIVE and // agrees on genesis), and ≤ the current P-chain height, so finality stays live and
// consistent — and EXACT for any chain whose validator set is unchanged since // consistent — and exact for any chain whose validator set is unchanged since
// genesis. The refinement that pins post-genesis STAKING-CHANGE epochs requires // genesis. Pinning post-genesis staking-change epochs instead needs the proposervm
// the proposervm to deliver its PChainHeight to the engine's block (the // to deliver its PChainHeight to the engine's block; the mechanism here is covered
// remaining (b2) wiring); the mechanism here is proven by // by TestPChainEpochFinality_RealWiring, which feeds a block that carries one.
// TestPChainEpochFinality_RealWiring, which feeds a block that DOES carry it.
pChainHeight uint64 pChainHeight uint64
// Consensus state - Photon -> Wave -> Focus finality // Consensus state - Photon -> Wave -> Focus finality
@@ -87,31 +86,30 @@ type Block struct {
accepted bool accepted bool
rejected bool rejected bool
// acceptVoters / rejectVoters ARE the tally. α means "α DISTINCT validators // acceptVoters / rejectVoters are the tally. α means "α distinct validators
// agreed"; a count that any single voter can advance by repeating itself does // agreed"; a plain counter any single voter can advance by repeating itself
// not mean that. ProcessVote used to take only (blockID, accept) — the voter // does not mean that — without the voter's identity this layer cannot tell one
// was not a parameter, so this layer could not tell one validator answering // validator answering four times from four validators answering once, and one
// four times from four validators answering once, and at α=4-of-5 one node // node alone carries a block past α. That takes no malice: polls are
// finalized a block alone. Polls ARE re-solicited and peers DO answer twice, // re-solicited and peers do answer twice.
// so it needed no malice; a peer that wanted it could just resend.
// //
// They are SETS, and acceptVotes()/rejectVotes() derive from them, so the // They are sets, and acceptVotes()/rejectVotes() derive from them, so the
// count cannot drift from the identities it claims to summarize. // count cannot drift from the identities it claims to summarize.
acceptVoters map[ids.NodeID]struct{} acceptVoters map[ids.NodeID]struct{}
rejectVoters map[ids.NodeID]struct{} rejectVoters map[ids.NodeID]struct{}
} }
// acceptVotes is the number of DISTINCT validators that accepted — the α predicate. // acceptVotes is the number of distinct validators that accepted — the α predicate.
func (b *Block) acceptVotes() int { return len(b.acceptVoters) } func (b *Block) acceptVotes() int { return len(b.acceptVoters) }
// rejectVotes is the number of DISTINCT validators that rejected. // rejectVotes is the number of distinct validators that rejected.
func (b *Block) rejectVotes() int { return len(b.rejectVoters) } func (b *Block) rejectVotes() int { return len(b.rejectVoters) }
// AddBlock admits a block into the preference tree. It // AddBlock admits a block into the preference tree. It is tracking-only and
// is tracking-only and PERMISSIVE: any child is admitted, siblings coexist, and the // permissive: any child is admitted, siblings coexist, and the new block becomes the
// new block becomes the sole build tip of its parent. Unknown-parent / fetch safety // sole build tip of its parent. Unknown-parent and fetch safety are enforced at
// is enforced at FINALIZE (the fold's ErrAncestorNotTracked), not here — tracking is // finalize (the fold's ErrAncestorNotTracked), not here — tracking is decomplected
// decomplected from finality. // from finality.
func (c *ChainConsensus) AddBlock(ctx context.Context, block *Block) error { func (c *ChainConsensus) AddBlock(ctx context.Context, block *Block) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -137,10 +135,10 @@ func (c *ChainConsensus) AddBlock(ctx context.Context, block *Block) error {
return nil return nil
} }
// ProcessVote records one vote into a block's Photon->Wave->Focus driver // ProcessVote records one vote into a block's Photon->Wave->Focus driver (per
// (per block). Reaching the α accept count sets the LIVENESS // block). Reaching the α accept count sets the liveness flag block.accepted — the
// flag block.accepted — the engine's DrainAccepted trigger — but NEVER advances the // engine's DrainAccepted trigger — and never advances the committed ledger. Finality
// committed ledger. Finality is the cert fold's job alone. // is the cert fold's job alone.
func (c *ChainConsensus) ProcessVote(ctx context.Context, blockID ids.ID, voter ids.NodeID, accept bool) error { func (c *ChainConsensus) ProcessVote(ctx context.Context, blockID ids.ID, voter ids.NodeID, accept bool) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -154,10 +152,10 @@ func (c *ChainConsensus) ProcessVote(ctx context.Context, blockID ids.ID, voter
return fmt.Errorf("block not initialized for consensus") return fmt.Errorf("block not initialized for consensus")
} }
// Track both accept and reject votes — ONE PER VALIDATOR. A repeat from a // Track both accept and reject votes — one per validator. A repeat from a
// voter already counted is not an error and not evidence of anything (polls // voter already counted is not an error and not evidence of anything (polls
// are re-solicited), it simply must not move the tally a second time. The // are re-solicited), it simply must not move the tally a second time. The
// driver is likewise only advanced on a voter's FIRST accept, so confidence // driver is likewise only advanced on a voter's first accept, so confidence
// cannot be self-assembled either. // cannot be self-assembled either.
if accept { if accept {
if _, counted := block.acceptVoters[voter]; counted { if _, counted := block.acceptVoters[voter]; counted {
@@ -168,16 +166,15 @@ func (c *ChainConsensus) ProcessVote(ctx context.Context, blockID ids.ID, voter
} }
block.acceptVoters[voter] = struct{}{} block.acceptVoters[voter] = struct{}{}
// CONFIDENCE ADVANCES PER SUCCESSFUL POLL, NEVER PER VOTE. // Confidence advances per successful poll, not per vote. β is the number of
// β is the number of consecutive polls that EACH reached α — that is what // consecutive polls that each reached α — that is what makes a decision worth
// makes a decision worth βα agreements. Calling RecordVote on every arriving // βα agreements. Recording a poll on every arriving vote collapses that to β
// vote collapses that to β agreements total: with K=5, α=4, β=2 a block // agreements total: at K=5, α=4, β=2 the driver decides after three distinct
// reached the driver's decision after THREE distinct accepts and VM.Accept // accepts, so VM.Accept runs on a block the α=4 quorum never carried and two
// ran while the α=4 quorum had never been met. Two of five validators could // of five validators settle it, far under the threshold the parameters name.
// decide a block — far below the BFT threshold the parameters promise.
// //
// Gating on the α predicate restores the floor: no confidence accrues, and // Testing the α predicate first restores the floor: no confidence accrues,
// so nothing can be decided, until α DISTINCT validators have accepted. // and so nothing can be decided, until α distinct validators have accepted.
if len(block.acceptVoters) >= c.alpha { if len(block.acceptVoters) >= c.alpha {
block.driver.RecordVote(blockID) block.driver.RecordVote(blockID)
} }
@@ -191,10 +188,10 @@ func (c *ChainConsensus) ProcessVote(ctx context.Context, blockID ids.ID, voter
block.rejectVoters[voter] = struct{}{} block.rejectVoters[voter] = struct{}{}
} }
// LIVENESS only (decomplected from finality). Reaching the α accept count marks // Liveness only (decomplected from finality). Reaching the α accept count marks
// the block worth a finalize ATTEMPT — block.accepted is the engine's // the block worth a finalize attempt — block.accepted is the engine's
// DrainAccepted trigger — but it does NOT advance the per-height ledger. Finality // DrainAccepted trigger — but it does not advance the per-height ledger. Finality
// is committed ONLY by the cert-driven FinalizeBranch (the α-of-K SIGNED witness), // is committed only by the cert-driven FinalizeBranch (the α-of-K signed witness),
// which also performs the sibling reorg. A sibling reaching α-count here is // which also performs the sibling reorg. A sibling reaching α-count here is
// harmless: the cert decides which branch finalizes, and the loser is pruned. // harmless: the cert decides which branch finalizes, and the loser is pruned.
if block.acceptVotes() >= c.alpha && !block.accepted { if block.acceptVotes() >= c.alpha && !block.accepted {
@@ -211,9 +208,9 @@ func (c *ChainConsensus) ProcessVote(ctx context.Context, blockID ids.ID, voter
return nil return nil
} }
// Poll conducts a consensus poll over a batch of vote responses ( // Poll conducts a consensus poll over a batch of vote responses. It drives each
// RecordPoll). It drives each block's Wave->Focus driver; convergence + the α accept // block's Wave->Focus driver; convergence plus the α accept count sets the liveness
// count sets the LIVENESS flag only. Finality (and the reorg) is the cert path. // flag only. Finality (and the reorg) is the cert path.
func (c *ChainConsensus) Poll(ctx context.Context, responses map[ids.ID]int) error { func (c *ChainConsensus) Poll(ctx context.Context, responses map[ids.ID]int) error {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -248,10 +245,10 @@ func (c *ChainConsensus) Poll(ctx context.Context, responses map[ids.ID]int) err
shouldContinue := block.driver.Poll(blockResponses) shouldContinue := block.driver.Poll(blockResponses)
decided := block.driver.Decided() decided := block.driver.Decided()
// Focus convergence + α accept count → LIVENESS: mark the block worth a // Focus convergence plus the α accept count is liveness: the block is
// finalize attempt. Finality (and the reorg) is the cert path's job // worth a finalize attempt. Finality (and the reorg) belongs to the cert
// (FinalizeBranch), never the count path's — so the count never advances // path (FinalizeBranch), not the count path — so the count neither
// the ledger nor branches it. // advances the ledger nor branches it.
if !shouldContinue && decided && block.acceptVotes() >= c.alpha { if !shouldContinue && decided && block.acceptVotes() >= c.alpha {
block.accepted = true block.accepted = true
} }
@@ -287,11 +284,10 @@ func (c *ChainConsensus) IsRejected(blockID ids.ID) bool {
return block.rejected return block.rejected
} }
// Preference returns the FINALITY preference — the cert-selected/finalized tip // Preference returns the finality preference — the cert-selected finalized tip. It
//. It stays at the finalized tip until a // stays there until a quorum cert selects a child; a build-tip move does not advance
// quorum cert selects a child; a mere build-tip move does NOT advance it. This is // it. This is the finality-reporting concern, distinct from the build target (see
// the finality-reporting concern and MUST NOT be conflated with the build target // PreferredBuildTip) — the conformance suite pins this contract.
// (see PreferredBuildTip) — the conformance suite pins this contract.
func (c *ChainConsensus) Preference() ids.ID { func (c *ChainConsensus) Preference() ids.ID {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@@ -311,21 +307,20 @@ func (c *ChainConsensus) Preference() ids.ID {
return ids.Empty return ids.Empty
} }
// PreferredBuildTip returns the deterministic BUILD target — the deepest VERIFIED // PreferredBuildTip returns the deterministic build target — the deepest verified
// block extending the finalized chain (NOT merely the finalized tip). The node // block extending the finalized chain, which is not in general the finalized tip.
// steers the VM to build its next block on THIS, so when a verified-but-unfinalized // The node steers the VM to build its next block on this, so when a verified but
// block exists at height H every validator builds H+1 ON TOP of it and they // unfinalized block exists at height H every validator builds H+1 on top of it and
// CONVERGE — instead of each building a competing sibling at height H, which splits // they converge. Building on the finalized tip instead gives each proposer a
// the α-of-K vote so no single block ever reaches the cert and the chain HALTS the // competing sibling at height H, which splits the α-of-K vote, so no single block
// moment one proposer is down (the down-leader liveness bug). Sibling ties break on // reaches a cert and the chain stops the moment one proposer is down. Sibling ties
// lowest block ID so every node with the same block tree picks the SAME chain. // break on lowest block ID, so every node holding the same tree picks the same chain.
// //
// This is the DECOMPLECTED build concern (separate from the finality Preference): // This is the build concern, decomplected from the finality Preference: a hint only.
// it is a BUILD hint only — finality stays governed exclusively by the α-of-K cert // Finality is governed exclusively by the α-of-K cert folded into c.ledger
// folded into c.ledger (applyCertLocked), so advancing the build target past the // (applyCertLocked), so advancing the build target past the finalized tip decides
// finalized tip touches no finality decision and cannot affect safety, only // nothing and can affect liveness but not safety — the same way a linear-chain
// liveness — exactly as the upstream linear-chain consensus steers the VM to its preferred // engine steers the VM to its preferred non-finalized tip.
// non-finalized tip.
func (c *ChainConsensus) PreferredBuildTip() ids.ID { func (c *ChainConsensus) PreferredBuildTip() ids.ID {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()
@@ -333,7 +328,7 @@ func (c *ChainConsensus) PreferredBuildTip() ids.ID {
} }
// buildTipLocked descends from the finalized tip (or, before the first finalize, // buildTipLocked descends from the finalized tip (or, before the first finalize,
// the preliminary preference, else the lowest-ID tracked tip) through VERIFIED, // the preliminary preference, else the lowest-ID tracked tip) through verified,
// non-rejected children — choosing the lowest-ID child at each level — and returns // non-rejected children — choosing the lowest-ID child at each level — and returns
// the first block with no such child: the deterministic build tip. The descent is // the first block with no such child: the deterministic build tip. The descent is
// bounded by the tracked-block count so a malformed tree can never spin forever. // bounded by the tracked-block count so a malformed tree can never spin forever.
@@ -387,10 +382,10 @@ func (c *ChainConsensus) GetBlock(blockID ids.ID) (*Block, bool) {
return block, exists return block, exists
} }
// EpochHeightOf returns the P-CHAIN epoch height recorded for a tracked block // EpochHeightOf returns the P-chain epoch height recorded for a tracked block
// (the height its weighted validator set, set-root, and ⅔-stake tally are pinned // (the height its weighted validator set, set-root, and ⅔-stake tally are pinned
// to — Block.pChainHeight), and whether the block is tracked at all. It is the // to — Block.pChainHeight), and whether the block is tracked at all. It is the
// SINGLE authoritative read of "what epoch did we record for this block", used by // single authoritative read of "what epoch did we record for this block", used by
// the receive-side monotonicity gate to reject a child whose stamped epoch // the receive-side monotonicity gate to reject a child whose stamped epoch
// regresses below its parent's recorded epoch (the far-past attack: a Byzantine // regresses below its parent's recorded epoch (the far-past attack: a Byzantine
// proposer stamps a stale H where its old coalition held ≥⅔). A miss (false) means // proposer stamps a stale H where its old coalition held ≥⅔). A miss (false) means
@@ -407,17 +402,17 @@ func (c *ChainConsensus) EpochHeightOf(blockID ids.ID) (uint64, bool) {
} }
// ForcePreference reaffirms the engine's preferred tip after a VM SetPreference // ForcePreference reaffirms the engine's preferred tip after a VM SetPreference
// failure. It is a recovery mechanism used when SetPreference fails AFTER a block // failure. It exists for the case where SetPreference fails after a block was
// was accepted — without it the VM and consensus engine could disagree on the // accepted: left alone, the VM and the engine hold different chain tips and each
// chain tip, causing a state-divergence death spiral. Every legitimate caller invokes // subsequent block widens the divergence. Every legitimate caller passes the block
// it with the block that was JUST finalized ("SetPreference failed after Accept"), so // that was just finalized, so the block is already the finalized tip and this is a
// the block is already the finalized tip and this is a reaffirming no-op. // reaffirming no-op.
// //
// The committed ledger tip is advanced ONLY by the cert fold (applyCertLocked); the // The committed ledger tip advances only through the cert fold (applyCertLocked);
// reorg is the sole authority on finalized history. So this method never moves the // the reorg is the sole authority on finalized history. So this method never moves
// finalized tip — with the per-height ADMISSION gate gone, there is no invariant for a // the finalized tip, and nothing here keys on preference, so a stray preference has
// stray preference to corrupt. It only adopts blockID as the preliminary build // no invariant to corrupt. It adopts blockID as the preliminary build preference
// preference before the FIRST finalize, and always records it as a build tip. // before the first finalize, and always records it as a build tip.
func (c *ChainConsensus) ForcePreference(blockID ids.ID) { func (c *ChainConsensus) ForcePreference(blockID ids.ID) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
@@ -429,7 +424,7 @@ func (c *ChainConsensus) ForcePreference(blockID ids.ID) {
} }
// ancestry exposes the live block tree to the pure fold as a read-only Ancestry. The // ancestry exposes the live block tree to the pure fold as a read-only Ancestry. The
// fold reads parent/height links and sibling children through this view ONLY; it never // fold reads parent/height links and sibling children through this view alone; it never
// mutates the DAG. Caller holds c.mu (the view is used only within the locked fold). // mutates the DAG. Caller holds c.mu (the view is used only within the locked fold).
func (c *ChainConsensus) ancestry() Ancestry { func (c *ChainConsensus) ancestry() Ancestry {
return blocksAncestry{blocks: c.blocks, recall: c.recall} return blocksAncestry{blocks: c.blocks, recall: c.recall}
@@ -446,14 +441,14 @@ type blocksAncestry struct {
// at resolves a block for the walk: from the live tree first, and failing that from // at resolves a block for the walk: from the live tree first, and failing that from
// the VM's own accepted chain. // the VM's own accepted chain.
// //
// The live tree holds what THIS PROCESS has seen. The finalized tip it walks up from // The live tree holds what this process has seen; the finalized tip it walks up from
// is durable. Those two disagree after any restart — the node comes back knowing it // is durable. The two disagree after any restart — the node comes back knowing it
// finalized through height H and remembering nothing above it, while its VM still // finalized through height H and remembering nothing above it, while its VM still
// holds every block it ever accepted. Without the second lookup the walk cannot cross // holds every block it ever accepted. Without the second lookup the walk cannot cross
// that seam, so a valid cert for a block above H is refused for a missing ancestor // that boundary, so a valid cert for a block above H is refused for a missing ancestor
// the node is in fact holding, and the gap only widens with the next restart. // the node is in fact holding, and the gap only widens with the next restart.
// //
// Reading it back from the VM is the STRONGER source, not a weaker one: the VM's // Reading it back from the VM is the stronger source, not a weaker one: the VM's
// chain is what this node actually executed and committed, where the live tree is // chain is what this node actually executed and committed, where the live tree is
// populated from gossip. Nothing here decides finality — the cert has already cleared // populated from gossip. Nothing here decides finality — the cert has already cleared
// the ⅔-by-stake predicate on its own — so this only supplies the parent links that // the ⅔-by-stake predicate on its own — so this only supplies the parent links that
@@ -480,9 +475,9 @@ func (a blocksAncestry) Parent(id ids.ID) (ids.ID, uint64, ids.ID, ids.ID, bool)
} }
// WrapperByCanonical resolves an inner execution commitment at a height to a // WrapperByCanonical resolves an inner execution commitment at a height to a
// locally-tracked OUTER wrapper of it — any alias — so the finality walk can collapse a // locally-tracked outer wrapper of it — any alias — so the finality walk can collapse a
// sibling wrapper in place of the exact envelope a cert named (the intermediate ancestor // sibling wrapper in place of the exact envelope a cert named (the intermediate ancestor
// the node holds under a DIFFERENT proposervm wrapper). It prefers an already-accepted // the node holds under a different proposervm wrapper). It prefers an already-accepted
// wrapper when several tracked aliases exist, so the walk lands on the block the VM // wrapper when several tracked aliases exist, so the walk lands on the block the VM
// committed. A miss (ok=false) means the node holds NO wrapper of that inner block, // committed. A miss (ok=false) means the node holds NO wrapper of that inner block,
// preserving the fail-closed behind-node defer (ErrAncestorNotTracked). This is a rare // preserving the fail-closed behind-node defer (ErrAncestorNotTracked). This is a rare
+163 -146
View File
@@ -1,19 +1,18 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved. // Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms. // See the file LICENSE for licensing terms.
// topology.go — the vote/cert distribution handlers that make α-of-K // The vote/cert distribution handlers that make α-of-K finality both safe and live.
// finality both SAFE and LIVE.
// //
// The topology in one paragraph: a follower verifies a gossiped block and // The topology in one paragraph: a follower verifies a gossiped block and
// BROADCASTS its signed accept vote to ALL validators (followVerifiedBlock, // broadcasts its signed accept vote to every validator (followVerifiedBlock,
// integration.go). Every validator feeds incoming signed votes into the engine // integration.go). Each validator feeds incoming signed votes into the engine
// (HandleIncomingVote), which collects them toward a QuorumCert. Whichever node // (HandleIncomingVote), which collects them toward a QuorumCert. Whichever node
// first collects α distinct verified votes assembles the cert, GOSSIPS it // first collects α distinct verified votes assembles the cert and gossips it
// (tryFinalizeBlock), and every node finalizes the block on receipt of that // (tryFinalizeBlock); every node finalizes the block on receipt of that
// verifiable proof (HandleIncomingCert). No node finalizes without a cert // verifiable proof (HandleIncomingCert). No node finalizes without a cert —
// (safety); the cert reaches everyone via vote-broadcast + cert-gossip + the // that is safety. The cert reaches everyone by three independent routes
// poll-timeout re-request, so finality never hinges on one node's inbound Chits // (vote-broadcast, cert-gossip, the poll-timeout re-request), so finality never
// (liveness — the proposer-freeze cannot recur). // depends on any single node's inbound chits — that is liveness.
package chain package chain
import ( import (
@@ -38,7 +37,7 @@ var ErrVoteWireCorrupt = errors.New("chain: signed vote wire corrupt")
// //
// The signed vote travels with the blockID in the gossip envelope (the network // The signed vote travels with the blockID in the gossip envelope (the network
// message already names the chain + block), so the canonical message a verifier // message already names the chain + block), so the canonical message a verifier
// rebuilds is derived from the RECEIVER's tracked position for that block — a // rebuilds is derived from the receiver's tracked position for that block — a
// vote cannot smuggle a different position because the signature is checked // vote cannot smuggle a different position because the signature is checked
// against the receiver's own (chain,height,round,block,parent). // against the receiver's own (chain,height,round,block,parent).
@@ -74,7 +73,7 @@ func decodeSignedVote(data []byte) (ids.NodeID, []byte, error) {
// HandleIncomingVote ingests a signed accept vote received from another // HandleIncomingVote ingests a signed accept vote received from another
// validator's broadcast. The vote is bound to a blockID (carried by the gossip // validator's broadcast. The vote is bound to a blockID (carried by the gossip
// envelope); the engine rebuilds the canonical message from ITS OWN tracked // envelope); the engine rebuilds the canonical message from its own tracked
// position for that block and verifies the signature before counting the vote. // position for that block and verifies the signature before counting the vote.
// A vote for a block we are not tracking is dropped (we cannot know its // A vote for a block we are not tracking is dropped (we cannot know its
// position to verify against — the proposer's block gossip carries that, and // position to verify against — the proposer's block gossip carries that, and
@@ -106,12 +105,12 @@ func (rt *Runtime) HandleIncomingVote(blockID ids.ID, voteBytes []byte) bool {
return false return false
} }
if !exists { if !exists {
// The block is not pending. It may be a FINALIZED (Nova-accepted) block whose trailing // The block is not pending. It may be a finalized (Nova-accepted) block whose trailing
// ⅔-stake vote is still arriving — the ⅔-th stake vote necessarily follows the // ⅔-stake vote is still arriving — the ⅔-th stake vote necessarily follows the
// bare-majority accept — so route a verified accept for it to the late-attestation path // bare-majority accept — so route a verified accept for it to the late-attestation path
// (handleVote → attestFinalizedVote → the Quasar attestor) to complete the EXPORT cert. // (handleVote → attestFinalizedVote → the Quasar attestor) to complete the export cert.
// Verify against the REMEMBERED accepted position (the exact bytes the accept votes signed). // Verify against the remembered accepted position: the exact bytes the accept votes
// An unknown / aged-out block drops (unchanged behaviour). // signed. An unknown or aged-out block drops.
ap, remembered := t.lookupAcceptedPos(blockID) ap, remembered := t.lookupAcceptedPos(blockID)
if !remembered || !verifier.VerifyVote(nodeID, CanonicalVoteMessage(ap.pos), sig, ap.epoch) { if !remembered || !verifier.VerifyVote(nodeID, CanonicalVoteMessage(ap.pos), sig, ap.epoch) {
return false return false
@@ -127,9 +126,9 @@ func (rt *Runtime) HandleIncomingVote(blockID ids.ID, voteBytes []byte) bool {
return true return true
} }
// Verify the signature against OUR position for this block, resolving the // Verify the signature against our position for this block, resolving the
// voter's pubkey at the block's P-CHAIN epoch height (RESIDUAL-B). A vote that // voter's pubkey at the block's P-chain epoch height. A vote that signed a
// signed a different position (different height/round/parent/set-root) fails. // different position (different height/round/parent/set-root) fails.
if !verifier.VerifyVote(nodeID, CanonicalVoteMessage(pos), sig, epochHeight) { if !verifier.VerifyVote(nodeID, CanonicalVoteMessage(pos), sig, epochHeight) {
if !rt.config.Logger.IsZero() { if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("incoming vote: signature invalid", rt.config.Logger.Debug("incoming vote: signature invalid",
@@ -153,17 +152,17 @@ func (rt *Runtime) HandleIncomingVote(blockID ids.ID, voteBytes []byte) bool {
// HandleIncomingCert ingests a finality cert gossiped by another validator and, // HandleIncomingCert ingests a finality cert gossiped by another validator and,
// if it verifies as a valid α-of-K witness for a block we have verified, // if it verifies as a valid α-of-K witness for a block we have verified,
// finalizes that block. This is the SAFE replacement for fast-follow: a // finalizes that block. A follower commits a gossiped block only against a
// follower commits a gossiped block ONLY against a verifiable α-of-K proof. // verifiable α-of-K proof.
// //
// Safety gates: // What a cert must clear:
// - the cert must decode and Verify under our VoteVerifier (α distinct // - it must decode and Verify under our VoteVerifier (α distinct
// correctly-signed accepts over the cert's position), // correctly-signed accepts over the cert's position),
// - the cert's position chain must match ours, // - its position chain must match ours,
// - we must have VERIFIED the block locally (it is in pendingBlocks). We do // - we must have verified the block locally (it is in pendingBlocks). A block
// NOT accept a block whose contents we have not validated, even with a // whose contents we have not validated is not accepted even with a valid
// valid cert — the cert proves agreement, local Verify proves validity; // cert — the cert proves agreement, local Verify proves validity; finality
// both are required. // needs both.
// //
// Returns true iff the block was finalized as a result of this cert. // Returns true iff the block was finalized as a result of this cert.
func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool { func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
@@ -185,17 +184,17 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
if verifier == nil { if verifier == nil {
return false return false
} }
// The cert's threshold MUST meet our own floor FOR ITS TIER — a cert that asserts a // The cert's threshold must meet our own floor for its tier: a cert asserting a lower
// LOWER threshold than this chain requires for that tier is rejected even if its // threshold than this chain requires for that tier is rejected even if its internal
// internal signatures verify (a cheap sub-quorum forgery filter; the AUTHORITATIVE gate // signatures verify. This is a cheap sub-quorum forgery filter; the authority is
// is verifyCert → VerifyWeighted below, which re-derives the tier threshold from the // verifyCert → VerifyWeighted below, which re-derives the tier threshold from the
// validator set at the cert's epoch). A gossiped NOVA cert legitimately carries a // validator set at the cert's epoch. A gossiped Nova cert legitimately carries a
// bare-majority threshold BELOW the ⅔ Quasar floor, so the floor is tier-selected: // bare-majority threshold below the ⅔ Quasar floor, so the floor is tier-selected:
// NovaSignerFloor(K) for Nova, the ⅔ count Alpha() for Quasar. K/Alpha track the live // NovaSignerFloor(K) for Nova, the ⅔ count Alpha() for Quasar. K/Alpha track the live
// committee (construction clamp + reclampCommitteeLocked), so both floors follow the // committee (construction clamp + reclampCommitteeLocked), so both floors follow the
// live set. An unknown tier is left to verifyCert (Verify rejects it fail-closed). // live set. An unknown tier is left to verifyCert, which rejects fail-closed.
// //
// The Nova floor is the SIGNER floor, not the count majority: on a stake-weighted chain // The Nova floor is the signer floor, not the count majority: on a stake-weighted chain
// the Nova majority is measured in stake, so a legitimate cert's self-declared threshold // the Nova majority is measured in stake, so a legitimate cert's self-declared threshold
// is the signer floor and a count-majority pre-filter here would drop it before the // is the signer floor and a count-majority pre-filter here would drop it before the
// authoritative stake check ever ran. On an equal-stake chain the signer floor is ≤ the // authoritative stake check ever ran. On an equal-stake chain the signer floor is ≤ the
@@ -217,7 +216,7 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
return false return false
} }
// MED-5 HEIGHT GATE — reject the cert on height BEFORE any finalize work: // Height gate — reject on height before any finalize work:
// - a cert at or below the last-finalized height is stale or a fork attempt // - a cert at or below the last-finalized height is stale or a fork attempt
// (the height is already decided); and // (the height is already decided); and
// - a cert whose height does not match the height of the block we are // - a cert whose height does not match the height of the block we are
@@ -225,28 +224,29 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
// This is the cheap front-line check; FinalizeBranch (inside AcceptWithCert) // This is the cheap front-line check; FinalizeBranch (inside AcceptWithCert)
// is the authoritative backstop that also produces equivocation evidence. // is the authoritative backstop that also produces equivocation evidence.
if fh, set := t.consensus.GetFinalizedHeight(); set && cert.Position.Height <= fh { if fh, set := t.consensus.GetFinalizedHeight(); set && cert.Position.Height <= fh {
// Equivocation is decided on the CANONICAL commitment, NEVER the outer envelope // Equivocation is decided on the canonical commitment, not the outer envelope.
// (the incident-1082814 fix). A DIFFERENT canonical block already finalized at // Under anyone-can-propose every validator wraps the same inner execution block
// this height is a POTENTIAL fork; the SAME canonical id under a different // in its own envelope, so envelope identity says nothing about agreement: a
// envelope is a harmless DUPLICATE alias (and an identical envelope is a stale // different canonical block already finalized at this height is a potential
// replay). Either way the cert finalizes nothing new. // fork, while the same canonical id under a different envelope is a harmless
// alias (and an identical envelope is a stale replay). Either way the cert
// finalizes nothing new.
certCanonical := cert.Position.CanonicalID certCanonical := cert.Position.CanonicalID
if certCanonical == ids.Empty { if certCanonical == ids.Empty {
certCanonical = cert.Position.BlockID certCanonical = cert.Position.BlockID
} }
finCanonical, have := t.consensus.FinalizedBlockAtHeight(cert.Position.Height) finCanonical, have := t.consensus.FinalizedBlockAtHeight(cert.Position.Height)
if have && finCanonical != certCanonical { if have && finCanonical != certCanonical {
// HARD GATE on equivocation STATE (H1): a conflicting cert is fork EVIDENCE // A conflicting cert is fork evidence only if it is a verified QC, so run the
// only if it is a VERIFIED QC. Run the FULL predicate (α distinct in-set // full predicate (α distinct in-set signatures over the canonical position,
// signatures over the canonical position, stake-weighted when wired) BEFORE // stake-weighted when a stake source is wired) before recording any slashing
// recording ANY slashing evidence. The pre-fix code recorded a DoubleVote per // evidence. Naming a validator costs nothing to forge: junk signatures over a
// NAMED voter here before checking a single signature — so a forged cert (junk // random canonical at a decided height would otherwise jail honest validators
// signatures naming honest validators, a random different canonical, height ≤ // below quorum and halt the chain — the attack is cheaper than the fork it
// finalized) could jail honest validators below quorum and re-halt the chain. // claims to prove. Resolve the epoch from the tracked block if we have it; on a
// Resolve the epoch from the tracked block if we have it; on a fixed-set chain // fixed-set chain the verifier ignores epoch (equal-stake admission), and on a
// the verifier ignores epoch (equal-stake admission invariant), and on a // stake-weighted chain an unresolvable epoch fails verification and yields no
// stake-weighted chain an unresolvable epoch fails verification → no evidence // evidence. Fail-closed here means we may miss a slash, never invent one.
// (fail-closed: we never false-slash, we may miss-slash).
var epochHeight uint64 var epochHeight uint64
if exists && pending.ConsensusBlock != nil { if exists && pending.ConsensusBlock != nil {
epochHeight = pending.ConsensusBlock.pChainHeight epochHeight = pending.ConsensusBlock.pChainHeight
@@ -258,8 +258,8 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
} }
return false return false
} }
// A VERIFIED α-of-K cert over a DIFFERENT canonical at a decided height — a // A verified α-of-K cert over a different canonical at a decided height is a
// genuine, attributable equivocation. Now (and only now) it is evidence. // genuine, attributable equivocation. Now, and only now, it is evidence.
rt.reportCertEquivocation(cert, finCanonical) rt.reportCertEquivocation(cert, finCanonical)
} else if !rt.config.Logger.IsZero() { } else if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("incoming cert: height at/below finalized; dropping (duplicate or stale, not a fork)", rt.config.Logger.Debug("incoming cert: height at/below finalized; dropping (duplicate or stale, not a fork)",
@@ -278,20 +278,38 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
return false return false
} }
// Resolve the cert's epoch height (MEDIUM-1) from OUR locally-tracked, // Resolve the cert's epoch height from our locally-tracked, locally-verified
// locally-verified block — the proposervm P-chain height we recorded for this // block — the proposervm P-chain height we recorded for this block ID. Every
// block ID. Every honest node derives the same epoch height from the same // honest node derives the same epoch height from the same signed block, so the
// signed block, so the cert verifies against the IDENTICAL set/root/stake on // cert verifies against an identical set/root/stake everywhere.
// every node. A cert for a block we have not yet tracked is deferred below //
// (we cannot know its epoch — and we must not Accept an unverified block // A cert can name an envelope we do not track while we hold a DIFFERENT wrapper
// anyway), so for a stake-weighted finalize `exists` is required here. // of the same inner execution block (the storm alias handled below). The epoch
// is a property of the inner block, not of the outer envelope, so resolve it by
// canonical id in that case. Reading it off the envelope alone left the epoch at
// 0 — the genesis validator set — and on any chain whose set is not the height-0
// set every voter's pubkey and the whole stake tally resolved against the wrong
// committee, so the cert failed verification here and the alias cure below was
// unreachable exactly on the chains old enough to need it. No wrapper of the
// inner block at all leaves the epoch at 0, which is what a node that has never
// seen the block can honestly say.
var epochHeight uint64 var epochHeight uint64
if exists && pending.ConsensusBlock != nil { if exists && pending.ConsensusBlock != nil {
epochHeight = pending.ConsensusBlock.pChainHeight epochHeight = pending.ConsensusBlock.pChainHeight
} else {
canon := cert.Position.CanonicalID
if canon == ids.Empty {
canon = cert.Position.BlockID
}
t.mu.RLock()
if local, _ := t.pendingByCanonicalLocked(canon); local != nil {
epochHeight = t.epochHeightLocked(local)
}
t.mu.RUnlock()
} }
// Defence in depth (epoch binding): the cert's set-root MUST equal the set-root // Defence in depth (epoch binding): the cert's set-root must equal the set-root
// WE recompute at our epoch height for this block. The set-root is folded into // we recompute at our epoch height for this block. The set-root is folded into
// every signed vote, so a verifying cert already implies the signers agreed on // every signed vote, so a verifying cert already implies the signers agreed on
// it; this cross-check additionally rejects a cert whose epoch (set-root) does // it; this cross-check additionally rejects a cert whose epoch (set-root) does
// not match the epoch of the block we tracked under this ID — a cert laundered // not match the epoch of the block we tracked under this ID — a cert laundered
@@ -312,10 +330,10 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
} }
} }
// THE finality predicate — the SAME gate the equivocation-evidence path runs // The finality predicate — the same gate the equivocation-evidence path runs
// (verifyCert): α distinct in-set signatures over the canonical position, // (verifyCert): α distinct in-set signatures over the canonical position,
// stake-weighted to a strict ⅔ when a stake source is wired (HIGH-3), count-only // stake-weighted to a strict ⅔ when a stake source is wired, count-only
// otherwise (equal-stake admission invariant). A forged cert dies here. // otherwise (equal-stake admission). A forged cert dies here.
if verr := t.verifyCert(cert, epochHeight); verr != nil { if verr := t.verifyCert(cert, epochHeight); verr != nil {
if !rt.config.Logger.IsZero() { if !rt.config.Logger.IsZero() {
rt.config.Logger.Warn("incoming cert: verification failed", rt.config.Logger.Warn("incoming cert: verification failed",
@@ -324,26 +342,26 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
return false return false
} }
// Cert is a valid α-of-K finality proof. Finalize the block IF we have // The cert is a valid α-of-K finality proof. Finalize the block if we have
// verified it locally. If we do NOT track the block (an eclipsed follower that // verified it locally. If we do not track the block (an eclipsed follower that
// adopted a losing sibling envelope, or a node behind the frontier), we cannot // adopted a losing sibling envelope, or a node behind the frontier), we cannot
// safely Accept it — but we must NOT silently drop a VERIFIED finality proof // safely Accept it — and silently dropping a verified finality proof leaves the
// either (M1). Trigger a throttled, best-effort catch-up fetch for the certified // node stuck with no way back. Trigger a throttled, best-effort catch-up fetch
// block so finalization resumes once a reachable peer serves it; the fetch is // for the certified block so finalization resumes once a reachable peer serves
// gated on the cert having VERIFIED above, so a forged cert can never make us // it. The fetch is reachable only after the cert verified above, so a forged
// fetch arbitrary ids. Peer selection is the node layer's job (EmptyNodeID ⇒ // cert can never make us fetch arbitrary ids. Peer selection is the node layer's
// sample a peer); claimCatchupLocked rate-limits the request. // job (EmptyNodeID ⇒ sample a peer); claimCatchupLocked rate-limits the request.
if !exists { if !exists {
// STORM-ALIAS UNFREEZE (mainnet 1085755). The cert VERIFIED as a valid α-of-K // The cert verified as a valid α-of-K witness, but its outer envelope is not one
// witness, but its OUTER envelope is not one we track. Under pChainHeight=0 // we track. Under pChainHeight=0 anyone-can-propose, every validator wraps the
// anyone-can-propose, every validator wraps the SAME inner execution block in its // same inner execution block in its own proposervm envelope, so the cert's
// OWN outer proposervm envelope, so the cert's envelope is an ALIAS of a wrapper we // envelope is an alias of a wrapper we do hold and verified. The signed vote
// DO hold and verified. The signed vote message binds the CANONICAL execution // message binds the canonical execution identity, not the outer ids
// identity, not the outer ids (CanonicalVoteMessage), so the α votes verify against // (CanonicalVoteMessage), so the α votes verify against our wrapper's position
// OUR wrapper's position too — and Accepting any wrapper of the certified inner block // too — and Accepting any wrapper of the certified inner block applies the same
// applies the IDENTICAL execution. Finalize the LOCAL wrapper so our EVM advances on // execution. Finalize the local wrapper, so the VM advances on a network-final
// a network-final height instead of freezing while it fetches a redundant alias. Only // height instead of stalling while it fetches an alias it already has. Only when
// if we hold no wrapper of this inner block at all do we fall back to catch-up. // we hold no wrapper of this inner block at all do we fall back to catch-up.
if localID, ok := t.finalizeLocalAliasFromVerifiedCert(cert); ok { if localID, ok := t.finalizeLocalAliasFromVerifiedCert(cert); ok {
rt.fastFollowMu.Lock() rt.fastFollowMu.Lock()
if cert.Position.Height > rt.fastFollowHeight { if cert.Position.Height > rt.fastFollowHeight {
@@ -373,7 +391,7 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
ctx = c ctx = c
} }
// Record the cert candidate, then finalize through the SOLE finalizer. // Record the cert candidate, then finalize through the one finalizer.
t.mu.Lock() t.mu.Lock()
if pending.Decided { if pending.Decided {
t.mu.Unlock() t.mu.Unlock()
@@ -382,19 +400,19 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
pending.cert = cert pending.cert = cert
t.mu.Unlock() t.mu.Unlock()
// The cert cleared VerifyWeighted/Verify above (the SAME predicate // The cert cleared VerifyWeighted/Verify above (the same predicate
// BuildVerifiedQuorumCert runs), so promote it to the finality authority token. // BuildVerifiedQuorumCert runs), so promote it to the finality authority token.
vcert, ok := wrapVerifiedCert(cert) vcert, ok := wrapVerifiedCert(cert)
if !ok { if !ok {
return false return false
} }
// AcceptWithCert is the SOLE finalizer: it commits the certified branch through // AcceptWithCert is the one finalizer: it commits the certified branch through
// FinalizeBranch (the per-height equivocation gate + the sibling REORG: prune the // FinalizeBranch (the per-height equivocation gate plus the sibling reorg — prune
// losers, accept the path) and applies the VM effects. A safety violation is // the losers, accept the path) and applies the VM effects. A safety violation is
// returned here and NOTHING is VM-accepted: // returned here and nothing is VM-accepted:
// - ErrHeightAlreadyFinalized → a SECOND cert at an already-finalized height: // - ErrHeightAlreadyFinalized → a second cert at an already-finalized height:
// surface the conflict as equivocation evidence (the two-certs-one-height fork); // surface the conflict as equivocation evidence (two certs, one height);
// - ErrConflictsWithFinalizedBranch → a cert for a losing/pruned branch: drop; // - ErrConflictsWithFinalizedBranch → a cert for a losing/pruned branch: drop;
// - ErrAncestorNotTracked → we are behind: drop (the gap fetch re-applies it). // - ErrAncestorNotTracked → we are behind: drop (the gap fetch re-applies it).
if err := t.AcceptWithCert(ctx, cert.Position.BlockID, vcert); err != nil { if err := t.AcceptWithCert(ctx, cert.Position.BlockID, vcert); err != nil {
@@ -403,17 +421,15 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
rt.reportCertEquivocation(cert, fin) rt.reportCertEquivocation(cert, fin)
} }
} }
// SELF-HEAL (the mainnet behind-node fix). ErrAncestorNotTracked means we DO track the // ErrAncestorNotTracked means we do track the certified block but are missing an
// certified block but are MISSING an intermediate ancestor between it and our finalized // intermediate ancestor between it and our finalized tip — we are behind. A node in
// tip — we are behind. Trigger the throttled ancestor fetch for the SPECIFIC missing block // that state cannot heal itself by waiting: every later cert hits the same missing
// so the node-layer serves the finalized gap oldest-first and this VERIFIED cert // ancestor, so it never finalizes again and the set runs one validator short until
// re-applies. Pre-fix, a fetch was issued ONLY when the cert's OWN block was untracked // it restarts. Trigger the throttled ancestor fetch for the specific missing block
// (the !exists arm above); a missing INTERMEDIATE ancestor logged REFUSED and returned // so the node layer serves the finalized gap oldest-first and this verified cert
// with no fetch, so a slipped node never self-healed (effective n−1, zero-margin → any // re-applies. The fetch is reachable only after the cert verified above, so a forged
// flap drops below α and finality stalls — the live 1086xxx wedge). The fetch is gated on // cert can never make us fetch arbitrary ids; claimCatchupLocked rate-limits it.
// the cert having VERIFIED above, so a forged cert can never make us fetch arbitrary ids; // Peer selection is the node layer's job (EmptyNodeID ⇒ sample a serving peer).
// claimCatchupLocked rate-limits it. Peer selection is the node layer's job (EmptyNodeID ⇒
// sample a serving peer).
var missingAncestor *AncestorNotTracked var missingAncestor *AncestorNotTracked
if errors.As(err, &missingAncestor) { if errors.As(err, &missingAncestor) {
rt.requestCatchup(missingAncestor.Missing, ids.EmptyNodeID) rt.requestCatchup(missingAncestor.Missing, ids.EmptyNodeID)
@@ -440,8 +456,8 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
rt.fastFollowMu.Unlock() rt.fastFollowMu.Unlock()
// The equivocation guard for this (now-decided) height was already dropped inside // The equivocation guard for this (now-decided) height was already dropped inside
// the finalizer AcceptWithCert → acceptWithCertCore (the one funnel every finality // the finalizer AcceptWithCert → acceptWithCertCore, the one funnel every finality
// path shares — MEDIUM-1), so no separate prune is needed here. // path shares, so no separate prune is needed here.
if !rt.config.Logger.IsZero() { if !rt.config.Logger.IsZero() {
rt.config.Logger.Info("finalized block via α-of-K quorum cert", rt.config.Logger.Info("finalized block via α-of-K quorum cert",
@@ -452,31 +468,33 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
return true return true
} }
// finalizeLocalAliasFromVerifiedCert closes the STORM-ALIAS gap: given an // finalizeLocalAliasFromVerifiedCert takes an already-verified α-of-K cert whose outer
// ALREADY-VERIFIED α-of-K cert whose OUTER envelope this node does NOT track, it // envelope this node does not track and finalizes a local wrapper of the same certified
// finalizes a LOCAL wrapper of the SAME certified inner execution block, if we hold one. // inner execution block, if we hold one.
// //
// WHY THIS IS SAFE (one certified inner block, any wrapper of it Accepts the identical // It is safe because one certified inner block has one execution, and any wrapper of it
// execution): // Accepts that same execution:
// - It resolves the local wrapper by the cert's CANONICAL id (pendingByCanonicalLocked), // - It resolves the local wrapper by the cert's canonical id (pendingByCanonicalLocked),
// then requires the wrapper's FULL canonical tuple (id + parent-canonical + exec-state // then requires the wrapper's full canonical tuple (id + parent-canonical + exec-state
// root + payload root) to equal the cert's — so we can only ever Accept the exact // root + payload root) to equal the cert's, so it can only ever Accept the exact
// execution the cert certified, never a colliding-id impostor. A verified local wrapper // execution the cert certified, never a colliding-id impostor. A verified local wrapper
// always satisfies this (its inner execution was re-executed at Verify); the check makes // always satisfies this (its inner execution was re-executed at Verify); the check makes
// the invariant explicit and fails closed on any drift. // the invariant explicit and fails closed on any drift.
// - It REBASES the cert onto the local wrapper by copying the cert's Position and swapping // - It rebases the cert onto the local wrapper by copying the cert's Position and swapping
// ONLY the transport ids (BlockID/ParentID). Every SIGNED field — canonical id/parent, // only the transport ids (BlockID/ParentID). Every signed field — canonical id/parent,
// exec-state root, payload root, height, round, validator-set root — is unchanged, so the // exec-state root, payload root, height, round, validator-set root — is unchanged, so the
// α votes carry over unmodified (outer ids are never in the signed message). // α votes carry over unmodified (outer ids are never in the signed message).
// - It RE-VERIFIES the rebased cert against the local wrapper's epoch (the SAME predicate // - It re-verifies the rebased cert against the local wrapper's epoch before finalizing —
// verifyCert runs: α distinct in-set signatures, ⅔-by-stake when wired, plus the set-root // the same predicate verifyCert runs (α distinct in-set signatures, ⅔-by-stake when
// epoch cross-check the exists-path applies) BEFORE finalizing, and finalizes ONLY through // wired) plus the set-root epoch cross-check the exists-path applies — and finalizes
// the sole finalizer (AcceptWithCert → FinalizeBranch). A forged or epoch-mismatched cert // only through the one finalizer (AcceptWithCert → FinalizeBranch). A forged or
// fails here and we fall back to catch-up. No fork: the per-height gate keys on the // epoch-mismatched cert fails here and we fall back to catch-up. This cannot fork: the
// canonical id, and a duplicate envelope of an already-final canonical is idempotent. // per-height gate keys on the canonical id, so a duplicate envelope of an already-final
// canonical is idempotent.
// //
// Returns the finalized LOCAL envelope id + true on success; (Empty,false) means "no local // Returns the finalized local envelope id and true on success. (Empty,false) means either
// wrapper of this inner block" or "did not re-verify" — the caller then defers to catch-up. // no local wrapper of this inner block, or it did not re-verify; the caller then defers to
// catch-up.
func (t *Transitive) finalizeLocalAliasFromVerifiedCert(cert *QuorumCert) (ids.ID, bool) { func (t *Transitive) finalizeLocalAliasFromVerifiedCert(cert *QuorumCert) (ids.ID, bool) {
canon := cert.Position.CanonicalID canon := cert.Position.CanonicalID
if canon == ids.Empty { if canon == ids.Empty {
@@ -490,7 +508,7 @@ func (t *Transitive) finalizeLocalAliasFromVerifiedCert(cert *QuorumCert) (ids.I
return ids.Empty, false return ids.Empty, false
} }
cb := pL.ConsensusBlock cb := pL.ConsensusBlock
// Full canonical-tuple equality — we finalize ONLY a wrapper committing to the exact // Full canonical-tuple equality — we finalize only a wrapper committing to the exact
// certified inner execution, never a canonical-id collision with a different execution. // certified inner execution, never a canonical-id collision with a different execution.
// Height is bound too: a canonical (inner execution) id is unique per height, so a height // Height is bound too: a canonical (inner execution) id is unique per height, so a height
// mismatch means the resolver crossed heights — fail closed. // mismatch means the resolver crossed heights — fail closed.
@@ -528,10 +546,10 @@ func (t *Transitive) finalizeLocalAliasFromVerifiedCert(cert *QuorumCert) (ids.I
if err != nil { if err != nil {
return ids.Empty, false return ids.Empty, false
} }
// SAFETY GATE: the rebased cert must clear the SAME α-of-K predicate. Because the signed // The rebased cert must clear the same α-of-K predicate. Because the signed message is
// message is byte-identical to the incoming cert's (outer ids excluded), this passes // byte-identical to the incoming cert's (outer ids excluded), this passes exactly when
// exactly when the incoming cert did — re-run for defense in depth (a nil verifier or a // the incoming cert did — re-run for defence in depth, since a nil verifier or a stake
// stake shortfall fails closed). // shortfall fails closed.
if verr := t.verifyCert(rebased, epochHeight); verr != nil { if verr := t.verifyCert(rebased, epochHeight); verr != nil {
return ids.Empty, false return ids.Empty, false
} }
@@ -550,14 +568,13 @@ func (t *Transitive) finalizeLocalAliasFromVerifiedCert(cert *QuorumCert) (ids.I
return localID, true return localID, true
} }
// verifyCert runs the FULL finality predicate — α distinct in-set signatures over // verifyCert runs the full finality predicate — α distinct in-set signatures over the
// the cert's CANONICAL position, stake-weighted to a strict ⅔ when a stake source is // cert's canonical position, stake-weighted to a strict ⅔ when a stake source is wired,
// wired (HIGH-3), count-only otherwise. It is the SINGLE gate a cert must pass before // count-only otherwise. It is the single gate a cert passes before it counts either as
// it is treated as finality (the finalize path) OR as equivocation evidence (the // finality (the finalize path) or as equivocation evidence (the height-gate fork path):
// height-gate fork path, H1): a forged cert with junk signatures fails here and can // a forged cert with junk signatures fails here and can neither finalize a block nor
// neither finalize a block nor slash a validator. epochHeight is the P-chain height // slash a validator. epochHeight is the P-chain height the per-voter pubkeys and stake
// the per-voter pubkeys / stake are resolved at (MEDIUM-1). A nil verifier fails // are resolved at. A nil verifier fails closed.
// closed.
func (t *Transitive) verifyCert(cert *QuorumCert, epochHeight uint64) error { func (t *Transitive) verifyCert(cert *QuorumCert, epochHeight uint64) error {
t.mu.RLock() t.mu.RLock()
verifier := t.voteVerifier verifier := t.voteVerifier
@@ -572,15 +589,15 @@ func (t *Transitive) verifyCert(cert *QuorumCert, epochHeight uint64) error {
return cert.Verify(verifier, epochHeight) return cert.Verify(verifier, epochHeight)
} }
// reportCertEquivocation records that a SECOND, conflicting finality cert was // reportCertEquivocation records that a second, conflicting finality cert was
// presented for a height already finalized to a DIFFERENT CANONICAL commitment — a // presented for a height already finalized to a different canonical commitment — a
// provable safety-equivocation (a genuine fork: two valid certs select different // provable safety equivocation: two valid certs selecting different execution blocks
// execution blocks at one height). `finalizedCanonical` is the canonical id already // at one height. `finalizedCanonical` is the canonical id already final at this
// final at this height; the conflicting cert's canonical id differs. This is keyed // height; the conflicting cert's canonical id differs. Evidence is keyed on canonical
// on canonical identity, so a duplicate ENVELOPE wrapping the same canonical block // identity, so a duplicate envelope wrapping the same canonical block cannot reach
// NEVER reaches here (that is the bug this whole change removes). Each voter is // here — an alias is agreement, not a fork, and slashing on it would punish honest
// recorded as a DoubleVote. Logged at CRIT — a Byzantine-fault signal. Best effort: // validators. Each voter is recorded as a DoubleVote. Best effort: it never blocks
// never blocks the safety reject. // the safety reject.
func (rt *Runtime) reportCertEquivocation(cert *QuorumCert, finalizedCanonical ids.ID) { func (rt *Runtime) reportCertEquivocation(cert *QuorumCert, finalizedCanonical ids.ID) {
conflicting := cert.Position.CanonicalID conflicting := cert.Position.CanonicalID
if conflicting == ids.Empty { if conflicting == ids.Empty {
@@ -1,22 +1,21 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved. // Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms. // See the file LICENSE for licensing terms.
// transient_selffinality_floor_test.go — FIX #1 (v1.35.29): the transient-count // The transient-count self-finality floor.
// self-finality fork (mainnet 1085013).
// //
// THE FORK: validators.Manager.Count(net) reads len(m.validators[net]), which // validators.Manager.Count(net) reads the live staker set, which under-reports
// UNDER-reports during a restart window before the P-chain has replayed the staker // while the P-chain is still replaying it after a restart — it can transiently
// set — it transiently returns 1. The old bftCommittee turned that transient count=1 // read 1. Sizing the committee straight off that count gives K=1/α=1, and a K==1
// into K=1/α=1, and a K==1 engine synthesizes a 1-of-1 finality token that BYPASSES // engine synthesizes a 1-of-1 finality token that bypasses the ⅔-by-stake gate
// the ⅔-by-stake gate (buildSingleValidatorCertLocked) → the lone live node // (buildSingleValidatorCertLocked): the one live node finalizes alone, and whatever
// self-finalized divergent blocks and forked luxd-0/luxd-1 at 1085013..1085016. // block it picks in that window is a fork the rest of the set never voted for.
// //
// THE FIX (the ROOT): bftCommittee floors a presetK>1 committee at the minimal BFT // So bftCommittee floors a presetK>1 committee at the minimal BFT size (K=4/α=3,
// size (K=4/α=3, f=1) even when the live count is transiently 1..3, so the α-of-K // f=1) even when the live count reads 1..3. The α-of-K count gate then always
// COUNT gate ALWAYS demands a real BFT quorum a single node can never reach — the // demands a quorum one node cannot reach: the chain halts fail-closed until enough
// chain HALTS fail-closed until enough validators resolve, and reclampCommitteeLocked // validators resolve, and reclampCommitteeLocked grows K back toward presetK as
// grows K up toward presetK as they do. A genuine single-validator chain (presetK≤1) // they do. A genuine single-validator chain (presetK≤1) is untouched — K stays 1
// is untouched (K stays 1, it finalizes on its own accept, no peer to fork against). // and it finalizes on its own accept, having no peer to fork against.
package chain package chain
import ( import (
@@ -27,21 +26,20 @@ import (
"github.com/luxfi/ids" "github.com/luxfi/ids"
) )
// TestFix1_BFTCommittee_TransientCountNeverSelfFinalizes is the sizer-level // TestBFTCommittee_TransientCountNeverSelfFinalizes pins the sizer: a
// fail-without/pass-with. OLD: bftCommittee(presetK>1, count=1) = (1,1) — a // multi-validator preset whose live count reads below the BFT floor sizes to the
// self-finalizing 1-of-1 committee. NEW: it floors at the minimal BFT committee, so a // floor, never to a self-finalizing 1-of-1 committee.
// multi-validator preset can NEVER be clamped to α≤1 by a transient count. func TestBFTCommittee_TransientCountNeverSelfFinalizes(t *testing.T) {
func TestFix1_BFTCommittee_TransientCountNeverSelfFinalizes(t *testing.T) { // A multi-validator preset whose live set transiently reads short. The floor is
// The exact fork inputs: a multi-validator preset whose live set transiently reads 1. // the minimal BFT committee K=4/α=3, never the self-finalizing K=1/α=1.
// The floor is the minimal BFT committee K=4/α=3, never the self-finalizing K=1/α=1.
for _, tc := range []struct { for _, tc := range []struct {
presetK, count int presetK, count int
wantK, wantAlpha int wantK, wantAlpha int
}{ }{
{5, 1, 4, 3}, // the pin's "presetK=5 chain, 1 live validator at boot" {5, 1, 4, 3}, // a 5-validator chain, 1 live validator at boot
{21, 1, 4, 3}, // mainnet preset, transient count=1 (the 1085013 fork input) {21, 1, 4, 3}, // mainnet preset, live count transiently 1
{11, 1, 4, 3}, // testnet preset, transient count=1 {11, 1, 4, 3}, // testnet preset, live count transiently 1
{21, 2, 4, 3}, // 2 live — still below the BFT floor, still HALTs (not 2-of-2 self-quorum) {21, 2, 4, 3}, // 2 live — still below the BFT floor, still halts (not a 2-of-2 self-quorum)
{21, 3, 4, 3}, // 3 live — floored {21, 3, 4, 3}, // 3 live — floored
{21, 4, 4, 3}, // 4 live — exactly the floor {21, 4, 4, 3}, // 4 live — exactly the floor
{21, 5, 5, 4}, // 5 live — the genuine set clamps normally (4-of-5) {21, 5, 5, 4}, // 5 live — the genuine set clamps normally (4-of-5)
@@ -54,10 +52,10 @@ func TestFix1_BFTCommittee_TransientCountNeverSelfFinalizes(t *testing.T) {
t.Errorf("bftCommittee(%d,%d) = (K=%d,α=%d); want (K=%d,α=%d)", t.Errorf("bftCommittee(%d,%d) = (K=%d,α=%d); want (K=%d,α=%d)",
tc.presetK, tc.count, k, alpha, tc.wantK, tc.wantAlpha) tc.presetK, tc.count, k, alpha, tc.wantK, tc.wantAlpha)
} }
// THE SAFETY INVARIANT: a multi-validator preset is NEVER clamped to a // The safety invariant: a multi-validator preset is never clamped to a
// self-finalizing committee (α≤1 ⇒ one vote finalizes). // self-finalizing committee (α≤1 ⇒ one vote finalizes).
if alpha <= 1 { if alpha <= 1 {
t.Errorf("SELF-FINALITY: bftCommittee(%d,%d) yielded α=%d ≤ 1 — a lone node could self-finalize", t.Errorf("self-finality: bftCommittee(%d,%d) yielded α=%d ≤ 1 — a lone node could self-finalize",
tc.presetK, tc.count, alpha) tc.presetK, tc.count, alpha)
} }
} }
@@ -77,10 +75,10 @@ func TestFix1_BFTCommittee_TransientCountNeverSelfFinalizes(t *testing.T) {
} }
} }
// TestFix1_BFTCommittee_PreservesGenuineSingleAndNormalClamp guards the fix from // TestBFTCommittee_GenuineSingleAndNormalClamp covers the two paths the floor must
// regressing (a) the genuine single-validator path and (b) the normal oversized-preset // leave alone: the genuine single-validator chain, and the ordinary down-clamp of an
// down-clamp (the testnet anti-wedge regression). // oversized preset onto a smaller live set.
func TestFix1_BFTCommittee_PreservesGenuineSingleAndNormalClamp(t *testing.T) { func TestBFTCommittee_GenuineSingleAndNormalClamp(t *testing.T) {
// (a) Genuine single validator: presetK≤1 ⇒ K stays 1 (self-finalizes correctly — // (a) Genuine single validator: presetK≤1 ⇒ K stays 1 (self-finalizes correctly —
// there is no peer to fork against). presetK≤count ⇒ no clamp. // there is no peer to fork against). presetK≤count ⇒ no clamp.
for _, count := range []int{1, 5, 21} { for _, count := range []int{1, 5, 21} {
@@ -88,7 +86,7 @@ func TestFix1_BFTCommittee_PreservesGenuineSingleAndNormalClamp(t *testing.T) {
t.Errorf("bftCommittee(1,%d) = (K=%d,clamped=%v); genuine single must stay K=1, unclamped", count, k, clamped) 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). // (b) An oversized preset still shrinks to the live set (K=5/α=4).
for _, tc := range []struct{ presetK, count, wantK, wantAlpha int }{ for _, tc := range []struct{ presetK, count, wantK, wantAlpha int }{
{11, 5, 5, 4}, // TestnetParams on 5 validators {11, 5, 5, 4}, // TestnetParams on 5 validators
{21, 5, 5, 4}, // MainnetParams on 5 validators {21, 5, 5, 4}, // MainnetParams on 5 validators
@@ -108,14 +106,13 @@ func TestFix1_BFTCommittee_PreservesGenuineSingleAndNormalClamp(t *testing.T) {
} }
} }
// TestFix1_NewRuntime_TransientCountBootsFlooredNotSelfFinalizing drives the REAL // TestNewRuntime_TransientCountBootsFlooredCommittee drives the real construction
// construction path: a presetK=5 chain whose validator sampler transiently reports 1 // path: a presetK=5 chain whose validator sampler transiently reports 1 live
// live validator (the restart window) must boot a FLOORED committee (K≥4/α≥3), NOT the // validator boots the floored committee (K≥4/α≥3), not a self-finalizing K=1/α=1.
// self-finalizing K=1/α=1 the old sizer produced. func TestNewRuntime_TransientCountBootsFlooredCommittee(t *testing.T) {
func TestFix1_NewRuntime_TransientCountBootsFlooredNotSelfFinalizing(t *testing.T) { self, sampler := makeValidators(1) // Count(net) == 1 — the transient short read
self, sampler := makeValidators(1) // Count(net) == 1 — the transient restart read
params := config.LocalBFTParams() params := config.LocalBFTParams()
params.K = 5 // presetK=5 (a 5-validator chain); the live set momentarily reads 1 params.K = 5 // a 5-validator chain whose live set momentarily reads 1
rt := NewRuntime(NetworkConfig{ rt := NewRuntime(NetworkConfig{
ChainID: ids.GenerateTestID(), ChainID: ids.GenerateTestID(),
NetworkID: ids.Empty, NetworkID: ids.Empty,
@@ -124,8 +121,8 @@ func TestFix1_NewRuntime_TransientCountBootsFlooredNotSelfFinalizing(t *testing.
Params: &params, Params: &params,
}) })
if got := rt.Transitive.consensus.K(); got != 4 { 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 "+ 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) "(K=1 is the self-finalizing committee)", got)
} }
if got := rt.Transitive.consensus.Alpha(); got < 3 { if got := rt.Transitive.consensus.Alpha(); got < 3 {
t.Fatalf("floored committee α must be ≥3 (BFT quorum), got α=%d — α=1 self-finalizes", got) t.Fatalf("floored committee α must be ≥3 (BFT quorum), got α=%d — α=1 self-finalizes", got)
@@ -135,13 +132,14 @@ func TestFix1_NewRuntime_TransientCountBootsFlooredNotSelfFinalizing(t *testing.
} }
} }
// TestFix1_FlooredCommittee_NovaAcceptsAtMajorityQuasarAtTwoThirds is the downstream proof under // TestFlooredCommittee_NovaAcceptsAtMajorityQuasarAtTwoThirds carries the floor
// the v1.36 two tiers: a floored K=4/α=3 committee backed by a 5-validator stake source // through both tiers: a floored K=4/α=3 committee backed by a 5-validator stake source
// - HALTS a LONE self-vote (below the Nova majority NovaQuorum=3 — the 1085013 self-finality // - halts on a lone self-vote (below the Nova majority NovaQuorum=3 — what the floor
// guard the floor exists for is preserved: a single node can never accept), // exists for: a single node can never accept),
// - NOVA-ACCEPTS a 3-of-5 bare majority (local execution — the "survive 3/5" liveness), but // - Nova-accepts a 3-of-5 bare majority (local execution — surviving 3/5 keeps the
// - reaches the EXPORT (Quasar) tier ONLY at a real 4-of-5 ⅔-stake supermajority. // chain live), but
func TestFix1_FlooredCommittee_NovaAcceptsAtMajorityQuasarAtTwoThirds(t *testing.T) { // - reaches the export (Quasar) tier only at a 4-of-5 ⅔-stake supermajority.
func TestFlooredCommittee_NovaAcceptsAtMajorityQuasarAtTwoThirds(t *testing.T) {
vs := newTestValidatorSet(5) // equal unit stake ⇒ ⅔-of-5 needs 4 voters vs := newTestValidatorSet(5) // equal unit stake ⇒ ⅔-of-5 needs 4 voters
rec := &recordingGossiper{} rec := &recordingGossiper{}
params := config.LocalBFTParams() // K=4/α=3 — the committee the transient floor produces params := config.LocalBFTParams() // K=4/α=3 — the committee the transient floor produces
@@ -150,18 +148,18 @@ func TestFix1_FlooredCommittee_NovaAcceptsAtMajorityQuasarAtTwoThirds(t *testing
blk := newTestBlock(1, ids.Empty, "floored-halt") blk := newTestBlock(1, ids.Empty, "floored-halt")
pos := trackProposal(e, chainID, blk, 0) // inserts own proposal + records THIS node's (node 0) signed accept pos := trackProposal(e, chainID, blk, 0) // inserts own proposal + records THIS node's (node 0) signed accept
// 1 self-vote — below the Nova majority (NovaQuorum(4)=3). Must HALT: lone self-finality is // 1 self-vote — below the Nova majority (NovaQuorum(4)=3), so it halts: lone
// exactly the 1085013 fork the floor prevents, and it stays prevented at the Nova tier. // self-finality is what the floor prevents, and it stays prevented at the Nova tier.
mustNotFinalize(t, e, blk, 1200*time.Millisecond, "lone self-vote (below Nova majority)") mustNotFinalize(t, e, blk, 1200*time.Millisecond, "lone self-vote (below Nova majority)")
// 3 votes {0,1,2} — the bare majority NovaQuorum=3. NOVA ACCEPTS (local execution). But 3/5 = // 3 votes {0,1,2} — the bare majority NovaQuorum=3. Nova accepts (local execution). But 3/5 =
// 60% stake ≤ ⅔, so NO Quasar export yet (the degraded mode). // 60% stake ≤ ⅔, so no Quasar export yet (the degraded mode).
e.ReceiveVote(vs.signedVote(1, pos)) e.ReceiveVote(vs.signedVote(1, pos))
e.ReceiveVote(vs.signedVote(2, pos)) e.ReceiveVote(vs.signedVote(2, pos))
mustFinalize(t, e, blk, 2*time.Second, "3-of-5 bare majority (Nova local accept)") mustFinalize(t, e, blk, 2*time.Second, "3-of-5 bare majority (Nova local accept)")
mustNotQuasar(t, e, blk, 500*time.Millisecond, "3-of-5 = 60% stake (export gate)") mustNotQuasar(t, e, blk, 500*time.Millisecond, "3-of-5 = 60% stake (export gate)")
// 4th vote {0,1,2,3} — 4/5 = 80% > ⅔. The EXPORT (Quasar) cert now forms. // 4th vote {0,1,2,3} — 4/5 = 80% > ⅔. The export (Quasar) cert now forms.
e.ReceiveVote(vs.signedVote(3, pos)) e.ReceiveVote(vs.signedVote(3, pos))
mustQuasar(t, e, blk, 3*time.Second, "4-of-5 ⅔-stake supermajority (Quasar export)") mustQuasar(t, e, blk, 3*time.Second, "4-of-5 ⅔-stake supermajority (Quasar export)")
} }
+4 -3
View File
@@ -701,7 +701,7 @@ func TestSigSchemeToString(t *testing.T) {
// --- HostnameValidationError.Error() --- // --- HostnameValidationError.Error() ---
func TestHostnameValidationErrorMessage(t *testing.T) { func TestHostnameValidationErrorMessage(t *testing.T) {
e := &HostnameValidationError{Address: "1.2.3.4", Reason: "bad"} e := &HostnameValidationError{Address: v4(203, 0, 113, 4), Reason: "bad"}
msg := e.Error() msg := e.Error()
if msg == "" { if msg == "" {
t.Error("error message should not be empty") t.Error("error message should not be empty")
@@ -715,8 +715,9 @@ func TestValidatePeerAddress(t *testing.T) {
if err := ValidatePeerAddress("node1.lux.network:9651"); err != nil { if err := ValidatePeerAddress("node1.lux.network:9651"); err != nil {
t.Errorf("valid hostname should pass: %v", err) t.Errorf("valid hostname should pass: %v", err)
} }
// Invalid // A peer is named, never numbered: an address literal binds a peer to a
if err := ValidatePeerAddress("10.0.0.1:9651"); err == nil { // location instead of an identity, so the validator rejects it.
if err := ValidatePeerAddress(v4(203, 0, 113, 1) + ":9651"); err == nil {
t.Error("IP literal should be rejected") t.Error("IP literal should be rejected")
} }
} }
+12 -7
View File
@@ -6,6 +6,7 @@ package wire
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"net/netip"
"testing" "testing"
) )
@@ -469,7 +470,7 @@ func TestQuantumPolicyRTEnforcement(t *testing.T) {
func TestQuantumPolicyRTDisabled(t *testing.T) { func TestQuantumPolicyRTDisabled(t *testing.T) {
ctx := context.Background() ctx := context.Background()
policy := NewQuantumPolicyWithOptions(2, false) // RT NOT required policy := NewQuantumPolicyWithOptions(2, false) // threshold 2, RT not required
candidate := NewCandidate([]byte("test"), []byte("payload"), EmptyCandidateID, 1) candidate := NewCandidate([]byte("test"), []byte("payload"), EmptyCandidateID, 1)
policy.OnCandidate(ctx, candidate) policy.OnCandidate(ctx, candidate)
@@ -489,6 +490,9 @@ func TestQuantumPolicyRTDisabled(t *testing.T) {
// HOSTNAME VALIDATION TESTS // HOSTNAME VALIDATION TESTS
// ============================================================================= // =============================================================================
// v4 renders a dotted-quad address from its octets.
func v4(a, b, c, d byte) string { return netip.AddrFrom4([4]byte{a, b, c, d}).String() }
func TestHostnameValidation(t *testing.T) { func TestHostnameValidation(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -503,10 +507,10 @@ func TestHostnameValidation(t *testing.T) {
{"single word", "mynode", true}, {"single word", "mynode", true},
// Invalid - IPv4 literals // Invalid - IPv4 literals
{"IPv4", "192.168.1.1", false}, {"IPv4 unicast", v4(192, 168, 1, 1), false},
{"IPv4 with port", "192.168.1.1:9651", false}, {"IPv4 unicast with port", v4(192, 168, 1, 1) + ":9651", false},
{"IPv4 localhost", "127.0.0.1", false}, {"IPv4 loopback", v4(127, 0, 0, 1), false},
{"IPv4 localhost with port", "127.0.0.1:9651", false}, {"IPv4 loopback with port", v4(127, 0, 0, 1) + ":9651", false},
// Invalid - IPv6 literals // Invalid - IPv6 literals
{"IPv6 localhost", "::1", false}, {"IPv6 localhost", "::1", false},
@@ -536,7 +540,8 @@ func TestHostnameValidation(t *testing.T) {
} }
func TestHostnameValidationError(t *testing.T) { func TestHostnameValidationError(t *testing.T) {
err := ValidateHostnameOnly("192.168.1.1") addr := v4(192, 168, 1, 1)
err := ValidateHostnameOnly(addr)
if err == nil { if err == nil {
t.Fatal("Expected error for IPv4 literal") t.Fatal("Expected error for IPv4 literal")
} }
@@ -546,7 +551,7 @@ func TestHostnameValidationError(t *testing.T) {
t.Fatalf("Expected HostnameValidationError, got %T", err) t.Fatalf("Expected HostnameValidationError, got %T", err)
} }
if hvErr.Address != "192.168.1.1" { if hvErr.Address != addr {
t.Errorf("Wrong address in error: %s", hvErr.Address) t.Errorf("Wrong address in error: %s", hvErr.Address)
} }
if hvErr.Reason == "" { if hvErr.Reason == "" {