mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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:
+1
-1
@@ -822,7 +822,7 @@ func TestVersionEntropy_Edge(t *testing.T) {
|
||||
{"empty", ""},
|
||||
{"simple", "1.0"},
|
||||
{"complex", "1.2.3"},
|
||||
{"very_complex", "1.2.3.4.5"},
|
||||
{"more_than_semver", "1.2.3-rc.4"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
+13
-54
@@ -6,7 +6,6 @@ package main
|
||||
import (
|
||||
"encoding/binary"
|
||||
"hash/crc32"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
@@ -38,28 +37,33 @@ func encodeV3(bindings map[uint64]binding, floor uint64) []byte {
|
||||
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) {
|
||||
const floor = 4_000_000
|
||||
const round = 12
|
||||
a := ids.GenerateTestID()
|
||||
b := ids.GenerateTestID()
|
||||
in := map[uint64]binding{
|
||||
1082879: {height: 1082879, target: a}, // hard lock (no round) — AT floor
|
||||
1082880: {height: 1082880, lockRound: 8450, hasLock: true, target: b}, // v3 view lock — above floor
|
||||
floor: {height: floor, target: a},
|
||||
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 {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if s.finalizedThrough != 1082879 {
|
||||
t.Fatalf("floor: got %d want 1082879", s.finalizedThrough)
|
||||
if s.finalizedThrough != floor {
|
||||
t.Fatalf("floor: got %d want %d", s.finalizedThrough, floor)
|
||||
}
|
||||
if len(s.bindings) != 2 {
|
||||
t.Fatalf("bindings: got %d want 2", len(s.bindings))
|
||||
}
|
||||
// sorted by height: [1082879 hard][1082880 round=8450]
|
||||
if s.bindings[0].height != 1082879 || s.bindings[0].hasLock {
|
||||
// bindings come back sorted by height: [floor, hard] then [floor+1, round]
|
||||
if s.bindings[0].height != floor || s.bindings[0].hasLock {
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,24 +13,21 @@ import (
|
||||
)
|
||||
|
||||
// TestSelfHeal_AbandonedBlock_KeepsRequestingCatchup is the sibling of
|
||||
// TestSelfHeal_StuckTrackedNonOwnBlock_TriggersCertCatchup, and the difference between
|
||||
// them is the whole defect: that one proves the recovery fires, this one proves it fires
|
||||
// AGAIN.
|
||||
// TestSelfHeal_StuckTrackedNonOwnBlock_TriggersCertCatchup: that one proves the recovery
|
||||
// fires, this one proves it fires again.
|
||||
//
|
||||
// Abandonment used to end two things with one `continue`. Not re-polling a block whose
|
||||
// voters cannot vote on it is correct and deliberate. Never asking for it again is not:
|
||||
// the cert catch-up fired once, at the instant of abandonment, and rePollAbandoned is set
|
||||
// for the life of the process and cleared nowhere, so the block was never looked at again.
|
||||
// A single dropped fetch was therefore permanent.
|
||||
// Abandoning a block ends two separate things, and only one of them should end. Not
|
||||
// re-polling a block its voters can no longer vote on is right — peers vote near their own
|
||||
// frontier, so a height they have run past will never gather a quorum however often it is
|
||||
// asked. Not asking for the block again is wrong: rePollAbandoned is set once and cleared
|
||||
// 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
|
||||
// luxd-0/1/4 ran on to 1,160,600. Both stalled nodes HELD the blocks — their EVM had them
|
||||
// canonical — and consensus simply never accepted them, because by then the peers were
|
||||
// 1,500 blocks ahead and no longer vote on a block that old. Cert catch-up was the only
|
||||
// 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.
|
||||
// For a node that far behind the certificate fetch is the only remaining door — its own
|
||||
// store may already hold the block, yet consensus cannot accept it without a decision it
|
||||
// can no longer vote its way to. So the fetch must be reissued while the block stays
|
||||
// undecided: one per cooldown, not one ever.
|
||||
func TestSelfHeal_AbandonedBlock_KeepsRequestingCatchup(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
chainID := ids.GenerateTestID()
|
||||
@@ -89,7 +86,7 @@ func TestSelfHeal_AbandonedBlock_KeepsRequestingCatchup(t *testing.T) {
|
||||
}
|
||||
e.mu.Unlock()
|
||||
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.
|
||||
@@ -97,9 +94,9 @@ func TestSelfHeal_AbandonedBlock_KeepsRequestingCatchup(t *testing.T) {
|
||||
|
||||
if got := cu.count(); got <= first {
|
||||
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 "+
|
||||
"how two mainnet validators sat at a fixed height for hours while their peers held "+
|
||||
"the blocks the whole time", first, got)
|
||||
"cooldown lapses: fetches went %d -> %d. Asked for exactly once, a single dropped "+
|
||||
"fetch pins the node at this height for good, even while its peers hold the block",
|
||||
first, got)
|
||||
}
|
||||
if m, _ := cu.lastMissing(); m != blk9.id {
|
||||
t.Fatalf("the re-request must target the stuck block %s, got %s", blk9.id, m)
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
|
||||
// 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
|
||||
// come back with certified finality ABOVE the block its VM last committed, and then
|
||||
// finality cannot advance at all: the fail-closed guard correctly refuses to move past
|
||||
// applied state, while catch-up — steering by the ledger — asks peers for the height
|
||||
// above the gap and never fetches the blocks the VM is missing.
|
||||
// The consensus ledger and the VM persist separately, so a node that stops uncleanly
|
||||
// can come back with certified finality above the block its VM last committed. Finality
|
||||
// then cannot advance at all: the fail-closed guard refuses to move past applied state,
|
||||
// while catch-up steering by the ledger asks peers for the height above the gap and so
|
||||
// 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
|
||||
// "expected accepted block to have parent ...:1159050 but got ...:1161398".
|
||||
// Catch-up must therefore steer by the lower of the two heads: what the VM has applied.
|
||||
|
||||
package chain
|
||||
|
||||
@@ -39,12 +39,12 @@ func (v *appliedVM) GetBlock(_ context.Context, id ids.ID) (block.Block, error)
|
||||
return nil, errors.New("not held")
|
||||
}
|
||||
|
||||
// TestAppliedHeadReportsTheVMWhenTheLedgerIsAhead is the mainnet state exactly: a
|
||||
// ledger certified well above the block the VM actually holds.
|
||||
// TestAppliedHeadReportsTheVMWhenTheLedgerIsAhead: a ledger certified well above the
|
||||
// block the VM actually holds.
|
||||
func TestAppliedHeadReportsTheVMWhenTheLedgerIsAhead(t *testing.T) {
|
||||
const (
|
||||
ledgerHeight = 1_160_628 // what consensus had certified
|
||||
appliedHeight = 1_159_050 // what the EVM had committed
|
||||
ledgerHeight = 1_000_000 // certified by consensus
|
||||
appliedHeight = 998_500 // committed by the VM
|
||||
)
|
||||
|
||||
blk := &verifyOnceBlock{
|
||||
@@ -75,7 +75,7 @@ func TestAppliedHeadReportsTheVMWhenTheLedgerIsAhead(t *testing.T) {
|
||||
t.Fatalf("AppliedHead: %v", err)
|
||||
}
|
||||
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 {
|
||||
t.Fatalf("applied=%d, want the VM's %d", applied, appliedHeight)
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// bootstrap_sync_test.go — proves the BOOTSTRAP accept path (initial sync by
|
||||
// fetch-from-frontier + re-execute, no vote, no cert):
|
||||
// bootstrap_sync_test.go — the bootstrap accept path: initial sync by
|
||||
// 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
|
||||
// peer's height N by re-executing fetched blocks 1..N oldest-first. It does NOT
|
||||
// stay stuck at 0.
|
||||
// - PARTIAL node (M → N): a node already at height M converges to N by executing
|
||||
// - 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, rather than
|
||||
// staying at 0.
|
||||
// - 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.
|
||||
// - SAFETY (reject invalid): a block that fails Verify (a corrupt/forged block
|
||||
// from a malicious peer) is REJECTED — VM.Accept never runs and finalized height
|
||||
// does not advance past it. The sync recovers when a VALID block arrives.
|
||||
// - PHASE BOUNDARY: once the node goes live (FinishBootstrap), the bootstrap accept
|
||||
// - Safety: a block that fails Verify — corrupt or forged, from a peer that need
|
||||
// not be honest — is rejected. VM.Accept never runs and finalized height does not
|
||||
// advance past it. The sync recovers when a valid block arrives.
|
||||
// - 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
|
||||
// cert. This is exactly where bootstrap ends and the live cert-gate begins.
|
||||
// - ORDERING: a gapped / out-of-order block is refused by the per-height guard, so
|
||||
// oldest-first is ENFORCED, not assumed.
|
||||
// cert. That is where bootstrap ends and the live cert-gate begins.
|
||||
// - Ordering: a gapped or out-of-order block is refused by the per-height guard, so
|
||||
// oldest-first is enforced by the engine rather than assumed of the transport.
|
||||
package chain
|
||||
|
||||
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 +
|
||||
// EXECUTES + ACCEPTS blocks 1..N from a peer and reaches height N (not stuck at 0).
|
||||
// Empty node — genesis → tip. A node holding only genesis fetches, executes and
|
||||
// accepts blocks 1..N from a peer, reaching height N.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestBootstrap_EmptyNodeSyncsGenesisToTip(t *testing.T) {
|
||||
@@ -50,8 +50,9 @@ func TestBootstrap_EmptyNodeSyncsGenesisToTip(t *testing.T) {
|
||||
vm := newCatchupVM()
|
||||
rt, _, rec := newCatchupRuntime(t, vs, 0, vm)
|
||||
|
||||
// An EMPTY node: it holds only genesis (height 0). This is the luxd-0 incident —
|
||||
// stuck at C-Chain height 0 because the bootstrapper fetched nothing.
|
||||
// An empty node: it holds only genesis (height 0). A bootstrapper that fetches
|
||||
// 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")
|
||||
seedBehindAt(t, rt, vm, genesis)
|
||||
|
||||
@@ -61,28 +62,27 @@ func TestBootstrap_EmptyNodeSyncsGenesisToTip(t *testing.T) {
|
||||
gap := buildGap(vm, genesis, N)
|
||||
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) {
|
||||
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 {
|
||||
t.Fatalf("finalized tip %s != block N %s", tip, gap[N-1].id)
|
||||
}
|
||||
|
||||
// Bootstrap accepts WITHOUT voting or assembling certs — it re-executes
|
||||
// network-finalized blocks. No vote/cert may be emitted (re-voting a decided
|
||||
// height is spam the network drops).
|
||||
// Bootstrap accepts without voting or assembling certs — it re-executes blocks the
|
||||
// network already finalized. No vote or cert may be emitted: re-voting a decided
|
||||
// height is spam the network drops.
|
||||
rec.mu.Lock()
|
||||
votes, certs := len(rec.votes), len(rec.certs)
|
||||
rec.mu.Unlock()
|
||||
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
|
||||
// 1082797): a node already at M converges to N by executing the gap.
|
||||
// Partial node — M → N. A node already at M converges to N by executing the gap.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestBootstrap_PartialNodeConvergesToTip(t *testing.T) {
|
||||
@@ -90,8 +90,8 @@ func TestBootstrap_PartialNodeConvergesToTip(t *testing.T) {
|
||||
vm := newCatchupVM()
|
||||
rt, _, _ := newCatchupRuntime(t, vs, 0, vm)
|
||||
|
||||
const M = uint64(1082780)
|
||||
const k = 17 // the incident delta: N = M+17 = 1082797
|
||||
const M = uint64(1_000_000) // a node deep in a long chain, not near genesis
|
||||
const k = 17 // gap width: N = M+k
|
||||
tip := newTestBlock(M, ids.Empty, "tip@M")
|
||||
seedBehindAt(t, rt, vm, tip)
|
||||
gap := buildGap(vm, tip, k)
|
||||
@@ -99,10 +99,10 @@ func TestBootstrap_PartialNodeConvergesToTip(t *testing.T) {
|
||||
feedBootstrap(t, rt, gap)
|
||||
|
||||
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
|
||||
// responder always serves some blocks we already have; bootstrap must skip them.
|
||||
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)
|
||||
}
|
||||
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
|
||||
// the sync with a corrupt/forged block; finalized height does not move. The sync
|
||||
// then recovers when a VALID block at that height arrives.
|
||||
// Safety — a block that fails Verify is rejected. A peer cannot advance the sync
|
||||
// with a corrupt or forged block; finalized height does not move. The sync then
|
||||
// recovers when a valid block at that height arrives.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestBootstrap_RejectsInvalidBlockThenRecovers(t *testing.T) {
|
||||
@@ -129,27 +129,27 @@ func TestBootstrap_RejectsInvalidBlockThenRecovers(t *testing.T) {
|
||||
tip := newTestBlock(M, ids.Empty, "tip@M")
|
||||
seedBehindAt(t, rt, vm, tip)
|
||||
|
||||
// A corrupt block at the contiguous next height M+1 whose Verify FAILS. We model
|
||||
// the failure faithfully with the verify-once block: pre-exhausting its single
|
||||
// successful Verify makes the bootstrap path's Verify (the next call) fail, exactly
|
||||
// as a real VM's Verify would reject a block with a bad state root / invalid txs.
|
||||
// A corrupt block at the contiguous next height M+1 whose Verify fails. The
|
||||
// verify-once block models that faithfully: pre-exhausting its single successful
|
||||
// Verify makes the bootstrap path's Verify (the next call) fail, as a real VM's
|
||||
// Verify would reject a block with a bad state root or invalid txs.
|
||||
bad := newTestBlock(M+1, tip.id, "corrupt@M+1")
|
||||
vm.register(bad)
|
||||
_ = bad.Verify(context.Background()) // exhaust the one good Verify → next call errors
|
||||
|
||||
err := rt.AcceptBootstrapBlock(context.Background(), bad.bytes)
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
// block did not poison the height (it never committed to the ledger).
|
||||
// Recovery: a valid block at the same height M+1 finalizes — the rejected block
|
||||
// did not poison the height, because it never committed to the ledger.
|
||||
good := newTestBlock(M+1, tip.id, "valid@M+1")
|
||||
vm.register(good)
|
||||
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
|
||||
// path is FAIL-CLOSED. This is where bootstrap ends and the cert-gated live path
|
||||
// Phase boundary — once the node goes live (FinishBootstrap), the bootstrap accept
|
||||
// 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.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -186,25 +186,25 @@ func TestBootstrap_FailClosedOnceLive(t *testing.T) {
|
||||
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.
|
||||
next := newTestBlock(M+1, tip.id, "post-live@M+1")
|
||||
vm.register(next)
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
// the oldest-first invariant is ENFORCED. After the gap is filled in order, the
|
||||
// same height finalizes.
|
||||
// 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 same
|
||||
// height finalizes.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
// (the un-seeded / empty-genesis path — SyncState only sets it when the VM has a
|
||||
// non-empty last-accepted), the per-height guard alone would record WHATEVER
|
||||
// First-block anchor. When the consensus finalized-height tracker is unset — the
|
||||
// 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
|
||||
// (height, parent) the first fetched block claims. AcceptBootstrapBlock instead
|
||||
// binds the FIRST block to the VM's ACTUAL last-accepted: a peer cannot seed
|
||||
// finality at an arbitrary height/parent.
|
||||
// binds the first block to the VM's actual last-accepted, so a peer cannot seed
|
||||
// finality at an arbitrary height or parent.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestBootstrap_M2_FirstBlockAnchorsToLocalLastAccepted(t *testing.T) {
|
||||
func TestBootstrap_FirstBlockAnchorsToLocalLastAccepted(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
vm := newCatchupVM()
|
||||
rt, _, _ := newCatchupRuntime(t, vs, 0, vm)
|
||||
|
||||
// Empty node holding only genesis (height 0). Crucially we DO NOT seedBehindAt —
|
||||
// so the consensus finalized-height tracker stays UNSET (set==false). The VM's
|
||||
// Empty node holding only genesis (height 0). We deliberately skip seedBehindAt, so
|
||||
// the consensus finalized-height tracker stays unset (set==false). The VM's
|
||||
// last-accepted is genesis.
|
||||
genesis := newTestBlock(0, ids.Empty, "genesis")
|
||||
vm.register(genesis)
|
||||
_ = vm.SetPreference(context.Background(), genesis.id)
|
||||
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
|
||||
// arbitrary parent. Without the anchor, markFinalizedLocked would record it. The
|
||||
// anchor refuses it: height 500 != localLastH+1 (== 1).
|
||||
// A peer's first block seeds finality far ahead (height 500) on an arbitrary
|
||||
// parent. Without the anchor, markFinalizedLocked would record it. The anchor
|
||||
// refuses it: height 500 != localLastH+1 (== 1).
|
||||
ahead := newTestBlock(500, ids.GenerateTestID(), "seed-ahead@500")
|
||||
vm.register(ahead)
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
// first block must extend the VM's actual last-accepted.
|
||||
// The right height (1) but the wrong parent (not genesis). Refused: the first
|
||||
// block must extend the VM's actual last-accepted.
|
||||
wrongParent := newTestBlock(1, ids.GenerateTestID(), "wrong-parent@1")
|
||||
vm.register(wrongParent)
|
||||
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 {
|
||||
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
|
||||
// tracker is now seeded at height 1 and the normal contiguity guard takes over.
|
||||
// Height 1, parent == genesis. Anchored, Verify passes, finalizes; the tracker is
|
||||
// now seeded at height 1 and the normal contiguity guard takes over.
|
||||
first := newTestBlock(1, genesis.id, "first@1")
|
||||
vm.register(first)
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// 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
|
||||
// N+k by accepting fetched (block, cert) pairs through AcceptCatchupBlock,
|
||||
// WITHOUT re-voting (no live quorum exists for an already-finalized height).
|
||||
// - SAFETY: a forged / sub-quorum / below-α-floor cert delivered via catch-up
|
||||
// is REJECTED and finalizes nothing — the cert-gate holds through catch-up
|
||||
// exactly as it does on the live path. A node cannot be force-fed a chain.
|
||||
// - 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
|
||||
// is ENFORCED, not merely assumed.
|
||||
// - Liveness: a validator stranded at height N converges to the network tip N+k by
|
||||
// accepting fetched (block, cert) pairs through AcceptCatchupBlock, without
|
||||
// re-voting — no live quorum exists for an already-finalized height.
|
||||
// - Safety: a forged, sub-quorum or below-α-floor cert delivered via catch-up is
|
||||
// rejected and finalizes nothing. The cert-gate holds through catch-up exactly as
|
||||
// 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
|
||||
// per-height guard, since finality is contiguous. Oldest-first is enforced by the
|
||||
// engine rather than assumed of the transport.
|
||||
package chain
|
||||
|
||||
import (
|
||||
@@ -25,11 +25,11 @@ import (
|
||||
"github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// catchupVM is a FAITHFUL test VM: ParseBlock returns the SAME *verifyOnceBlock
|
||||
// that was registered for those bytes, so block identity (ID/height/parent) and
|
||||
// AcceptCalled tracking survive a round-trip through bytes — exactly as a real
|
||||
// VM's deterministic codec does (unlike verifyOnceVM.ParseBlock, which discards
|
||||
// identity). Unknown bytes parse to an error so AcceptCatchupBlock rejects them.
|
||||
// catchupVM is a faithful test VM: ParseBlock returns the same *verifyOnceBlock that
|
||||
// was registered for those bytes, so block identity (ID/height/parent) and AcceptCalled
|
||||
// tracking survive a round-trip through bytes, as a real VM's deterministic codec does
|
||||
// and unlike verifyOnceVM.ParseBlock, which discards identity. Unknown bytes parse to
|
||||
// an error so AcceptCatchupBlock rejects them.
|
||||
type catchupVM struct {
|
||||
mu sync.Mutex
|
||||
byBytes map[string]*verifyOnceBlock
|
||||
@@ -88,12 +88,12 @@ func (m *catchupVM) SetPreference(_ context.Context, id ids.ID) error {
|
||||
|
||||
var _ BlockBuilder = (*catchupVM)(nil)
|
||||
|
||||
// newCatchupRuntime builds a STARTED stake-weighted multi-validator Runtime for
|
||||
// validator `self`, wired with the test validator set (verifier + signer + stake),
|
||||
// the faithful VM, and a recording gossiper (so a test can assert NO votes/certs
|
||||
// are emitted during catch-up). It returns the runtime, its chainID, and the
|
||||
// recorder. Stake-weighting is wired because the value (mainnet) chain that wedged
|
||||
// is stake-weighted — catch-up must clear the SAME ⅔-of-stake predicate.
|
||||
// newCatchupRuntime builds a started stake-weighted multi-validator Runtime for
|
||||
// validator `self`, wired with the test validator set (verifier + signer + stake), the
|
||||
// faithful VM, and a recording gossiper so a test can assert that no votes or certs are
|
||||
// emitted during catch-up. It returns the runtime, its chainID, and the recorder. Stake
|
||||
// weighting is wired because catch-up must clear the same ⅔-of-stake predicate that
|
||||
// live finality does; a headcount quorum is not enough.
|
||||
func newCatchupRuntime(t *testing.T, vs *testValidatorSet, self int, vm BlockBuilder) (*Runtime, ids.ID, *recordingGossiper) {
|
||||
t.Helper()
|
||||
chainID := ids.GenerateTestID()
|
||||
@@ -117,10 +117,10 @@ func newCatchupRuntime(t *testing.T, vs *testValidatorSet, self int, vm BlockBui
|
||||
return rt, chainID, rec
|
||||
}
|
||||
|
||||
// catchupCertFor assembles a REAL finality cert for blk at the given chainID,
|
||||
// signed by `voters` over blk's canonical position, asserting `threshold`. This is
|
||||
// byte-identical to the cert an AHEAD node would have stored (CertForBlock) and
|
||||
// gossiped at finalize time. Returns the marshaled cert bytes.
|
||||
// catchupCertFor assembles a real finality cert for blk at the given chainID, signed by
|
||||
// `voters` over blk's canonical position, asserting `threshold`. It is byte-identical
|
||||
// to the cert a node ahead of us would have stored (CertForBlock) and gossiped at
|
||||
// finalize time. Returns the marshaled cert bytes.
|
||||
func catchupCertFor(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock, voters []int, threshold uint32) []byte {
|
||||
t.Helper()
|
||||
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
|
||||
}
|
||||
|
||||
// seedBehindAt strands the runtime at finalized height N with tip block `tip`,
|
||||
// exactly as a node that fell behind the frontier (the incident: luxd-0 at
|
||||
// 1082780). The gap blocks built afterward extend `tip`.
|
||||
// seedBehindAt strands the runtime at finalized height N with tip block `tip`, as a
|
||||
// node that fell behind the frontier would be. The gap blocks built afterward extend
|
||||
// `tip`.
|
||||
func seedBehindAt(t *testing.T, rt *Runtime, vm *catchupVM, tip *verifyOnceBlock) {
|
||||
t.Helper()
|
||||
vm.register(tip)
|
||||
// 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 —
|
||||
// it no longer seeds certified finality — so the certified baseline is set through
|
||||
// the real finalize fold (first-finalize seeds at tip.height). The distinct
|
||||
// restart-from-hint recovery is covered by the incident regression suite.
|
||||
// Establish certified finality at N: a node that legitimately finalized up to N
|
||||
// in-process. SyncState is only a non-authoritative hint and does not seed certified
|
||||
// finality, so the certified baseline has to come through the real finalize fold,
|
||||
// whose first finalize seeds at tip.height.
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
// ZERO re-voting. This is the fix for the wedge: the network will not re-vote an
|
||||
// already-finalized height, so the only way back is accepting the certs it
|
||||
// already assembled.
|
||||
// Liveness — a stranded node converges to the tip through the cert path, with no
|
||||
// re-voting. The network will not re-vote an already-finalized height, so the only
|
||||
// way back for a node left behind is applying the certs the network already
|
||||
// assembled.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestCatchup_BehindNodeConvergesViaCertPath(t *testing.T) {
|
||||
@@ -185,9 +184,9 @@ func TestCatchup_BehindNodeConvergesViaCertPath(t *testing.T) {
|
||||
vm := newCatchupVM()
|
||||
rt, chainID, rec := newCatchupRuntime(t, vs, 0, vm)
|
||||
|
||||
// Strand the node at N=1082780 (the incident height) and build the 17-block gap
|
||||
// up to the network tip N+17=1082797 (the incident delta).
|
||||
const N = uint64(1082780)
|
||||
// Strand the node at N, deep in a long chain, and build a k-block gap up to the
|
||||
// network tip N+k.
|
||||
const N = uint64(1_000_000)
|
||||
const k = 17
|
||||
tip := newTestBlock(N, ids.Empty, "tip@N")
|
||||
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) {
|
||||
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
|
||||
// FINISHED certs. (If catch-up had fallen back to the voting Put path, the node
|
||||
// would have broadcast votes for already-decided heights and still wedged.)
|
||||
// finished ones. Falling back to the voting Put path would broadcast votes for
|
||||
// already-decided heights, which no peer will answer, leaving the node stuck.
|
||||
rec.mu.Lock()
|
||||
votes, certs := len(rec.votes), len(rec.certs)
|
||||
rec.mu.Unlock()
|
||||
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 {
|
||||
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.
|
||||
// The cert-gate (VerifyWeighted / α-floor) holds through the catch-up path with
|
||||
// the SAME rigor as live finality. A node cannot be force-fed a bad chain.
|
||||
// Safety — a bad cert delivered via catch-up is rejected and finalizes nothing. The
|
||||
// cert-gate (VerifyWeighted and the α-floor) holds through the catch-up path with
|
||||
// the same rigor as live finality, so a node cannot be force-fed a bad chain.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
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
|
||||
// DEFECTIVE cert. None may finalize. Fresh runtimes keep verifyOnceBlock.Verify
|
||||
// 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
|
||||
// single-shot and isolate the per-height ledger.
|
||||
cases := []struct {
|
||||
name string
|
||||
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
|
||||
// carries voter 1's signature → clause-6 (signature) fails → no cert.
|
||||
// Forged signature: 4 voters (count ok, stake ok) but voter 0's slot
|
||||
// carries voter 1's signature, so the signature clause fails and no cert
|
||||
// is formed.
|
||||
name: "forged-signature",
|
||||
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}
|
||||
@@ -265,19 +265,20 @@ func TestCatchup_RejectsForgedAndSubQuorumCerts(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
// SUB-QUORUM by stake: 3 of 5 validators (count=3≥α=3 passes the COUNT
|
||||
// predicate) but 3/5 = 60% ≤ ⅔ → VerifyWeighted's strict supermajority
|
||||
// fails → no finality. This is the HIGH-3 headcount-vs-stake gap, enforced
|
||||
// through catch-up.
|
||||
// Sub-quorum by stake: 3 of 5 validators clears the count predicate
|
||||
// (count=3 ≥ α=3) but 3/5 = 60% ≤ ⅔, so VerifyWeighted's strict
|
||||
// supermajority fails and nothing finalizes. This is the gap between a
|
||||
// headcount quorum and a stake quorum, enforced through catch-up.
|
||||
name: "sub-quorum-stake",
|
||||
cert: func(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *verifyOnceBlock) []byte {
|
||||
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
|
||||
// α (3). HandleIncomingCert rejects it at the MinThreshold floor even though
|
||||
// its 4 signatures verify — sub-quorum finality-forgery defence.
|
||||
// Below the α-floor: a cert asserting a lower threshold (1) than the
|
||||
// chain's α (3). HandleIncomingCert rejects it at the MinThreshold floor
|
||||
// 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",
|
||||
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)
|
||||
@@ -299,13 +300,13 @@ func TestCatchup_RejectsForgedAndSubQuorumCerts(t *testing.T) {
|
||||
|
||||
err := rt.AcceptCatchupBlock(context.Background(), blk.bytes, bad)
|
||||
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) {
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
// per-height guard requires height == finalizedHeight+1 AND parent == finalizedTip,
|
||||
// so the oldest-first invariant is ENFORCED by the engine, not merely assumed by
|
||||
// the transport. After the gap is filled in order, the same block finalizes.
|
||||
// Ordering — even a valid cert applied out of parent order is refused. The
|
||||
// per-height guard requires height == finalizedHeight+1 and parent == finalizedTip,
|
||||
// so the oldest-first invariant is enforced by the engine rather than assumed of the
|
||||
// transport. After the gap is filled in order, the same block finalizes.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestCatchup_OutOfOrderRefusedThenInOrderConverges(t *testing.T) {
|
||||
@@ -326,30 +327,30 @@ func TestCatchup_OutOfOrderRefusedThenInOrderConverges(t *testing.T) {
|
||||
vm := newCatchupVM()
|
||||
rt, chainID, _ := newCatchupRuntime(t, vs, 0, vm)
|
||||
|
||||
const N = uint64(1082780)
|
||||
const N = uint64(1_000_000)
|
||||
tip := newTestBlock(N, ids.Empty, "tip@N")
|
||||
seedBehindAt(t, rt, vm, tip)
|
||||
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)
|
||||
// with a perfectly VALID 4-of-5 cert, applied while still finalized at N. The
|
||||
// per-height guard refuses it (height N+2 != finalizedHeight+1 == N+1) — a valid
|
||||
// cert does NOT license a non-contiguous finalize.
|
||||
// Try to skip ahead: a distinct block at height N+2 (parent = the real N+1) with a
|
||||
// perfectly valid 4-of-5 cert, applied while still finalized at N. The per-height
|
||||
// guard refuses it (height N+2 != finalizedHeight+1 == N+1) — a valid cert does not
|
||||
// license a non-contiguous finalize.
|
||||
ooo := newTestBlock(N+2, gap[0].id, "ooo@N+2")
|
||||
vm.register(ooo)
|
||||
certOoo := catchupCertFor(t, vs, chainID, ooo, []int{0, 1, 2, 3}, 3)
|
||||
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) {
|
||||
t.Fatal("ORDERING VIOLATION: out-of-order block finalized")
|
||||
t.Fatal("out-of-order block finalized")
|
||||
}
|
||||
if fh, _ := rt.Transitive.consensus.GetFinalizedHeight(); fh != N {
|
||||
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
|
||||
// the contiguity guard, not a stuck path.
|
||||
// Now apply N+1 then N+2 in order and both finalize. The earlier refusal was the
|
||||
// contiguity guard, not a stuck path.
|
||||
for i, blk := range gap {
|
||||
cert := catchupCertFor(t, vs, chainID, blk, []int{0, 1, 2, 3}, 3)
|
||||
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
|
||||
// cert (CertForBlock), and the served bytes are exactly what a behind node needs
|
||||
// to finalize the same block. Closes the loop: store-on-finalize ⇄ serve.
|
||||
// Serve — the ahead side. A node that finalized a block retains and serves its cert
|
||||
// (CertForBlock), and the served bytes are exactly what a node behind it needs to
|
||||
// finalize the same block. That closes the loop: store-on-finalize ⇄ serve.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
func TestCatchup_CertForBlockServesWhatWasFinalized(t *testing.T) {
|
||||
@@ -387,8 +388,8 @@ func TestCatchup_CertForBlockServesWhatWasFinalized(t *testing.T) {
|
||||
t.Fatalf("finalize N+1: %v", err)
|
||||
}
|
||||
|
||||
// After finalize: the node serves a cert that decodes+verifies to the SAME
|
||||
// finality witness — a peer can finalize on it with zero trust in this node.
|
||||
// After finalize: the node serves a cert that decodes and verifies to the same
|
||||
// finality witness, so a peer can finalize on it with zero trust in this node.
|
||||
served, ok := rt.CertForBlock(blk.id)
|
||||
if !ok {
|
||||
t.Fatal("CertForBlock did not serve the finalized block's cert")
|
||||
|
||||
+196
-215
@@ -3,39 +3,37 @@
|
||||
|
||||
// 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 correctly-signed ACCEPT vote over the SAME consensus position
|
||||
// (chain, height, round, block_id, parent_id). The proof of that fact is a
|
||||
// QuorumCert. No node — not even the proposer — may finalize a value block
|
||||
// without holding (or having verified) a QuorumCert for it.
|
||||
// A value block finalizes only after α distinct validators have each produced
|
||||
// a correctly-signed ACCEPT vote over the same consensus position
|
||||
// (chain, height, round, canonical block, canonical parent). The proof of that
|
||||
// fact is a QuorumCert. No node — not even the proposer — may finalize a value
|
||||
// block without holding, or having verified, a QuorumCert for it.
|
||||
//
|
||||
// This closes the proposer-self-finality hole: previously the proposer force-
|
||||
// accepted its own block on its LONE self-vote (consensus.ForceAccept), and a
|
||||
// peer's REJECT was counted as ACCEPT for the proposer's own block
|
||||
// (effectiveAccept = ... || IsOwnProposal). Both are deleted. The α-of-K
|
||||
// Snowball counting in consensus.go is now the sole finality authority, and a
|
||||
// QuorumCert is its portable, verifiable witness.
|
||||
// A proposer's own vote is one vote and buys its own proposal no credit, and a
|
||||
// peer's REJECT is never read as an ACCEPT. The α-of-K counting in consensus.go
|
||||
// is the sole finality authority; 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.
|
||||
// - 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,
|
||||
// ML-DSA, secp256k1) backed by a proven library. A QuorumCert over signed
|
||||
// votes is the full-node-verifiable witness at THIS abstraction level.
|
||||
// - protocol/quasar.WeightedQuorumCert is the SAME relation expressed with
|
||||
// votes is the full-node-verifiable witness at this abstraction level.
|
||||
// - protocol/quasar.WeightedQuorumCert is the same relation expressed with
|
||||
// the heavyweight PQ apparatus (per-signer FIPS 204/205 records + weighted
|
||||
// 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
|
||||
// QuorumCert UPGRADES to carry a quasar.WeightedQuorumCert as its crypto
|
||||
// witness with NO change to the finality rule (see CryptoWitness / the
|
||||
// quasar bridge in quasar.go). One rule; the witness format is
|
||||
// orthogonal and forward-compatible.
|
||||
// QuorumCert upgrades to carry a quasar.WeightedQuorumCert as its crypto
|
||||
// witness with no change to the finality rule (see CryptoWitness and the
|
||||
// quasar bridge in quasar.go). One rule; the witness format is orthogonal
|
||||
// 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
|
||||
// has collected α distinct signed ACCEPT votes assembles the identical cert
|
||||
// (leaderless, permissionless). Verification is the direct predicate below.
|
||||
@@ -53,45 +51,29 @@ import (
|
||||
)
|
||||
|
||||
// QuorumCertVersion is the wire/struct version of the engine-level quorum
|
||||
// certificate. Bound into the canonical vote message and the cert digest so a
|
||||
// version bump is non-malleable.
|
||||
// certificate. It is bound into the canonical vote message and into the cert
|
||||
// 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
|
||||
// 1082814 durable fix). It 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 DROPS the outer
|
||||
// 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.
|
||||
// Versions do not interoperate, by construction. Because the version is folded
|
||||
// into the signed message, a cert signed under one version fails a verifier of
|
||||
// another on both the version clause and the signature clause, loudly rather than
|
||||
// by silent mis-parse. There is no partial interop and no read-only legacy window.
|
||||
//
|
||||
// This is a deliberate forward-only break: a v2 signature (outer-id finality) and
|
||||
// a v3 signature (canonical finality) are over different messages and carry
|
||||
// different versions, so a mixed-version cert fails clause-1 (version) and
|
||||
// clause-6 (signature) loudly rather than silently mis-parsing. There is no partial
|
||||
// QCv1→QCv2 interop and no read-only legacy window.
|
||||
//
|
||||
// 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.
|
||||
// That is fail-safe but not fail-live across a version boundary: while fewer than
|
||||
// α validators run a given version, no α-quorum of that version can form, so
|
||||
// finality stalls on both sides of the skew. A version change is therefore a single
|
||||
// coordinated cut of at least α of the validator set, not a staged migration — a
|
||||
// trickle that straddles α on each side halts finality on both.
|
||||
const QuorumCertVersion uint16 = 3
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// adversarial cert yields an error, never a node crash.
|
||||
var (
|
||||
@@ -141,7 +123,7 @@ type SignedVote struct {
|
||||
// NodeID is the signing validator's identifier. Votes in a cert are sorted
|
||||
// strictly-increasing by this field (distinctness / anti-double-count).
|
||||
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
|
||||
// Signature is the validator's signature over CanonicalVoteMessage of the
|
||||
// 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.
|
||||
//
|
||||
// THE CANONICAL/TRANSPORT SPLIT (the incident-1082814 durable fix). The position
|
||||
// carries TWO identities for the block:
|
||||
// The canonical/transport split. The position carries two identities for the
|
||||
// block:
|
||||
//
|
||||
// - the CANONICAL execution identity — {CanonicalID, ParentCanonicalID,
|
||||
// ExecutionStateRoot, PayloadRoot}. This is the PRIMARY consensus object: the
|
||||
// inner execution block commitment that finality, equivocation, ancestry, and
|
||||
// idempotency are ALL defined over. It is folded into the canonical signed
|
||||
// - the canonical execution identity — {CanonicalID, ParentCanonicalID,
|
||||
// ExecutionStateRoot, PayloadRoot}. This is the primary consensus object: the
|
||||
// inner execution block commitment that finality, equivocation, ancestry and
|
||||
// idempotency are all defined over. It is folded into the canonical signed
|
||||
// message, so a signature is bound to the exact execution result.
|
||||
// - the TRANSPORT/envelope identity — {BlockID, ParentID}, the outer proposervm
|
||||
// wrapper ids. These are a CACHE KEY for block lookup/gossip only. They are
|
||||
// DELIBERATELY EXCLUDED from the signed message (CanonicalVoteMessage) and from
|
||||
// every finality decision, so two different outer envelopes wrapping the SAME
|
||||
// inner block sign the SAME message and are duplicates, never a fork.
|
||||
// - the transport identity — {BlockID, ParentID}, the outer proposervm wrapper
|
||||
// ids. These are a cache key for block lookup and gossip only. They are left
|
||||
// out of the signed message (CanonicalVoteMessage) and out of every finality
|
||||
// decision, so two different outer envelopes wrapping the same inner block
|
||||
// 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
|
||||
// another. The transport ids are not signed — they are non-authoritative.
|
||||
type VotePosition struct {
|
||||
@@ -172,18 +154,18 @@ type VotePosition struct {
|
||||
Height uint64
|
||||
Round uint32
|
||||
|
||||
// BlockID / ParentID are the OUTER proposervm envelope ids — the TRANSPORT
|
||||
// cache keys (block lookup, gossip, DAG tracking). NON-AUTHORITATIVE: excluded
|
||||
// from the signed message and from finality/equivocation. For a block that is
|
||||
// not proposervm-wrapped at the engine boundary, BlockID == CanonicalID (the
|
||||
// scheme degrades to outer==canonical and behaves exactly as before this split).
|
||||
// BlockID / ParentID are the outer proposervm envelope ids — the transport
|
||||
// cache keys (block lookup, gossip, DAG tracking). Non-authoritative: excluded
|
||||
// from the signed message and from finality and equivocation. For a block that
|
||||
// is not proposervm-wrapped at the engine boundary, BlockID == CanonicalID and
|
||||
// the scheme degrades to outer == canonical.
|
||||
BlockID ids.ID
|
||||
ParentID ids.ID
|
||||
|
||||
// CanonicalID is the inner EXECUTION block commitment — THE primary consensus
|
||||
// object. Finality certifies THIS; the per-height equivocation index keys on
|
||||
// THIS; two certs at one height conflict iff THIS differs. It is bound into the
|
||||
// signed message. For a non-wrapped block it equals BlockID.
|
||||
// CanonicalID is the inner execution block commitment — the primary consensus
|
||||
// object. Finality certifies it; the per-height equivocation index keys on it;
|
||||
// two certs at one height conflict iff it differs. It is bound into the signed
|
||||
// message. For a non-wrapped block it equals BlockID.
|
||||
CanonicalID ids.ID
|
||||
// ParentCanonicalID is the inner execution commitment of the parent — binds the
|
||||
// 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.
|
||||
// Bound into the signed message. ids.Empty when the VM does not expose one.
|
||||
PayloadRoot ids.ID
|
||||
// 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
|
||||
// set+weights at this position's height/epoch — mirroring quasar's
|
||||
// ConsensusCert.ValidatorSetRoot, which already binds the same axis
|
||||
// (consensus_cert_legs.go). Because it is folded into the canonical signed
|
||||
// message, a cert assembled from votes cast under set-root R cannot be
|
||||
// re-presented as certifying under a different set-root R': every signature
|
||||
// was over R, so reconstructing the message with R' fails clause-6 verify.
|
||||
// ValidatorSetRoot binds the cert to the exact weighted validator set the
|
||||
// vote was cast under. It is a commitment to the active set and weights at
|
||||
// this position's height/epoch — the same axis quasar's
|
||||
// ConsensusCert.ValidatorSetRoot binds (consensus_cert_legs.go). Because it is
|
||||
// folded into the canonical signed message, a cert assembled from votes cast
|
||||
// under set-root R cannot be re-presented as certifying under a different
|
||||
// set-root R': every signature was over R, so reconstructing the message with
|
||||
// R' fails clause-6 verify.
|
||||
//
|
||||
// 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
|
||||
// (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.
|
||||
@@ -234,15 +216,14 @@ func CanonicalVoteMessage(pos VotePosition) []byte {
|
||||
// against (pos,true) and reject votes against (pos,false); the cert only ever
|
||||
// uses (pos,true).
|
||||
//
|
||||
// THE MESSAGE BINDS THE CANONICAL EXECUTION IDENTITY, NOT THE OUTER ENVELOPE
|
||||
// (the incident-1082814 fix). The outer proposervm ids (pos.BlockID/ParentID)
|
||||
// are NOT in this message — they are transport cache keys. The signed identity is
|
||||
// the inner execution commitment {canonical_id, parent_canonical_id,
|
||||
// execution_state_root, payload_root}. Consequence: two validators that executed
|
||||
// the SAME inner block sign byte-identical messages even if they received
|
||||
// different outer envelopes, so their votes interoperate and a cert assembled
|
||||
// from them verifies on every node. A locally-derived wrapper id can NEVER reach
|
||||
// a signature.
|
||||
// The message binds the canonical execution identity, not the outer envelope.
|
||||
// The outer proposervm ids (pos.BlockID/ParentID) are absent from this message —
|
||||
// they are transport cache keys. The signed identity is the inner execution
|
||||
// commitment {canonical_id, parent_canonical_id, execution_state_root,
|
||||
// payload_root}. Consequence: two validators that executed the same inner block
|
||||
// sign byte-identical messages even if they received different outer envelopes,
|
||||
// so their votes interoperate and a cert assembled from them verifies on every
|
||||
// node. A locally-derived wrapper id can never reach a signature.
|
||||
//
|
||||
// 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)
|
||||
// accept:1 (0x01 accept | 0x00 reject)
|
||||
//
|
||||
// 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
|
||||
// gathered under set-root R cannot be re-verified as certifying under a
|
||||
// different set R'. The domain tag is bumped v1→v2 so a canonical-commitment
|
||||
// signature can never be confused with a legacy outer-id signature.
|
||||
// validator_set_root is bound before the accept byte so a vote is committed to
|
||||
// the exact weighted validator set it was cast under: a cert gathered under
|
||||
// set-root R cannot be re-verified as certifying under a different set R'. The
|
||||
// domain tag carries its own version so a canonical-commitment signature can
|
||||
// never be confused with an outer-id signature.
|
||||
func canonicalVoteMessageFor(pos VotePosition, accept bool) []byte {
|
||||
const tag = "LUX/chain/vote/v2\x00"
|
||||
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
|
||||
binary.BigEndian.PutUint32(u32[:], pos.Round)
|
||||
buf = append(buf, u32[:]...)
|
||||
// CANONICAL execution identity — the signed primary object. Outer ids omitted.
|
||||
// FALLBACK (the non-wrapped degrade, in ONE place): a position whose canonical
|
||||
// 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
|
||||
// the pre-canonical message for that block. A proposervm-wrapped block carries a
|
||||
// distinct CanonicalID and binds THAT. Resolving the fallback here (not at the
|
||||
// position-build sites) guarantees every producer of a position — engine or test —
|
||||
// signs/verifies the SAME bytes for the same block.
|
||||
// Canonical execution identity — the signed primary object. Outer ids omitted.
|
||||
// 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
|
||||
// fixed-set chain) binds its outer id under the canonical slot. A
|
||||
// proposervm-wrapped block carries a distinct CanonicalID and binds that.
|
||||
// Resolving the degrade here rather than at the position-build sites is what
|
||||
// guarantees every producer of a position — engine or test — signs and verifies
|
||||
// the same bytes for the same block.
|
||||
canonicalID := pos.CanonicalID
|
||||
if canonicalID == ids.Empty {
|
||||
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
|
||||
// supplies the SIGNATURE CHECK. Decomplected.
|
||||
//
|
||||
// VerifyVote MUST be deterministic and side-effect free, return true iff sig is
|
||||
// a valid signature by nodeID over message, and NEVER panic on adversarial
|
||||
// input (return false). Implementations that consult a validator set MUST treat
|
||||
// an unknown nodeID as "false", not an error — a cert with an out-of-set voter
|
||||
// is simply invalid.
|
||||
// VerifyVote is deterministic and side-effect free, returns true iff sig is a
|
||||
// valid signature by nodeID over message, and does not panic on adversarial input
|
||||
// (it returns false). An implementation that consults a validator set treats an
|
||||
// unknown nodeID as "false", not an error — a cert with an out-of-set voter is
|
||||
// simply invalid.
|
||||
//
|
||||
// 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
|
||||
// key from a validator set MUST resolve it from the set IN FORCE AT epochHeight —
|
||||
// the SAME height the set-root and the ⅔-by-stake tally are read at — NOT from
|
||||
// the current validator map. Resolving from the current map drops the legitimate
|
||||
// vote of a validator that has since left the current set but was a member at
|
||||
// epochHeight (an async-skew window during a staking change), stalling finality
|
||||
// for that block. The four reads — membership, pubkey, set-root, stake — all key
|
||||
// off epochHeight so a cert is internally consistent at exactly one epoch.
|
||||
// epochHeight is the P-chain height the block's weighted validator set is pinned
|
||||
// to. An implementation that resolves the voter's public key from a validator set
|
||||
// resolves it from the set in force at epochHeight — the same height the set-root
|
||||
// and the ⅔-by-stake tally are read at — never from the current validator map.
|
||||
// Resolving from the current map drops the legitimate vote of a validator that has
|
||||
// since left the current set but was a member at epochHeight (the async-skew window
|
||||
// during a staking change), stalling finality for that block. The four reads —
|
||||
// membership, pubkey, set-root, stake — all key off epochHeight, so a cert is
|
||||
// internally consistent at exactly one epoch.
|
||||
type VoteVerifier interface {
|
||||
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
|
||||
// STAKE-WEIGHTED supermajority rather than a raw voter COUNT. This is the HIGH-3
|
||||
// fix: on a PoS chain with unequal stake, "α distinct voters" is NOT the same as
|
||||
// "≥⅔ of stake" — a coalition of many low-stake validators could reach the count
|
||||
// while controlling a minority of stake. When a value/PoS chain wires a
|
||||
// StakeSource, a cert finalizes only if BOTH hold: (count ≥ α) AND (Σ voter
|
||||
// stake ≥ ⅔ Σ total stake).
|
||||
// stake-weighted supermajority rather than a raw voter count. On a PoS chain with
|
||||
// unequal stake the two are not the same predicate: a coalition of many low-stake
|
||||
// validators can reach "α distinct voters" while controlling a minority of stake.
|
||||
// When a value/PoS chain supplies a StakeSource, a cert finalizes only if both
|
||||
// hold: (count ≥ α) and (Σ voter stake ≥ ⅔ Σ total stake).
|
||||
//
|
||||
// Determinism + fail-closed: Weight MUST be deterministic for a given
|
||||
// (nodeID, epoch) and return 0 for an unknown/out-of-set voter (an out-of-set
|
||||
// voter contributes no stake — it cannot inflate the numerator). TotalStake is
|
||||
// the epoch's total active stake; if it is 0 the source is unusable and the
|
||||
// caller treats the cert as unverifiable (fail closed). The engine binds the
|
||||
// source to the cert's position height so weights are read at the right epoch.
|
||||
// Determinism and fail-closed: Weight is deterministic for a given (nodeID, epoch)
|
||||
// and returns 0 for an unknown or out-of-set voter, so such a voter contributes no
|
||||
// stake and cannot inflate the numerator. TotalStake is the epoch's total active
|
||||
// stake; if it is 0 the source is unusable and the caller treats the cert as
|
||||
// unverifiable. The engine binds the source to the cert's position height so
|
||||
// weights are read at the right epoch.
|
||||
type StakeSource interface {
|
||||
// Weight returns the voting weight (stake) of nodeID at the given height, or
|
||||
// 0 if nodeID is not an active validator at that height.
|
||||
Weight(nodeID ids.NodeID, height uint64) uint64
|
||||
// TotalStake returns the total active validator stake at the given height.
|
||||
TotalStake(height uint64) uint64
|
||||
// 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
|
||||
// count distinct validators, so n must be the LIVE set, not the sample K).
|
||||
// Read from the SAME height-indexed set as Weight/TotalStake so the count-quorum and
|
||||
// the stake-quorum are the identical set. 0 for an unresolved/empty set (view-change
|
||||
// then keeps the configured committee, guarded by the 2α−n>f bound).
|
||||
// ValidatorCount returns the number of distinct active validators at the given
|
||||
// height — the round-scoped view-change's BFT committee size n. Its POL and
|
||||
// precommit both count distinct validators, so n is the live set, not the sample
|
||||
// K. It is read from the same height-indexed set as Weight and TotalStake, so the
|
||||
// count-quorum and the stake-quorum are over one identical set. 0 for an
|
||||
// unresolved or empty set; the view-change then keeps the configured committee,
|
||||
// guarded by the 2α−n>f bound.
|
||||
ValidatorCount(height uint64) int
|
||||
}
|
||||
|
||||
// ValidatorSetRootSource computes the commitment to the active weighted
|
||||
// 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
|
||||
// engine stamps it into every position it signs/assembles so a cert is
|
||||
// cryptographically pinned to the exact set+weights it was certified under
|
||||
// (the MEDIUM fix). It MUST be deterministic for a given height across all
|
||||
// honest nodes (every node computing the root for height H must agree, or their
|
||||
// signatures over the same block would not be mutually verifiable).
|
||||
// engine stamps it into every position it signs or assembles, so a cert is
|
||||
// cryptographically pinned to the exact set and weights it was certified under.
|
||||
// It is deterministic for a given height across all honest nodes: every node
|
||||
// computing the root for height H has to agree, or their signatures over the same
|
||||
// block would not be mutually verifiable.
|
||||
//
|
||||
// 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
|
||||
@@ -391,28 +372,29 @@ type ValidatorSetRootFunc func(height uint64) ids.ID
|
||||
// ValidatorSetRoot implements ValidatorSetRootSource.
|
||||
func (f ValidatorSetRootFunc) ValidatorSetRoot(height uint64) ids.ID { return f(height) }
|
||||
|
||||
// VerifyWeighted is the TIER-SELECTED finality predicate. It first runs Verify (the
|
||||
// tier-agnostic structural + signature predicate) and then enforces the threshold for
|
||||
// the cert's OWN tier:
|
||||
// VerifyWeighted is the tier-selected finality predicate. It first runs Verify (the
|
||||
// tier-agnostic structural and signature predicate) and then enforces the threshold for
|
||||
// the cert's own tier:
|
||||
//
|
||||
// - Nova → a strict MAJORITY of TOTAL STAKE by distinct signers (config.HalfStakeFloor)
|
||||
// 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"
|
||||
// liveness mandate — so the ⅔ gate here would defeat its whole purpose.
|
||||
// Crash-fault-safe by majority intersection; NOT Byzantine-safe (that is Quasar's job).
|
||||
// - Quasar → a strict >⅔ of TOTAL STAKE by distinct signers (config.TwoThirdsStakeFloor).
|
||||
// This is the EXPORT gate — the Byzantine-safe finality bridges / DEX settlement /
|
||||
// cross-chain messages / validator-set transitions consume.
|
||||
// - Nova → a strict majority of total stake by distinct signers (config.HalfStakeFloor)
|
||||
// plus a NovaSignerFloor count, and deliberately not a supermajority. This is the
|
||||
// local-execution rung: it has to ignite at a bare majority, so a ⅔ threshold here
|
||||
// would defeat its purpose. Crash-fault-safe by majority intersection, and not
|
||||
// Byzantine-safe — that is Quasar's job.
|
||||
// - Quasar → a strict >⅔ of total stake by distinct signers (config.TwoThirdsStakeFloor).
|
||||
// This is the export rung, the Byzantine-safe finality bridges, DEX settlement,
|
||||
// cross-chain messages and validator-set transitions consume.
|
||||
//
|
||||
// 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
|
||||
// tier upward: a Nova set of votes relabeled Quasar fails the ⅔-by-stake check, and a
|
||||
// Quasar cert relabeled Nova merely under-claims. An unknown tier fails closed.
|
||||
// The tier is read from the cert, but the threshold is re-derived from the authoritative
|
||||
// validator set rather than taken from the cert's self-declared Threshold, so a cert
|
||||
// cannot forge its tier upward: a Nova set of votes relabeled Quasar fails the ⅔-by-stake
|
||||
// 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
|
||||
// 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
|
||||
// silently count-only.
|
||||
// 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.
|
||||
// VerifyWeighted with a nil source fails closed, so a mis-connected caller is loud rather
|
||||
// than silently count-only.
|
||||
func (c *QuorumCert) VerifyWeighted(verifier VoteVerifier, stake StakeSource, epochHeight uint64) error {
|
||||
if err := c.Verify(verifier, epochHeight); err != nil {
|
||||
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
|
||||
// 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
|
||||
// set, so a cert can never self-declare a below-majority Nova threshold; an UNRESOLVED set
|
||||
// (n<1, or zero total stake) fails closed — a majority of an unknown set cannot be
|
||||
// asserted, and NovaQuorum(0)=1 would otherwise let a lone node self-accept during the
|
||||
// transient-empty validator view that forked 1085013.
|
||||
// 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
|
||||
// are at least NovaSignerFloor(n) of them. Both are recomputed from the authoritative
|
||||
// live set, so a cert can never self-declare a below-majority Nova threshold. An
|
||||
// unresolved set (n<1, or zero total stake) fails closed: a majority of an unknown set
|
||||
// cannot be asserted, and NovaQuorum(0)=1 would otherwise let a lone node self-accept
|
||||
// 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
|
||||
// Quasar ⅔ export gate — it is only read in the unit the chain actually weighs votes in.
|
||||
// On an equal-stake set the two readings are identical (⌊n/2⌋+1 signers either way), so a
|
||||
// uniform fleet sees no change at all. 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
|
||||
// gate: one more entry takes ⌊n/2⌋+1 from 3 to 4, so a set of five validators plus a
|
||||
// Stake, not head-count. Nova is still a bare majority and still deliberately below the
|
||||
// Quasar ⅔ export threshold — it is only read in the unit the chain actually weighs votes
|
||||
// in. On an equal-stake set the two readings are identical (⌊n/2⌋+1 signers either way),
|
||||
// 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
|
||||
// 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
|
||||
// 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.
|
||||
// A majority of stake is neither.
|
||||
//
|
||||
// The signer floor is the guard the stake predicate cannot give: a single validator holding
|
||||
// a stake majority would otherwise self-ignite (the 1085013 class). Stake majority AND the
|
||||
// floor — two independent predicates, neither sufficient alone.
|
||||
// The signer floor is the guard the stake predicate cannot give: a single validator
|
||||
// holding a stake majority would otherwise self-ignite. Stake majority and the floor are
|
||||
// two independent predicates, neither sufficient alone.
|
||||
func (c *QuorumCert) verifyNovaMajority(stake StakeSource, epochHeight uint64) error {
|
||||
n := stake.ValidatorCount(epochHeight)
|
||||
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
|
||||
// cert's distinct voters STRICTLY exceeds two-thirds of the total stake at the epoch.
|
||||
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
|
||||
// height the cert's set-root and the per-voter pubkeys are read at (MEDIUM-1)
|
||||
// — NOT c.Position.Height (the value-chain height, which platformvm would
|
||||
// reject as unfinalized and which races ahead of the P-chain epoch). Reading
|
||||
// the tally at the same epoch the signatures were cast under guarantees a
|
||||
// validator whose vote is in the cert also contributes its epoch weight.
|
||||
// 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 — rather than
|
||||
// at c.Position.Height, the value-chain height, which platformvm would reject as
|
||||
// unfinalized and which races ahead of the P-chain epoch. Reading the tally at
|
||||
// the same epoch the signatures were cast under is what guarantees a validator
|
||||
// whose vote is in the cert also contributes its epoch weight.
|
||||
total := stake.TotalStake(epochHeight)
|
||||
if total == 0 {
|
||||
// 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 {
|
||||
voted += stake.Weight(c.Votes[i].NodeID, epochHeight)
|
||||
}
|
||||
// STRICT supermajority by stake: accept iff voted > floor(2·total/3)
|
||||
// (Tendermint +⅔). The floor is the SINGLE definition in config.TwoThirdsStakeFloor
|
||||
// — the SAME function the live-set parameter sizer derives α from
|
||||
// (config.WeightedSupermajorityThreshold), so the count threshold the node
|
||||
// sizes to can never drift from the stake predicate enforced here.
|
||||
// Strict supermajority by stake: accept iff voted > floor(2·total/3). The floor
|
||||
// has one definition, config.TwoThirdsStakeFloor — the same function the live-set
|
||||
// parameter sizer derives α from (config.WeightedSupermajorityThreshold), so the
|
||||
// count threshold the node sizes to can never drift from the stake predicate
|
||||
// enforced here.
|
||||
twoThirdsFloor := config.TwoThirdsStakeFloor(total)
|
||||
if voted <= twoThirdsFloor {
|
||||
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
|
||||
}
|
||||
|
||||
// AuthorizesExport reports whether THIS cert is export-grade finality (Quasar or
|
||||
// brighter) — the safety boundary a bridge / DEX-settlement / cross-chain consumer MUST
|
||||
// gate on. A Nova cert (local execution) returns false even though it is a valid,
|
||||
// AuthorizesExport reports whether this cert is export-grade finality (Quasar or
|
||||
// brighter) — the safety boundary a bridge, DEX settlement or cross-chain consumer
|
||||
// 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
|
||||
// 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.
|
||||
@@ -524,7 +506,7 @@ func (c *QuorumCert) AuthorizesExport() bool {
|
||||
// signed ACCEPT over Position. It is portable (gossipable), verifiable by any
|
||||
// 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
|
||||
// the predicate in Verify.
|
||||
type QuorumCert struct {
|
||||
@@ -533,17 +515,16 @@ type QuorumCert struct {
|
||||
// Type names the cert's role (QCFinality). Bound into every vote message.
|
||||
Type QCType
|
||||
// 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
|
||||
// which threshold VerifyWeighted enforces, so ONE cert type carries both accept
|
||||
// local-execution cert) or Quasar (a strict ⅔-by-stake export cert). It selects
|
||||
// which threshold VerifyWeighted enforces, so one cert type carries both accept
|
||||
// (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
|
||||
// tier-agnostic ("I accept this block"), and the SAME accept vote counts toward
|
||||
// both a Nova majority and a Quasar supermajority. The tier therefore cannot be
|
||||
// forged UPWARD — VerifyWeighted re-derives the real threshold from the
|
||||
// authoritative validator set, so relabeling a Nova cert as Quasar is rejected
|
||||
// (it will not clear ⅔-by-stake) and relabeling a Quasar cert as Nova only
|
||||
// under-claims (harmless). Only Nova and Quasar are valid here; Horizon is the PQ
|
||||
// seal layer, not a QuorumCert.
|
||||
// proves. It is deliberately not bound into the per-vote signed message: a vote is
|
||||
// tier-agnostic ("I accept this block"), and one accept vote counts toward both a
|
||||
// Nova majority and a Quasar supermajority. The tier therefore cannot be forged
|
||||
// upward — VerifyWeighted re-derives the real threshold from the authoritative
|
||||
// validator set, so relabeling a Nova cert as Quasar is rejected (it will not clear
|
||||
// ⅔-by-stake) and relabeling a Quasar cert as Nova only under-claims. Only Nova and
|
||||
// Quasar are valid here; Horizon is the PQ seal layer, not a QuorumCert.
|
||||
Tier Finality
|
||||
// Position is the consensus position every vote binds to.
|
||||
Position VotePosition
|
||||
@@ -556,13 +537,13 @@ type QuorumCert struct {
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// clause holds; otherwise a typed error naming the FIRST failure. NEVER panics
|
||||
// and NEVER does unbounded work — an adversarial cert (duplicate voter, bad
|
||||
// clause holds; otherwise a typed error naming the first failure. It does not
|
||||
// panic and does no unbounded work — an adversarial cert (duplicate voter, bad
|
||||
// signature, sub-threshold, wrong position) yields a clean error.
|
||||
//
|
||||
// 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
|
||||
// (3) at least one vote
|
||||
// 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
|
||||
// (6) signature verifies under verifier over CanonicalVoteMessage(Position)
|
||||
// then
|
||||
|
||||
@@ -12,6 +12,7 @@ package chain
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
@@ -245,12 +246,13 @@ func TestInversion_DuplicateVoterRejected(t *testing.T) {
|
||||
raw := &QuorumCert{
|
||||
Version: QuorumCertVersion,
|
||||
Type: QCFinality,
|
||||
Tier: Quasar,
|
||||
Position: pos,
|
||||
Threshold: 2,
|
||||
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 {
|
||||
t.Fatal("a cert with non-increasing (duplicate) voters must fail verification")
|
||||
if err := raw.Verify(vs, 1); !errors.Is(err, ErrQCNotStrictlyIncreasing) {
|
||||
t.Fatalf("a cert with non-increasing (duplicate) voters must fail the strictly-increasing clause, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"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.
|
||||
bad := &QuorumCert{
|
||||
Version: QuorumCertVersion, Type: QCFinality, Position: pos, Threshold: 1,
|
||||
Version: QuorumCertVersion, Type: QCFinality, Tier: Quasar, Position: pos, Threshold: 1,
|
||||
Votes: []SignedVote{
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
|
||||
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)}, // dup
|
||||
},
|
||||
}
|
||||
if err := bad.Verify(vs, bad.Position.Height); err == nil {
|
||||
t.Fatal("Verify must reject non-strictly-increasing voters")
|
||||
if err := bad.Verify(vs, bad.Position.Height); !errors.Is(err, ErrQCNotStrictlyIncreasing) {
|
||||
t.Fatalf("Verify must reject non-strictly-increasing voters on the strictly-increasing clause, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +1,32 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// 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
|
||||
// block — refusing to orphan it orphanedHeight=1364
|
||||
// fatal SetPreference would orphan a CONSENSUS-CERTIFIED block — refusing (fail-closed)
|
||||
// error="cannot orphan finalized block at height: 1364 to common block at height: 1363"
|
||||
// - The producer. acceptWithCertCore releases t.mu across every VM call-out, so a second
|
||||
// finalize can complete inside that window and advance both the ledger and the VM to
|
||||
// blockID+1. Steering afterwards at the stale local blockID is a backwards steer, which
|
||||
// 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
|
||||
// certified = head − 1, and with every surviving node holding byte-identical blocks at
|
||||
// every one of those heights: no fork anywhere. os.Exit(1) on a benign state.
|
||||
// - The classifier. "The head is the ledger's certified canonical at its own height" is
|
||||
// trivially true for every healthy node whose head is certified, so on its own it says
|
||||
// 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:
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
// TestReconcile_CertifiedHead_HaltsFailClosed (orphan_reconcile_test.go), which now proves
|
||||
// the halt with BOTH blocks readable — the ledger, not an unreadable id, decides.
|
||||
// TestReconcile_CertifiedHead_HaltsFailClosed (orphan_reconcile_test.go), which proves the
|
||||
// halt with both blocks readable — the ledger, not an unreadable id, decides.
|
||||
package chain
|
||||
|
||||
import (
|
||||
@@ -42,7 +36,7 @@ import (
|
||||
"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
|
||||
// node is in after a second finalize completes while an earlier one is still between its
|
||||
// VM call-outs.
|
||||
@@ -53,13 +47,10 @@ func twoHeightLedger(lower ids.ID, h uint64, upper ids.ID) FinalityLedger {
|
||||
return led
|
||||
}
|
||||
|
||||
// TestReconcile_CertifiedDescendantHead_NoHalt is the incident, reduced. The VM's accepted
|
||||
// head is the CERTIFIED block at 1364; the steer target is the CERTIFIED block at 1363 —
|
||||
// its own parent on the one certified chain. SetPreference refuses (backwards steer), but
|
||||
// nothing would be orphaned: the VM already CONTAINS 1363. The engine must not halt, and
|
||||
// must not touch the VM head.
|
||||
//
|
||||
// Pre-fix this returned false ⇒ os.Exit(1) ⇒ five dead validators.
|
||||
// TestReconcile_CertifiedDescendantHead_NoHalt: the VM's accepted head is the certified
|
||||
// block at h+1 and the steer target is the certified block at h — its own parent on the one
|
||||
// certified chain. SetPreference refuses the backwards steer, but nothing would be orphaned:
|
||||
// the VM already contains h. The engine must neither halt nor touch the VM head.
|
||||
func TestReconcile_CertifiedDescendantHead_NoHalt(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
|
||||
@@ -76,12 +67,12 @@ func TestReconcile_CertifiedDescendantHead_NoHalt(t *testing.T) {
|
||||
}}
|
||||
|
||||
if !e.reconcileVMToCertified(context.Background(), vm, certified, errOrphan) {
|
||||
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 " +
|
||||
"NOT halt fail-closed")
|
||||
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 may " +
|
||||
"not halt fail-closed here")
|
||||
}
|
||||
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)
|
||||
}
|
||||
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.
|
||||
func TestReconcile_CertifiedAncestorHead_NoHalt(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
@@ -107,20 +98,18 @@ func TestReconcile_CertifiedAncestorHead_NoHalt(t *testing.T) {
|
||||
}}
|
||||
|
||||
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 {
|
||||
t.Fatalf("nothing to reconcile, got %d calls", vm.reconcileCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAcceptWithCert_StaleSteerIsNotIssued is the PRODUCER fix, end to end through the sole
|
||||
// finalizer. The VM block's Accept advances the ledger to H+1 — a deterministic injection of
|
||||
// the exact race window acceptWithCertCore documents (t.mu released across the VM call-outs).
|
||||
// The engine must then steer at the LIVE anchor (H+1), never at its stale local H.
|
||||
//
|
||||
// Pre-fix the engine issued SetPreference(H) — backwards, into the EVM's orphan refusal and
|
||||
// (with the pre-fix classifier) into os.Exit(1).
|
||||
// TestAcceptWithCert_StaleSteerIsNotIssued pins the producer property end to end through the
|
||||
// sole finalizer. The VM block's Accept advances the ledger to H+1 — a deterministic
|
||||
// injection of the race window acceptWithCertCore documents, where t.mu is released across
|
||||
// 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.
|
||||
func TestAcceptWithCert_StaleSteerIsNotIssued(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
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)
|
||||
}
|
||||
if vm.steers[0] == lower {
|
||||
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 "+
|
||||
"\"cannot orphan finalized block\"", lower, upper)
|
||||
t.Fatalf("stale backwards steer: SetPreference(%s) at height 1 while the ledger and the VM "+
|
||||
"are already at height 2 (%s) — the VM refuses this as orphaning a finalized block",
|
||||
lower, upper)
|
||||
}
|
||||
if vm.steers[0] != upper {
|
||||
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
|
||||
}
|
||||
|
||||
// TestAcceptWithCert_UnheldAnchorFallsBackToAccepted is the SINGLE-STORE INVARIANT at the
|
||||
// finalize steer — the property ava gets for free by steering at Consensus.Preference()
|
||||
// (ava snow/engine/snowman/voter.go, engine.go), a block it only ever obtained by
|
||||
// verifying INTO the VM.
|
||||
// TestAcceptWithCert_UnheldAnchorFallsBackToAccepted pins the single-store invariant at the
|
||||
// finalize steer: the engine may only name a block its own VM provably holds. An engine that
|
||||
// steers at a preference it obtained solely by verifying into the VM gets this for free; one
|
||||
// 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
|
||||
// its own VM does not hold. Steering there is silently lossy, not loud: the proposervm
|
||||
// keeps its prior preference and returns nil (node/vms/proposervm/vm.go SetPreference),
|
||||
// so the engine's own just-accepted blockID never gets steered either and the VM keeps
|
||||
// building on a head OLDER than the block consensus just finalized.
|
||||
//
|
||||
// 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.
|
||||
// PreferredBuildTip is such a DAG value, so a node that fell behind can name a tip its own VM
|
||||
// does not hold. Steering there is silently lossy rather than loud: the proposervm keeps its
|
||||
// prior preference and returns nil (node/vms/proposervm/vm.go SetPreference), so the engine's
|
||||
// own just-accepted blockID never gets steered either and the VM keeps building on a head
|
||||
// older than the block consensus just finalized. The fallback is blockID, which VM.Accept has
|
||||
// just applied and the VM therefore provably holds.
|
||||
func TestAcceptWithCert_UnheldAnchorFallsBackToAccepted(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
ctx := context.Background()
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
cb := &Block{id: lower, parentID: ids.Empty, height: 1}
|
||||
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)
|
||||
}
|
||||
if vm.steers[0] == upper {
|
||||
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 "+
|
||||
t.Fatalf("steered at an unheld block: SetPreference(%s) names a block this VM does not "+
|
||||
"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)
|
||||
}
|
||||
if vm.steers[0] != lower {
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// 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
|
||||
// certified chain — refusing to orphan it orphanedHeight=12781
|
||||
// fatal SetPreference would orphan a CONSENSUS-CERTIFIED block — refusing (fail-closed)
|
||||
// error="context canceled"
|
||||
// - The target cannot be read. While the engine is stopping, every VM call-out fails with
|
||||
// the context's error; reconcileVMToCertified already declines to crash for that reason
|
||||
// on its other read paths (LastAccepted, GetBlock(head)). Folding a GetBlock error into a
|
||||
// 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
|
||||
// SHUTTING DOWN. reconcileVMToCertified declines to crash for exactly that reason on both of
|
||||
// its other read paths (LastAccepted, GetBlock(head)) — but certifiedAtItsHeight folded its
|
||||
// GetBlock error into a bare `false`, and `false` was the ONE value routed to os.Exit(1). So
|
||||
// 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.
|
||||
// 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
|
||||
// refactor cannot quietly re-collapse them.
|
||||
package chain
|
||||
|
||||
import (
|
||||
@@ -43,7 +37,7 @@ import (
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
*orphanVMBase
|
||||
cached map[ids.ID]*mockBlock // served even after cancellation
|
||||
@@ -66,30 +60,28 @@ func (m *ctxHonoringOrphanVM) ReconcilePreference(_ context.Context, id ids.ID)
|
||||
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
|
||||
// proven about the target, so nothing may be orphaned and the node must NOT halt.
|
||||
//
|
||||
// Pre-fix: GetBlock error ⇒ false ⇒ "NOT on the certified chain" ⇒ os.Exit(1).
|
||||
// proven about the target, so nothing may be orphaned and the node may not halt.
|
||||
func TestReconcile_CertifiedHead_UnreadableSteerTarget_NoHalt(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
|
||||
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)
|
||||
|
||||
vm := &reconcilingOrphanVM{orphanVMBase: &orphanVMBase{
|
||||
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)},
|
||||
}}
|
||||
|
||||
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)")
|
||||
}
|
||||
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)
|
||||
}
|
||||
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
|
||||
// shutting down, so the steer target's read fails with context.Canceled while the head is
|
||||
// still served from cache. This is the state that killed luxd-0 on every restart.
|
||||
// TestReconcile_CertifiedHead_CancelledContext_NoHalt: the engine is shutting down, so the
|
||||
// steer target's read fails with context.Canceled while the head is still served from cache.
|
||||
// A stop is not evidence of anything about the ledger.
|
||||
func TestReconcile_CertifiedHead_CancelledContext_NoHalt(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
|
||||
@@ -119,8 +111,8 @@ func TestReconcile_CertifiedHead_CancelledContext_NoHalt(t *testing.T) {
|
||||
cancel() // the engine is stopping — exactly what failBootstrapChain does
|
||||
|
||||
if !e.reconcileVMToCertified(ctx, vm, certified, errOrphan) {
|
||||
t.Fatal("a cancelled context is a SHUTDOWN, not a double-finalization — the engine must " +
|
||||
"NOT os.Exit(1) while stopping")
|
||||
t.Fatal("a cancelled context is a shutdown, not a double-finalization — the engine may " +
|
||||
"not halt while stopping")
|
||||
}
|
||||
if vm.reconcileCalls != 0 {
|
||||
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
|
||||
// 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
|
||||
// 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.
|
||||
// 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
|
||||
// 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.
|
||||
func TestReconcile_CertifiedHead_TargetAboveFrontier_NoHalt(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
|
||||
@@ -153,17 +142,17 @@ func TestReconcile_CertifiedHead_TargetAboveFrontier_NoHalt(t *testing.T) {
|
||||
}}
|
||||
|
||||
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 " +
|
||||
"engine must NOT halt")
|
||||
"engine may not halt")
|
||||
}
|
||||
if 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
|
||||
// BELOW the frontier, but its height has aged out of byHeight (bounded by pruneBelowWindow).
|
||||
// TestReconcile_CertifiedHead_TargetPrunedFromWindow_NoHalt: the steer target is readable and
|
||||
// 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.
|
||||
func TestReconcile_CertifiedHead_TargetPrunedFromWindow_NoHalt(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
@@ -181,18 +170,17 @@ func TestReconcile_CertifiedHead_TargetPrunedFromWindow_NoHalt(t *testing.T) {
|
||||
}}
|
||||
|
||||
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) {
|
||||
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")
|
||||
t.Fatal("a steer target whose height was pruned from the equivocation window carries " +
|
||||
"no conflicting cert — the engine may not halt on a missing entry")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// error. A future refactor that collapses either back into a bare `false` fails here rather
|
||||
// than in production at 3am.
|
||||
// TestCertifiedAtItsHeight_ThreeValued pins the predicate's contract: `false` means "proven
|
||||
// conflicting" and nothing else. Both "unreadable" and "no cert at this height" report an
|
||||
// error instead. A refactor that collapses either back into a bare `false` fails here.
|
||||
func TestCertifiedAtItsHeight_ThreeValued(t *testing.T) {
|
||||
onChain := ids.GenerateTestID()
|
||||
offChain := ids.GenerateTestID()
|
||||
@@ -205,7 +193,7 @@ func TestCertifiedAtItsHeight_ThreeValued(t *testing.T) {
|
||||
head: onChain,
|
||||
blocks: map[ids.ID]*mockBlock{
|
||||
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()
|
||||
@@ -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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
ok, err := e.certifiedAtItsHeight(ctx, vm, missing)
|
||||
if err == nil {
|
||||
t.Fatal("an UNREADABLE block must report an ERROR, not (false, nil) — conflating the two " +
|
||||
"is what turned a shutdown into a fatal")
|
||||
t.Fatal("an unreadable block must report an error, not (false, nil) — conflating the two " +
|
||||
"would route an orderly shutdown to a fatal")
|
||||
}
|
||||
if 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.
|
||||
above := ids.GenerateTestID()
|
||||
vm.blocks[above] = mb(above, 9) // readable, above the frontier — byHeight has nothing at 9
|
||||
|
||||
+77
-84
@@ -24,18 +24,18 @@ var (
|
||||
// bypasses FinalizeBranch by design (an import is an out-of-band
|
||||
// reconcile, not an α-of-K finalize), so the monotonic invariant the finalize
|
||||
// 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).
|
||||
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
|
||||
// head (lastAcceptedID == ids.Empty) is paired with a POSITIVE height (INFO-6).
|
||||
// An empty head is the genesis/teardown reset, which is meaningful only at
|
||||
// height 0; an empty head at height>0 is contradictory. If allowed it would set
|
||||
// finalizedTip=Empty while LEAVING finalizedHeight/finalizedByHeight stale (the
|
||||
// non-empty seed branch is skipped) and PRUNE blocks below the positive height —
|
||||
// the exact finalizedTip-vs-finalizedHeight desync ForcePreference was hardened
|
||||
// against. SyncState refuses it fail-closed before mutating any state.
|
||||
// ErrSyncStateEmptyWithHeight is returned by SyncState when an empty import head
|
||||
// (lastAcceptedID == ids.Empty) is paired with a positive height. An empty head is
|
||||
// the genesis/teardown reset, meaningful only at height 0; above it the pair is
|
||||
// contradictory. Allowing it would set finalizedTip=Empty while leaving
|
||||
// finalizedHeight/finalizedByHeight stale (the non-empty seed branch is skipped)
|
||||
// and prune blocks below the positive height, so the tip and the height would
|
||||
// describe different chains. SyncState refuses it fail-closed before mutating any
|
||||
// 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)")
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// it; it is the ONLY tip bridges / DEX settlement / cross-chain messages may consume.
|
||||
quasar quasarFrontier
|
||||
@@ -176,7 +176,7 @@ func (c *ChainConsensus) applyCertLocked(cert Cert) (Plan, error) {
|
||||
if err != nil {
|
||||
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
|
||||
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
|
||||
// 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
|
||||
// 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.
|
||||
func (c *ChainConsensus) applyPlan(plan Plan) {
|
||||
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
|
||||
// state with the EVM state database.
|
||||
//
|
||||
// MONOTONIC GUARD (defence-in-depth): SyncState bypasses FinalizeBranch by
|
||||
// design — an import is an out-of-band reconcile with the VM's last-accepted
|
||||
// head, NOT an α-of-K finalize, so it legitimately seeds finalizedTip/Height
|
||||
// directly. But "bypasses the finalize path" must NOT mean "bypasses the
|
||||
// monotonic invariant": a backward import (height below the already-finalized
|
||||
// Monotonicity is re-asserted here, defence in depth. SyncState bypasses
|
||||
// FinalizeBranch by design — an import is an out-of-band reconcile with the VM's
|
||||
// last-accepted head, not an α-of-K finalize, so it legitimately seeds
|
||||
// finalizedTip/Height directly. Bypassing the finalize path does not mean bypassing
|
||||
// the monotonic invariant: a backward import (height below the already-finalized
|
||||
// height) is refused with ErrSyncStateRegression and leaves all finalized state
|
||||
// untouched. Without this guard a shorter/older imported chain could silently
|
||||
// regress finalizedHeight and un-finalize blocks the node already finalized —
|
||||
// re-opening the very fork window the per-height guard closes. A re-import at the
|
||||
// SAME height with the SAME block is idempotent; a forward import advances. The
|
||||
// only allowed move-backward is the genesis/empty reset (lastAcceptedID==Empty),
|
||||
// which is valid ONLY at height 0 — an empty head at height>0 is contradictory
|
||||
// and refused with ErrSyncStateEmptyWithHeight (INFO-6), since it would desync
|
||||
// finalizedTip from finalizedHeight and prune live blocks.
|
||||
// untouched. Otherwise a shorter/older imported chain could silently regress
|
||||
// finalizedHeight and un-finalize blocks the node already finalized, re-opening the
|
||||
// fork window the per-height guard closes. A re-import at the same height with the
|
||||
// same block is idempotent; a forward import advances. The only backward move
|
||||
// allowed is the genesis/empty reset (lastAcceptedID==Empty), valid only at height 0
|
||||
// — an empty head above it is contradictory and refused with
|
||||
// ErrSyncStateEmptyWithHeight, since it would leave finalizedTip and
|
||||
// finalizedHeight describing different chains and prune live blocks.
|
||||
func (c *ChainConsensus) SyncState(lastAcceptedID ids.ID, height uint64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Refuse an empty import head paired with a positive height (INFO-6). An empty
|
||||
// head is the genesis/teardown reset — valid only at height 0. At height>0 it
|
||||
// would assign finalizedTip=Empty while skipping the seed branch (so
|
||||
// finalizedHeight/finalizedByHeight stay stale) AND prune blocks below the
|
||||
// height, desyncing finalizedTip from finalizedHeight (the desync the per-height
|
||||
// guard and ForcePreference exist to prevent). Fail-closed BEFORE any mutation.
|
||||
// Unreachable on the live path (the sole caller, SyncStateFromVM, pairs Empty
|
||||
// with height 0), but guarded so a future caller cannot open the desync.
|
||||
// Refuse an empty import head paired with a positive height. An empty head is the
|
||||
// genesis/teardown reset — valid only at height 0. Above it, it would assign
|
||||
// finalizedTip=Empty while skipping the seed branch (so
|
||||
// finalizedHeight/finalizedByHeight stay stale) and prune blocks below the height,
|
||||
// leaving the tip and the height describing different chains. Fail closed before
|
||||
// any mutation. The sole caller, SyncStateFromVM, pairs Empty with height 0, so
|
||||
// this is unreachable today; it is guarded so a future caller cannot open the gap.
|
||||
if lastAcceptedID == ids.Empty && height > 0 {
|
||||
return fmt.Errorf("%w: refused empty head at height %d", ErrSyncStateEmptyWithHeight, height)
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
if c.ledger.set && lastAcceptedID != ids.Empty && height < c.ledger.height {
|
||||
return fmt.Errorf("%w: import height %d < finalized height %d (block %s)",
|
||||
ErrSyncStateRegression, height, c.ledger.height, lastAcceptedID)
|
||||
}
|
||||
// NOTE (incident-1082814 PART-A): the pre-fix code ALSO ran an equal-height
|
||||
// "different block ⇒ equivocation" check here, because SyncState used to SEED
|
||||
// FINALITY from vm.LastAccepted. It no longer does — the import is a
|
||||
// non-authoritative HINT — so there is nothing to equivocate against and that
|
||||
// check is deliberately gone. Equivocation is decided EXCLUSIVELY by the cert fold
|
||||
// over canonical commitments (Finalize), never from a local seed. This is the
|
||||
// single most important line of the fix: a locally-seeded sibling can no longer
|
||||
// manufacture a finalized entry that a peer's legitimate cert then "conflicts"
|
||||
// with.
|
||||
// There is deliberately no equal-height "different block ⇒ equivocation" check
|
||||
// here. An import is a non-authoritative hint, not a finality seed, so there is
|
||||
// nothing for it to equivocate against: equivocation is decided exclusively by the
|
||||
// cert fold over canonical commitments (Finalize). Were a local seed to enter
|
||||
// finality, a locally-chosen sibling could manufacture a finalized entry that a
|
||||
// peer's legitimate cert then reads as a conflict — refusing the honest chain
|
||||
// forever, since the local entry never moves.
|
||||
|
||||
// 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
|
||||
// it can name an UNCERTIFIED local block whose inner block the network actually
|
||||
// finalized under a different envelope. As a HINT, Height() stays (0,false) until a
|
||||
// real cert, byHeight stays empty, and no cert can ever equivocate against it.
|
||||
// Apply the import as a recovery hint — a build anchor only, never finality.
|
||||
// vm.LastAccepted is the local node's boot/import snapshot; after a crash it can
|
||||
// name an uncertified local block whose inner block the network actually finalized
|
||||
// under a different envelope. As a hint, Height() stays (0,false) until a real
|
||||
// cert, byHeight stays empty, and no cert can equivocate against it.
|
||||
if lastAcceptedID != ids.Empty {
|
||||
// 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
|
||||
@@ -337,11 +334,10 @@ func (c *ChainConsensus) GetFinalizedTip() ids.ID {
|
||||
// GetQuasarTip returns the canonical execution commitment of the highest EXPORT-FINAL
|
||||
// (Quasar, ⅔-by-stake) block — the ONLY tip a bridge / DEX settlement / cross-chain
|
||||
// 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
|
||||
// majority, so it is reorgable and non-exportable until it reaches Quasar. This is the
|
||||
// rename of the former GetCertifiedTip, re-pointed from the Nova fold result to the
|
||||
// trailing Quasar frontier — THE export-gating invariant of the two-tier ladder.
|
||||
// majority, so it is reorgable and non-exportable until it reaches Quasar. Gating export
|
||||
// on this frontier rather than on the accept frontier is the two-tier ladder's whole point.
|
||||
func (c *ChainConsensus) GetQuasarTip() ids.ID {
|
||||
c.mu.RLock()
|
||||
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
|
||||
// (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
|
||||
// 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
|
||||
// carries the old GetCertifiedTip semantics (the Nova ledger's canonical fold result),
|
||||
// renamed to name the tier explicitly so no caller can mistake accept for export.
|
||||
// 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
|
||||
// exported (use GetQuasarTip for any bridge / settlement / cross-chain decision). The
|
||||
// tier is in the name so no caller can mistake accept for export.
|
||||
func (c *ChainConsensus) GetNovaTip() ids.ID {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
@@ -379,10 +374,11 @@ func (c *ChainConsensus) QuasarHeight() (uint64, bool) {
|
||||
// did not accept:
|
||||
//
|
||||
// - 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
|
||||
// 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
|
||||
// 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
|
||||
@@ -419,13 +415,13 @@ func (c *ChainConsensus) PromoteQuasar(cert Cert) (advanced bool, err error) {
|
||||
novaCanonical, have := c.ledger.At(cert.Height)
|
||||
if !have {
|
||||
// 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.
|
||||
return false, nil
|
||||
}
|
||||
if novaCanonical != canonical {
|
||||
// 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.
|
||||
// A ⅔-stake export cert for a canonical the Nova ledger did not accept at this height
|
||||
// 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",
|
||||
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
|
||||
// has been finalized yet. The engine uses this to gate an incoming cert on
|
||||
// height (reject a cert at or below the last-finalized height — MED-5) BEFORE
|
||||
// running the cert through the per-height guard.
|
||||
// has been finalized yet. The engine uses this to gate an incoming cert on height
|
||||
// — a cert at or below the last-finalized height is rejected — before running the
|
||||
// cert through the per-height guard.
|
||||
func (c *ChainConsensus) GetFinalizedHeight() (uint64, bool) {
|
||||
c.mu.RLock()
|
||||
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 /
|
||||
// REPORTING bound only.
|
||||
//
|
||||
// FORBIDDEN AS A SIGN GATE. It was previously named GetNovaAcceptedFloor and fed the equivocation
|
||||
// sign gate, which welded a validator's PERMANENT sign-refusal floor to a reorgable height:
|
||||
// a node that accepted 11364..11367 on a bare Nova majority which was never ⅔-certified could
|
||||
// never again sign at or below 11367 — including the honest rebuild every peer agreed on.
|
||||
// That is the mainnet-1098192 / testnet-11367 permanent halt, and it is why "refusing more is
|
||||
// always fail-safe" is FALSE here: over-refusal on the signing path is not caution, it is a
|
||||
// liveness fault that no restart, resync, or peer can clear. Use GetQuasarSigningFloor for
|
||||
// anything that permanently closes a height.
|
||||
// Not a signing floor. Welding a validator's permanent sign-refusal floor to a reorgable
|
||||
// height strands that validator: heights accepted on a bare Nova majority that was never
|
||||
// ⅔-certified could never be signed at again, including the rebuild every honest peer
|
||||
// agreed on. "Refusing more is always fail-safe" is false on the signing path — over-refusal
|
||||
// there is not caution but a 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,
|
||||
// byHeight / the equivocation index / the authoritative ledger.Height() — it reads the hint
|
||||
// HEIGHT only, never the hint's (untrustworthy) identity.
|
||||
// Read-only: its value never enters byHeight, the equivocation index, or the authoritative
|
||||
// ledger.Height(). It reads the hint's height only, never the hint's (untrustworthy) identity.
|
||||
func (c *ChainConsensus) GetNovaAcceptedFloor() uint64 {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
@@ -522,17 +515,17 @@ func (c *ChainConsensus) K() int {
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// a real k=N quorum (RED HIGH, the 1→N decentralization path). beta (the confidence threshold)
|
||||
// is committee-size-independent and is left unchanged. A degenerate update (k<1, alpha<1, or
|
||||
// alpha>k) is ignored so a mis-derived call can never open a sub-quorum. The engine only ever
|
||||
// calls this to move the committee UP toward the live count (reclampCommitteeLocked, up-only),
|
||||
// so a transient low read can never shrink the quorum here.
|
||||
// at alpha=1) and finalize unilaterally — a cross-node fork against validators 2..N who require
|
||||
// a real k=N quorum. That is the failure mode on the path from one validator to many. beta (the
|
||||
// confidence threshold) is committee-size-independent and is left unchanged. A degenerate update
|
||||
// (k<1, alpha<1, or alpha>k) is ignored so a mis-derived call can never open a sub-quorum. The
|
||||
// engine only ever calls this to move the committee up toward the live count
|
||||
// (reclampCommitteeLocked, up-only), so a transient low read can never shrink the quorum here.
|
||||
func (c *ChainConsensus) Reclamp(k, alpha int) {
|
||||
if k < 1 || alpha < 1 || alpha > k {
|
||||
return
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
package chain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/ids"
|
||||
@@ -103,12 +104,16 @@ func TestEpochBinding_CrossEpochCertRejected(t *testing.T) {
|
||||
certLaundered := &QuorumCert{
|
||||
Version: certA.Version,
|
||||
Type: certA.Type,
|
||||
Tier: certA.Tier,
|
||||
Position: VotePosition{ChainID: chainID, Height: 10, Round: 0, BlockID: blockID, ParentID: ids.Empty, ValidatorSetRoot: rootEpochB},
|
||||
Threshold: certA.Threshold,
|
||||
Votes: certA.Votes, // same signatures, different claimed epoch
|
||||
}
|
||||
if err := certLaundered.Verify(vs, certLaundered.Position.Height); err == nil {
|
||||
t.Fatal("MEDIUM: a cert re-presented under a DIFFERENT validator-set-root must FAIL verification (cross-epoch laundering)")
|
||||
// Name the clause. Verify's clauses are ordered, so a bare err != nil is
|
||||
// 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,
|
||||
|
||||
@@ -1,30 +1,23 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// frontier_rebase_test.go — the ancestry walk must re-test the frontier AFTER a
|
||||
// canonical rebase.
|
||||
// frontier_rebase_test.go — the ancestry walk re-tests the frontier after a canonical
|
||||
// rebase.
|
||||
//
|
||||
// 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,
|
||||
// the walk stands a LOCAL canonical-equivalent wrapper in its place and assigns cur =
|
||||
// localID. That assignment moves cur mid-body, so the loop condition no longer holds for
|
||||
// the value being tested by the checks below it. When the local wrapper IS the finalized
|
||||
// tip, execution reached `curHeight <= led.height` with cur == led.tip and returned
|
||||
// ErrConflictsWithFinalizedBranch — refusing a cert that extends the frontier by exactly
|
||||
// one block, and naming the tip itself as the "losing/pruned sibling branch" it descends
|
||||
// from.
|
||||
// looping while cur != led.tip. A cert may name an outer envelope this node never
|
||||
// tracked; when it does, the walk substitutes a local canonical-equivalent wrapper and
|
||||
// assigns cur = localID. That assignment moves cur mid-body, past the loop condition, so
|
||||
// every check below it tests a value the condition was never applied to. If the
|
||||
// substituted wrapper is itself the finalized tip, the walk falls into
|
||||
// `curHeight <= led.height` holding cur == led.tip and reports
|
||||
// ErrConflictsWithFinalizedBranch: a cert that extends the frontier by exactly one block
|
||||
// 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:
|
||||
//
|
||||
// incoming cert: REFUSED by finality guard (no VM.Accept)
|
||||
// error="chain: cert-selected block does not extend the finalized frontier (it descends
|
||||
// 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.
|
||||
// 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
|
||||
// cert walks the same path to the same refusal. A frontier rebase must therefore re-test
|
||||
// the loop condition against the substituted value before any check consumes it.
|
||||
|
||||
package chain
|
||||
|
||||
@@ -36,8 +29,8 @@ import (
|
||||
)
|
||||
|
||||
// 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
|
||||
// walk returned ErrConflictsWithFinalizedBranch here.
|
||||
// on the finalized tip itself, the path is complete and the cert finalizes. A walk that
|
||||
// does not re-test the frontier after the rebase refuses it as a conflicting branch.
|
||||
func TestPathFromTip_RebaseOntoTip_Finalizes(t *testing.T) {
|
||||
const tipHeight = 36060
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@
|
||||
// peer verify+vote → BroadcastVote ─▶ peer.HandleIncomingVote
|
||||
// α-of-K reached → GossipCert ─▶ peer.HandleIncomingCert → finalize
|
||||
//
|
||||
// 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
|
||||
// asserts that the honest majority independently converges on a SINGLE finalized
|
||||
// block per height — the real property the down/wedged/forked-proposer fix must
|
||||
// deliver. Run under -race.
|
||||
// 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
|
||||
// asserts that the honest majority independently converges on a single finalized
|
||||
// block per height, which is the property that has to hold whether the odd proposer
|
||||
// is down, wedged or forked. Run under -race.
|
||||
//
|
||||
// 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
|
||||
@@ -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
|
||||
// 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
|
||||
// well-formed wrapper over a divergent inner execution.
|
||||
// an honest block would, but a tampered state root, so honest execution Verify
|
||||
// rejects it. This models a forked proposer that produces a well-formed wrapper over
|
||||
// a divergent inner execution.
|
||||
func newForkedBlock(parentID ids.ID, parentStateRoot ids.ID, height uint64, payload string) *simBlock {
|
||||
b := newHonestBlock(parentID, parentStateRoot, height, payload)
|
||||
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
|
||||
// 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).
|
||||
// This is what lets a test reproduce the ASYMMETRIC sibling arrival that splits the
|
||||
// α-of-K vote across siblings — the production down-proposer + WAN-latency condition
|
||||
// the broadcast bus (uniform, instant delivery) cannot express.
|
||||
// This is what lets a test reproduce asymmetric sibling arrival, where the α-of-K vote
|
||||
// splits across siblings — a down proposer plus wide-area latency, which a uniform
|
||||
// instant broadcast bus cannot express.
|
||||
link func(from, to ids.NodeID, kind busMsgKind) bool
|
||||
// delay, when non-nil, returns a per-message-per-target delivery latency. It turns
|
||||
// the uniform instant bus into an ASYNC bus with jitter/reorder — the WAN condition
|
||||
// where node A sees B's prevote before C's and every node's round-view advances on a
|
||||
// DIFFERENT message-arrival order. This is the async-gossip regime the live fleet
|
||||
// stalls in and the synchronous bus (0/200) cannot express. nil ⇒ instant (default).
|
||||
// delay, when non-nil, returns a per-message-per-target delivery latency. It turns the
|
||||
// uniform instant bus into an async bus with jitter and reorder: node A sees B's
|
||||
// prevote before C's, and each node's round view advances on its own arrival order.
|
||||
// Consensus that only converges under uniform delivery stalls here, so the regime is
|
||||
// worth expressing. nil ⇒ instant (default).
|
||||
delay func() time.Duration
|
||||
}
|
||||
|
||||
@@ -539,14 +539,15 @@ func newSimNet(t *testing.T, n int, params config.Parameters) *simNet {
|
||||
VoteSigner: vs.signerFor(i),
|
||||
StakeSource: vs,
|
||||
ValidatorSetRoot: nil,
|
||||
// Catchup wires the engine's runtime auto-recovery seam (requestCatchup →
|
||||
// RequestAncestors). Without it (nil) a healed/behind node can never re-fetch the
|
||||
// gap — the harness could only prove permanent-down liveness, never REJOIN. The
|
||||
// bus-backed transport models the FIXED node layer (peer-select #3 always reaches a
|
||||
// serving peer; have-block-but-unfinalized #5 triggers the fetch; #4 contiguity
|
||||
// applied oldest-first) by serving the requester the finalized gap WITH its real
|
||||
// certs, so re-convergence is EMERGENT (the node's own engine drives the fetch).
|
||||
// Inert for the existing permanent-down tests (a down node never calls requestCatchup).
|
||||
// Catchup gives the engine its runtime auto-recovery path (requestCatchup →
|
||||
// RequestAncestors). With it nil, a healed but behind node can never re-fetch the
|
||||
// gap, so the harness could prove liveness while a node stays down but never a
|
||||
// rejoin. The bus-backed transport models a working node layer: peer selection
|
||||
// reaches a serving peer, holding a block without a cert for it triggers the fetch,
|
||||
// and the gap is applied oldest-first so it stays contiguous. It serves the requester
|
||||
// the finalized gap with its real certs, so re-convergence is emergent — the node's
|
||||
// own engine drives the fetch. Inert while a node stays down, since a down node never
|
||||
// calls requestCatchup.
|
||||
Catchup: &busCatchup{net: net, selfIdx: i},
|
||||
}
|
||||
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
|
||||
// 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.
|
||||
func (net *simNet) quasarEverywhere(blk *simBlock) (all bool, fork bool) {
|
||||
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 —
|
||||
// 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
|
||||
// the export frontier lagged instead of only printing opaque IDs.
|
||||
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
|
||||
// (⌊2·5/3⌋+1). With one validator down/wedged/forked the remaining 4 are the
|
||||
// EXACT quorum (zero margin) — the mainnet condition. RoundTO is parked long so
|
||||
// the background re-poll ticker does not interfere with the emergent assertion
|
||||
// (finalization is driven by real gossip, not the ticker).
|
||||
// (⌊2·5/3⌋+1). With one validator down, wedged or forked, the remaining 4 are exactly
|
||||
// the quorum and no more — the zero-margin case, where losing a single further vote
|
||||
// stops finalization. RoundTO is parked long so the background re-poll ticker does not
|
||||
// interfere with the emergent assertion (finalization is driven by real gossip, not the
|
||||
// ticker).
|
||||
func prodParams5() config.Parameters {
|
||||
p := params5()
|
||||
p.K = 5
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// multinode_proposer_test.go — the down/wedged/forked-designated-proposer
|
||||
// LIVENESS + SAFETY suite, proven on the REAL multi-node harness
|
||||
// (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.
|
||||
// multinode_proposer_test.go — liveness and safety when the designated proposer
|
||||
// for a height is down, wedged, or forked.
|
||||
//
|
||||
// The mainnet-unblock property under test: a height's designated proposer is
|
||||
// DOWN, WEDGED (present but non-productive), or FORKED (emits a divergent-
|
||||
// execution block); a SUBSTITUTE builds the canonical block; and the honest
|
||||
// majority independently converges on that single block. The three faults are
|
||||
// DISTINCT scenarios with distinct assertions.
|
||||
// The suite runs on the multi-node harness (multinode_harness_test.go): N
|
||||
// independent *Runtime engines wired through an in-process gossip bus,
|
||||
// finalizing from real block gossip, real signed vote broadcast, and real α-of-K
|
||||
// cert assembly and verification. No synthetic quorum is hand-fed, so
|
||||
// finalization here is emergent rather than asserted into being.
|
||||
//
|
||||
// SAFETY is co-tested in the SAME harness (the liveness retry must not lower the
|
||||
// BFT threshold): sub-quorum, forged cert, and a post-finalization sibling all
|
||||
// FAIL to finalize. BFT ≥ avalanche (f < n/3): at K=5, α=4, f=1 the four healthy
|
||||
// validators are the EXACT quorum (zero margin) — the mainnet condition.
|
||||
// The property under test: when a height's designated proposer is down (silent
|
||||
// in both directions), wedged (present but non-productive), or forked (emitting
|
||||
// a divergent-execution block), a substitute builds the canonical block and the
|
||||
// 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
|
||||
// RED in its doc comment; the mechanisms live in v1.33.0 (never-abandon-own
|
||||
// re-poll, per-height finalize guard, canonical-keyed equivocation) plus the
|
||||
// engine.go build-path re-solicit alignment added with this suite.
|
||||
// Safety is co-tested in the same harness, because a liveness retry must never
|
||||
// lower the BFT threshold: a sub-quorum, a forged cert, and a post-finalization
|
||||
// sibling all fail to finalize. With f < n/3, at K=5, α=4, f=1 the four healthy
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -35,17 +36,18 @@ import (
|
||||
const emergeTO = 8 * time.Second // generous bound for emergent finalization under -race
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// BASELINE — the harness itself finalizes emergently. If this cannot go green,
|
||||
// no fault scenario below is meaningful.
|
||||
// Baseline — the harness itself finalizes emergently. If this cannot pass, no
|
||||
// fault scenario below is meaningful.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// TestMultiNode_HealthyProposer_EmergentFinalization: 5 up validators, node 0 is
|
||||
// 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
|
||||
// block — with no test-injected votes. Proves the emergent vote/cert topology.
|
||||
// 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
|
||||
// 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
|
||||
// followers never broadcast their votes) → no cert ever assembles → RED.
|
||||
// The assertion rests on followers broadcasting their own votes (integration.go,
|
||||
// followVerifiedBlock): with that broadcast absent, no cert can ever assemble.
|
||||
func TestMultiNode_HealthyProposer_EmergentFinalization(t *testing.T) {
|
||||
net := newSimNet(t, 5, prodParams5())
|
||||
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)
|
||||
}
|
||||
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
|
||||
// (crashed/partitioned — inbound AND outbound dropped). A substitute (node 1)
|
||||
// builds the canonical block; the four reachable validators are the EXACT α=4
|
||||
// quorum (zero margin) and must finalize WITHOUT the down node. Self-healing: no
|
||||
// reboot, no manual step.
|
||||
// 4/5 with a down proposer. The designated proposer (node 0) is down — crashed or
|
||||
// partitioned, inbound and outbound both dropped. A substitute (node 1) builds the
|
||||
// canonical block; the four reachable validators are the exact α=4 quorum, zero
|
||||
// margin, and must finalize without the down node: no reboot, no manual step.
|
||||
//
|
||||
// fails-before: revert engine.go rePollAllPending's own-proposal exemption (let
|
||||
// maxRePollAttempts abandon the substitute's undecided own block) AND drop the
|
||||
// build-path re-solicit → the 4th honest vote that arrives late is never
|
||||
// re-solicited → the zero-margin quorum never completes → RED.
|
||||
// Two mechanisms carry this. rePollAllPending exempts a node's own proposal from
|
||||
// maxRePollAttempts, so a substitute never abandons its own undecided block; and
|
||||
// the build path re-solicits. At zero margin the fourth honest vote is routinely
|
||||
// the late one, and a quorum that is never re-solicited never completes.
|
||||
// -----------------------------------------------------------------------------
|
||||
func TestMultiNode_DownProposer_SubstituteFinalizes(t *testing.T) {
|
||||
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")
|
||||
net.build(1, sub) // node 1 substitutes
|
||||
@@ -88,33 +89,33 @@ func TestMultiNode_DownProposer_SubstituteFinalizes(t *testing.T) {
|
||||
all, fork := net.finalizedEverywhere(sub)
|
||||
return all && !fork
|
||||
}) {
|
||||
t.Fatalf("DOWN-PROPOSER HALT: 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.",
|
||||
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",
|
||||
sub.ID(), net.headsAtHeight(1))
|
||||
}
|
||||
// The down node finalized nothing (it received nothing).
|
||||
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 {
|
||||
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
|
||||
// proposer and is PRESENT (its inbound is live — it receives gossip, tracks,
|
||||
// verifies, and will finalize via cert) but WEDGED: its OUTBOUND is silenced, so
|
||||
// the block it "builds" never propagates and its votes never reach peers. This is
|
||||
// distinct from DOWN: a wedged-present node still CONVERGES on the canonical block
|
||||
// (it finalizes via the inbound cert), it just cannot drive its own proposal.
|
||||
// 4/5 with a wedged-but-present proposer. Node 0 is the designated proposer and is
|
||||
// present — its inbound is live, so it receives gossip, tracks, verifies, and will
|
||||
// finalize via cert — but wedged: its outbound is silenced, so the block it builds
|
||||
// never propagates and its votes never reach peers. That is what separates wedged
|
||||
// from down. A wedged-present node still converges on the canonical block through
|
||||
// the inbound cert; it simply cannot drive its own proposal.
|
||||
//
|
||||
// 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
|
||||
// block finalizes NOWHERE.
|
||||
// α=4; node 0 finalizes it too, which is what "present" means here, while node 0's
|
||||
// own wedged block finalizes nowhere.
|
||||
//
|
||||
// fails-before: same revert as the down gate (own-proposal never-abandon +
|
||||
// build-path re-solicit) → RED.
|
||||
// Rests on the same two mechanisms as the down case: a node never abandons its own
|
||||
// proposal, and the build path re-solicits.
|
||||
// -----------------------------------------------------------------------------
|
||||
func TestMultiNode_WedgedPresentProposer_SubstituteFinalizes(t *testing.T) {
|
||||
net := newSimNet(t, 5, prodParams5())
|
||||
@@ -135,38 +136,39 @@ func TestMultiNode_WedgedPresentProposer_SubstituteFinalizes(t *testing.T) {
|
||||
all, fork := net.finalizedEverywhere(canonical)
|
||||
return all && !fork
|
||||
}) {
|
||||
t.Fatalf("WEDGED-PROPOSER HALT: validators did not finalize the substitute's canonical block %s "+
|
||||
"past the wedged-but-present proposer (heads=%v).", canonical.ID(), net.headsAtHeight(1))
|
||||
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))
|
||||
}
|
||||
// "Present": the wedged node finalized the canonical block via inbound cert.
|
||||
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)
|
||||
}
|
||||
// The wedged block finalized nowhere.
|
||||
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 {
|
||||
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 divergent-execution block (a well-formed wrapper over a tampered state root)
|
||||
// and actively gossips it to everyone. Every honest node PARSES it, RE-EXECUTES,
|
||||
// and REJECTS it (its claimed state root != the deterministic execution result) —
|
||||
// so it is never tracked, never voted, never finalized (no early out-of-turn
|
||||
// acceptance). The substitute (node 1) builds the canonical block, which honest
|
||||
// nodes finalize. Exactly one head; no double-finalize.
|
||||
// A forked proposer. Node 3 emits a divergent-execution block — a well-formed
|
||||
// wrapper over a tampered state root — and actively gossips it to everyone. Every
|
||||
// honest node parses it, re-executes it, and rejects it, because its claimed state
|
||||
// root does not equal the deterministic execution result. So it is never tracked,
|
||||
// never voted, never finalized: acceptance is never granted out of turn on the
|
||||
// strength of the wrapper alone. The substitute (node 1) builds the canonical
|
||||
// block, which honest nodes finalize. Exactly one head, no double-finalize.
|
||||
//
|
||||
// This is the engine-boundary half of the forked-proposer matrix: verify binds
|
||||
// the INNER execution (state root), not just the outer wrapper. The proposervm
|
||||
// inner-block-Verify half is proven in the node package.
|
||||
// This is the engine-boundary half of the forked-proposer matrix: verify binds the
|
||||
// inner execution (the state root), not just the outer wrapper. The proposervm
|
||||
// inner-block Verify half is proven in the node package.
|
||||
//
|
||||
// fails-before: make simBlock.Verify ignore the state root (accept any) → the
|
||||
// forked block is tracked+voted and can double-finalize → RED.
|
||||
// The assertion rests on simBlock.Verify binding that state root. Were verify to
|
||||
// accept any claimed root, the forked block would be tracked, voted, and could
|
||||
// double-finalize.
|
||||
// -----------------------------------------------------------------------------
|
||||
func TestMultiNode_ForkedProposer_DivergentRejected_CanonicalFinalizes(t *testing.T) {
|
||||
net := newSimNet(t, 5, prodParams5())
|
||||
@@ -183,16 +185,16 @@ func TestMultiNode_ForkedProposer_DivergentRejected_CanonicalFinalizes(t *testin
|
||||
all, fork := net.finalizedEverywhere(canonical)
|
||||
return all && !fork
|
||||
}) {
|
||||
t.Fatalf("FORKED-PROPOSER HALT: honest validators did not finalize the canonical block %s past the "+
|
||||
"forked proposer (heads=%v).", canonical.ID(), net.headsAtHeight(1))
|
||||
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))
|
||||
}
|
||||
// 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 {
|
||||
t.Fatal("SAFETY VIOLATION: a divergent-execution (forked) block finalized somewhere — honest execution " +
|
||||
"Verify must reject it before any vote (no early out-of-turn acceptance).")
|
||||
t.Fatal("safety: a divergent-execution (forked) block finalized somewhere — honest execution " +
|
||||
"verify must reject it before any vote is cast for it")
|
||||
}
|
||||
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).
|
||||
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.
|
||||
// Height 1 finalizes to block A (canonical). A conflicting sibling B at the SAME
|
||||
// height 1 is then built + gossiped by another substitute. Every node has already
|
||||
// finalized A at height 1, so B is REFUSED everywhere (the per-height guard keyed
|
||||
// on the canonical commitment) and reported as equivocation — never a second head.
|
||||
// Safety: no double-finalize, one head per height, sibling convergence. Height 1
|
||||
// finalizes to block A. A conflicting sibling B at that same height 1 is then built
|
||||
// and gossiped by another substitute. Every node has already finalized A there, so
|
||||
// B is refused everywhere by the per-height guard keyed on the canonical
|
||||
// commitment, and reported as equivocation — never as a second head.
|
||||
//
|
||||
// This is the deterministic form of "sibling convergence": once a height is
|
||||
// decided, the network is LOCKED to that head; a late competing sibling cannot
|
||||
// fork it, no matter that honest nodes would otherwise vote for any verified block.
|
||||
// This is the deterministic form of sibling convergence: once a height is decided
|
||||
// the network is locked to that head, and a late competing sibling cannot fork it,
|
||||
// however willing honest nodes are to vote for any verified block.
|
||||
//
|
||||
// fails-before: revert the topology.go HandleIncomingCert height gate (`cert.
|
||||
// Position.Height <= fh` early-return) and the FinalizeBranch per-height guard →
|
||||
// B's cert double-finalizes height 1 → RED.
|
||||
// Two guards carry it: HandleIncomingCert returns early for a cert at or below the
|
||||
// finalized height, and FinalizeBranch holds a per-height guard. Without both, B's
|
||||
// cert double-finalizes height 1.
|
||||
// -----------------------------------------------------------------------------
|
||||
func TestMultiNode_NoDoubleFinalize_LateSiblingRejected(t *testing.T) {
|
||||
net := newSimNet(t, 5, prodParams5())
|
||||
@@ -238,21 +240,21 @@ func TestMultiNode_NoDoubleFinalize_LateSiblingRejected(t *testing.T) {
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
|
||||
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 {
|
||||
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
|
||||
// 0,1,2); node 1 builds. 3 < α=4, so no cert can ever assemble no matter how long
|
||||
// the block is re-solicited. Liveness retry re-SOLICITS; it can never manufacture
|
||||
// the 4th vote.
|
||||
// Safety: a genuine sub-quorum never finalizes. Only nodes 0 and 1 are up and node
|
||||
// 1 builds. Two of five is below NovaQuorum(5)=3, so no cert can assemble however
|
||||
// long the block is re-solicited. A liveness retry re-solicits; it can never
|
||||
// manufacture the missing vote.
|
||||
//
|
||||
// fails-before: lower α below the live-up count (e.g. α=3) → the 3 up nodes
|
||||
// finalize → RED (proving the assertion actually depends on the threshold).
|
||||
// The assertion depends on the threshold itself, not on the retry: were the accept
|
||||
// count lowered to the live-up count, this same set would finalize.
|
||||
// -----------------------------------------------------------------------------
|
||||
func TestMultiNode_BelowMajorityNeverAccepts(t *testing.T) {
|
||||
net := newSimNet(t, 5, prodParams5())
|
||||
@@ -263,11 +265,11 @@ func TestMultiNode_BelowMajorityNeverAccepts(t *testing.T) {
|
||||
blk := newHonestBlock(ids.Empty, simGenesisRoot(), 1, "subquorum-h1")
|
||||
net.build(1, blk)
|
||||
|
||||
// Re-solicit far past any backoff; a BELOW-MAJORITY online set must STILL never accept. Under
|
||||
// v1.36 the accept floor is the MAJORITY (a 3-of-5 majority DOES accept — "survive 3/5"), so
|
||||
// the fail-closed floor moves to 2-of-5: below the majority, no Nova cert, no acceptance.
|
||||
// Re-solicit far past any backoff; a below-majority online set must still never accept. The
|
||||
// accept floor is the majority, so a 3-of-5 set does accept and the fail-closed floor sits at
|
||||
// 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 }) {
|
||||
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 {
|
||||
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
|
||||
// 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
|
||||
// WITHOUT any reset. Models the self-heal-and-continue property the 7-gate
|
||||
// requires (a node never needs a DB wipe to make progress).
|
||||
// Safety: sustained liveness across heights, with no reset and no state-wipe path.
|
||||
// The network finalizes height 1, then builds and finalizes height 2 on top of it,
|
||||
// so finalization is durable and the chain keeps producing past a fault without
|
||||
// any reset. A node never needs its state wiped to make progress.
|
||||
// -----------------------------------------------------------------------------
|
||||
func TestMultiNode_SustainedLiveness_TwoHeights(t *testing.T) {
|
||||
net := newSimNet(t, 5, prodParams5())
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// 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.
|
||||
// Crash-fault-safe (majority intersection), NOT Byzantine-safe.
|
||||
// QUASAR = EXPORT — strict >⅔ of STAKE by distinct signers. Byzantine-fork-safe.
|
||||
// The ONLY tier bridges / DEX settlement / cross-chain consume.
|
||||
// Nova = local accept — NovaQuorum(n)=⌊n/2⌋+1 distinct signers. Drives VM.Accept.
|
||||
// Crash-fault-safe by majority intersection, not Byzantine-safe.
|
||||
// Quasar = export — strictly more than ⅔ of stake by distinct signers, and so
|
||||
// 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
|
||||
// blocks ever both reach QUASAR at one height. Nova may fork under equivocation (expected,
|
||||
// non-exportable); Quasar may not (that needs >⅓ stake double-signing, beyond f<⅓).
|
||||
// The invariant asserted throughout: across every scenario below, no two conflicting
|
||||
// blocks both reach Quasar at one height. Nova may fork under equivocation — that is
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -35,11 +37,11 @@ const exportTO = 25 * time.Second
|
||||
// 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
|
||||
// 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
|
||||
// (Nova at the majority, Quasar only at ⅔, no two conflicting Quasars, export gating) are ALSO
|
||||
// asserted by the SINGLE-ENGINE matrix tests (Threshold / WeightedStake / SingleValidator /
|
||||
// Equivocation / DegradedRPC), which are deterministic and DO run under -race. Mirrors the
|
||||
// convergence-storm gate discipline in race_flag_race_test.go.
|
||||
// the convergence goroutine and the vote handlers. The two-tier safety invariants asserted
|
||||
// here — Nova at the majority, Quasar only at ⅔, no two conflicting Quasars, export gating —
|
||||
// are also asserted by the single-engine matrix tests (Threshold, WeightedStake,
|
||||
// SingleValidator, Equivocation, DegradedRPC), which are deterministic and do run under
|
||||
// -race. Mirrors the convergence-storm discipline in race_flag_race_test.go.
|
||||
func skipMultinodeUnderRace(t *testing.T) {
|
||||
t.Helper()
|
||||
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
|
||||
@@ -108,8 +110,8 @@ func TestNovaQuasarMatrix_Threshold_NovaMajority_QuasarTwoThirds(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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
|
||||
// of stake, Quasar on a ⅔ SUPERMAJORITY of it. A four-node head-count holding 40% clears neither;
|
||||
// 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;
|
||||
// the 60% holder's vote clears both, and because it trails, it arrives as a late attestation.
|
||||
func TestNovaQuasarMatrix_WeightedStake_NovaMajorityQuasarSupermajority(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
@@ -119,7 +121,7 @@ func TestNovaQuasarMatrix_WeightedStake_NovaMajorityQuasarSupermajority(t *testi
|
||||
e, chainID := newQuorumEngineOpts(t, dyn5(), vs, 0, rec, WithStakeWeighting(skew))
|
||||
|
||||
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.
|
||||
cb := &Block{id: blk.id, parentID: blk.parentID, height: blk.height, timestamp: blk.timestamp.Unix(), data: blk.bytes}
|
||||
_ = e.consensus.AddBlock(context.Background(), cb)
|
||||
@@ -132,9 +134,9 @@ func TestNovaQuasarMatrix_WeightedStake_NovaMajorityQuasarSupermajority(t *testi
|
||||
for _, i := range []int{1, 2, 3, 4} {
|
||||
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")
|
||||
// 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")
|
||||
|
||||
// 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 }
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// (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
|
||||
// live validators PRODUCE (Nova accepts + converges everywhere), but the export tier does NOT
|
||||
// advance (3/5 = 60% stake ≤ ⅔). Gate (d): the Quasar tip stays Empty (export consumers see no
|
||||
// finalized block) even though the Nova/accept tip has advanced. This is the owner's mandate:
|
||||
// "survive 3/5, majority is enough — production continues, certification pauses."
|
||||
// TestNovaQuasarMatrix_ThreeOfFive_NovaNoQuasar: with 2 of 5 validators down, the 3 live
|
||||
// validators produce — Nova accepts and converges everywhere — but the export tier does not
|
||||
// advance, because 3/5 is 60% of stake and that is not more than ⅔. The Quasar tip therefore
|
||||
// stays Empty, so an export consumer sees no finalized block, even though the Nova accept tip
|
||||
// has advanced. A majority is enough to keep producing; certification pauses until ⅔ attest.
|
||||
func TestNovaQuasarMatrix_ThreeOfFive_NovaNoQuasar(t *testing.T) {
|
||||
skipMultinodeUnderRace(t)
|
||||
net := newSimNet(t, 5, prodParams5())
|
||||
@@ -194,9 +196,9 @@ func TestNovaQuasarMatrix_ThreeOfFive_NovaNoQuasar(t *testing.T) {
|
||||
all, fork := net.finalizedEverywhere(blk)
|
||||
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 {
|
||||
for _, n := range net.nodes {
|
||||
if !n.reachable() {
|
||||
@@ -208,24 +210,24 @@ func TestNovaQuasarMatrix_ThreeOfFive_NovaNoQuasar(t *testing.T) {
|
||||
}
|
||||
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 {
|
||||
if !n.reachable() {
|
||||
continue
|
||||
}
|
||||
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 {
|
||||
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
|
||||
// block reaches Quasar and every up node exports the SAME block (no export fork).
|
||||
// TestNovaQuasarMatrix_FourOfFive_ExportsQuasar: with 4 of 5 up (80% of stake, more than ⅔),
|
||||
// the block reaches Quasar and every up node exports the same block — no export fork.
|
||||
func TestNovaQuasarMatrix_FourOfFive_ExportsQuasar(t *testing.T) {
|
||||
skipMultinodeUnderRace(t)
|
||||
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")
|
||||
net.build(0, blk)
|
||||
|
||||
// NOVA accept converges.
|
||||
// Nova accept converges.
|
||||
if !waitFor(emergeTO, func() bool {
|
||||
all, fork := net.finalizedEverywhere(blk)
|
||||
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
|
||||
// design actually makes. promoteQuasar: a block whose Nova cert did not itself carry ⅔
|
||||
// stake stays Nova-only, and "a later fully-voted block advances the export frontier past
|
||||
// the gap (a Quasar tip finalizes all its ancestors), so no per-block retroactive
|
||||
// promotion is needed for liveness."
|
||||
// The guarantee lives at the FRONTIER, not per block. promoteQuasar leaves a block whose own
|
||||
// Nova cert did not itself carry ⅔ of stake as Nova-only; a later fully-voted block then
|
||||
// carries the export frontier past it, and a Quasar tip finalizes all of its ancestors, so
|
||||
// no retroactive per-block promotion is needed for liveness.
|
||||
//
|
||||
// Demanding that THIS block export, while building no successor, asserted the stronger
|
||||
// per-block property. The relevant threshold is NOT α: assembleCertLocked freezes the Nova
|
||||
// cert at NovaQuorum(5)=3 verified votes, and α plays no part in cert assembly at all —
|
||||
// which matters because prodParams5 sets α=4 and twoThirdsCount(5) is also 4, so "completed
|
||||
// on the bare α without ⅔" would be arithmetically impossible and sends the next reader down
|
||||
// a dead end. A cert that freezes at 3 has 60% of stake, below the ⅔ export floor, so that
|
||||
// block never exports and the frontier has nothing to ride. Driving successive heights is not
|
||||
// a weaker assertion: the frontier advancing past height 1 finalizes height 1 with it, which
|
||||
// is exactly what an export consumer (bridge, DEX settlement) observes.
|
||||
// Which is why this waits on the frontier rather than on this one block. The threshold that
|
||||
// matters here is not α: assembleCertLocked freezes the Nova cert at NovaQuorum(5)=3 verified
|
||||
// votes, and α plays no part in cert assembly at all. That is worth stating, because
|
||||
// prodParams5 sets α=4 and twoThirdsCount(5) is also 4, so reading α as the export gate makes
|
||||
// "a cert completed on the bare α without ⅔" look arithmetically impossible and leads a
|
||||
// reader nowhere. A cert frozen at 3 carries 60% of stake, below the ⅔ export floor, so that
|
||||
// block never exports on its own and the frontier needs a successor to ride. Driving
|
||||
// successive heights is not the weaker claim: the frontier advancing past height 1 finalizes
|
||||
// height 1 along with it, which is exactly what an export consumer — a bridge, DEX settlement
|
||||
// — observes.
|
||||
ok, chain := waitForExportFrontier(net, blk, exportTO)
|
||||
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
|
||||
// above blk the tip is legitimately a DESCENDANT of blk — comparing the tip to blk would
|
||||
// flag honest frontier progress as a fork. What must never happen is a node exporting a
|
||||
// DIFFERENT canonical at blk's own height.
|
||||
// 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 not happen is a node exporting a
|
||||
// different canonical at blk's own height.
|
||||
for i, n := range net.nodes {
|
||||
if !n.reachable() {
|
||||
continue
|
||||
@@ -275,43 +277,43 @@ func TestNovaQuasarMatrix_FourOfFive_ExportsQuasar(t *testing.T) {
|
||||
if !exported || qh < blk.height {
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// canonical is the precise shape SyncQuasarFrontier can seed, and excusing it here would
|
||||
// skip the one case this check cites as its reason to exist.
|
||||
// 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
|
||||
// nothing about what was exported; read the tip directly.
|
||||
// There is no ids.Empty exemption. A node reporting an export height at or above blk with
|
||||
// an empty canonical is the precise shape SyncQuasarFrontier can seed, and excusing it
|
||||
// here would skip the one case this check exists for.
|
||||
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)",
|
||||
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.
|
||||
got, held := n.rt.FinalizedBlockAtHeight(blk.height)
|
||||
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)
|
||||
}
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// (f) 3/5 partition, BOTH sides: majority side produces Nova; neither exports Quasar;
|
||||
// on heal a single Quasar chain (no two conflicting Quasars).
|
||||
// A 3/5 partition, seen from both sides: the majority side produces Nova, neither side
|
||||
// exports Quasar, and the heal yields a single Quasar chain.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// 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
|
||||
// Quasar (3/5 = 60% ≤ ⅔ on one side, 2/5 below even the Nova majority on the other). On heal,
|
||||
// once all 5 are up a fresh block reaches Quasar — a SINGLE export chain, never two conflicting
|
||||
// Quasars.
|
||||
// {0,1,2} and a 2-side {3,4}. The majority side produces — Nova advances — and neither side
|
||||
// reaches Quasar: 3/5 is 60% of stake, not more than ⅔, and 2/5 is below even the Nova
|
||||
// majority. On heal, with all 5 up, a fresh block reaches Quasar on a single export chain,
|
||||
// never two conflicting ones.
|
||||
func TestNovaQuasarMatrix_Partition_MajorityProducesNeitherExports(t *testing.T) {
|
||||
skipMultinodeUnderRace(t)
|
||||
net := newSimNet(t, 5, prodParams5())
|
||||
@@ -325,16 +327,16 @@ func TestNovaQuasarMatrix_Partition_MajorityProducesNeitherExports(t *testing.T)
|
||||
all, fork := net.finalizedEverywhere(blk1)
|
||||
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 {
|
||||
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
|
||||
// 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[4].setUp(true)
|
||||
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.
|
||||
chain := map[ids.ID]bool{blk1.ID(): true}
|
||||
// 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
|
||||
// than the claim above it — it left nothing built behind the frontier and failed ~12% of
|
||||
// the time whenever any companion test ran (fleet at h3, newest h4, assert waited out
|
||||
// exportTO). "Export RESUMES" is proven by any post-heal height exporting, with the two
|
||||
// later blocks as slack; that no side exported DURING the partition is asserted above.
|
||||
// blocks, so requiring the NEWEST height to export asserts a timing detail rather than the
|
||||
// claim above it, and leaves nothing built behind the frontier for the frontier to ride.
|
||||
// "Export resumes" is proven by any post-heal height exporting, with the later blocks as
|
||||
// slack; that no side exported during the partition is asserted above.
|
||||
var target *simBlock
|
||||
// exported: has any reachable node exported a POST-HEAL height yet?
|
||||
exported := func() bool {
|
||||
@@ -364,16 +365,13 @@ func TestNovaQuasarMatrix_Partition_MajorityProducesNeitherExports(t *testing.T)
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Keep PRODUCING until export resumes. Export needs a fresh ⅔ poll, and the two healed
|
||||
// nodes may still have been catching up during the last one — leaving 3 of 5 voting, which
|
||||
// is below ⅔. Waiting cannot conjure a new poll, so a passive wait after the final block
|
||||
// could never recover and simply timed out (~12% of runs, tipHeights=map[] — nothing
|
||||
// exported at all). A real chain resumes export as blocks arrive; so does this one.
|
||||
// Drive heights until the export frontier moves, bounded by exportTO rather than by an
|
||||
// arbitrary height cap. The cap plus a trailing non-driving waitFor was the defect: if the
|
||||
// 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.
|
||||
// Keep producing until export resumes. Export needs a fresh ⅔ poll, and the two healed nodes
|
||||
// may still have been catching up during the last one, leaving 3 of 5 voting — below ⅔.
|
||||
// Waiting cannot conjure a new poll: export only ever advances on a NEW fully-voted block,
|
||||
// since promoteQuasar does no retroactive promotion. So a passive wait after the final block
|
||||
// waits for an event that, by construction, nothing can now cause. A real chain resumes
|
||||
// export as blocks arrive, and so does this one: drive heights until the export frontier
|
||||
// moves, bounded by exportTO rather than by a height cap.
|
||||
exportDeadline := time.Now().Add(exportTO)
|
||||
for h := uint64(2); !exported() && time.Now().Before(exportDeadline); 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)
|
||||
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()
|
||||
parentStateRoot = blk.stateRoot
|
||||
@@ -391,40 +389,43 @@ func TestNovaQuasarMatrix_Partition_MajorityProducesNeitherExports(t *testing.T)
|
||||
target = blk
|
||||
}
|
||||
}
|
||||
// A post-heal block reaches Quasar (all 5 up ⇒ 80%+ stake > ⅔): export RESUMES after the
|
||||
// partition healed. (Liveness; the no-conflicting-Quasar INVARIANT is proven deterministically
|
||||
// by TestNovaQuasarMatrix_Equivocation_TwoNovaNeverTwoQuasar.)
|
||||
// A post-heal block reaches Quasar (all 5 up, so more than 80% of stake, above ⅔): export
|
||||
// resumes once the partition heals. This is the liveness half; the no-conflicting-Quasar
|
||||
// invariant is proven deterministically by
|
||||
// TestNovaQuasarMatrix_Equivocation_TwoNovaNeverTwoQuasar.
|
||||
if !exported() {
|
||||
wantH := uint64(2)
|
||||
if target != nil {
|
||||
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())
|
||||
}
|
||||
// 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 {
|
||||
if !n.reachable() {
|
||||
continue
|
||||
}
|
||||
if qh, ok := n.quasarHeight(); ok && qh >= target.height {
|
||||
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
|
||||
// Nova; the rejoining node catches up; export resumes. No stall.
|
||||
// One node behind or restarting is a non-event: the majority keeps producing Nova, the
|
||||
// rejoining node catches up, and export resumes. No stall.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// TestNovaQuasarMatrix_BehindNodeRejoin_NonEvent kills one of five nodes, proves the remaining 4
|
||||
// keep PRODUCING (Nova) across several heights (the majority never stalls), then brings the node
|
||||
// back and proves it CATCHES UP to the tip AND the fleet resumes EXPORTING (Quasar) — the
|
||||
// pulling-luxd-4 scenario is a non-event. (That 4-of-5 exports during the outage is proven by
|
||||
// TestNovaQuasarMatrix_FourOfFive_ExportsQuasar; here the focus is Nova production + rejoin.)
|
||||
// TestNovaQuasarMatrix_BehindNodeRejoin_NonEvent takes one of five nodes down, proves the
|
||||
// remaining 4 keep producing Nova across several heights so the majority never stalls, then
|
||||
// brings the node back and proves it catches up to the tip while the fleet keeps exporting.
|
||||
// Taking one node out of a five-node set is a non-event by construction, and this is what that
|
||||
// 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) {
|
||||
skipMultinodeUnderRace(t)
|
||||
net := newSimNet(t, 5, prodParams5Fast())
|
||||
@@ -441,15 +442,15 @@ func TestNovaQuasarMatrix_BehindNodeRejoin_NonEvent(t *testing.T) {
|
||||
all, fork := net.finalizedEverywhere(blk)
|
||||
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()
|
||||
parentStateRoot = blk.stateRoot
|
||||
last = blk
|
||||
}
|
||||
|
||||
// REJOIN: node 4 comes back and must CATCH UP to the tip (the fetch-missing-ancestor path),
|
||||
// finalizing the block the fleet already accepted — no stall.
|
||||
// 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 — with no stall.
|
||||
net.nodes[4].setUp(true)
|
||||
// Nudge production so gossip/catch-up flows to node 4.
|
||||
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()
|
||||
}) {
|
||||
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) — "+
|
||||
"the behind-node rejoin must be a non-event", last.height, last.ID(), gotTip, gotH)
|
||||
t.Fatalf("rejoin stalled: node 4 did not catch up to height %d block %s after rejoining (its tip=%s@%d) — "+
|
||||
"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
|
||||
// return. Export RESUMPTION at ⅔ and the no-two-conflicting-Quasars INVARIANT are proven
|
||||
// (deterministically, -race-clean) by TestNovaQuasarMatrix_FourOfFive_ExportsQuasar and
|
||||
// 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
|
||||
// deterministically, and -race-clean, by TestNovaQuasarMatrix_FourOfFive_ExportsQuasar and
|
||||
// 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
|
||||
// layer (the deterministic form of the storm): two conflicting blocks A and B at one height. With
|
||||
// k equivocators, a second NOVA (bare-majority) cert for B is reachable at a LOWER k than a second
|
||||
// QUASAR (⅔) cert — Nova is crash-fault-safe only, so a second Nova cert under equivocation is
|
||||
// EXPECTED and non-exportable. THE INVARIANT: a second QUASAR cert requires k ≥ 2α−n = >⅓ stake
|
||||
// double-signing, so below that NO two conflicting export certs ever coexist.
|
||||
// layer — the deterministic form of the storm — with two conflicting blocks A and B at one
|
||||
// height. With k equivocators, a second Nova (bare-majority) cert for B is reachable at a lower k
|
||||
// than a second Quasar (⅔) cert: Nova is crash-fault-safe only, so a second Nova cert under
|
||||
// equivocation is expected, and it is not exportable. The invariant: a second Quasar cert takes
|
||||
// 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) {
|
||||
const 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
if !quasarCertOK(A.id) {
|
||||
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
|
||||
// NOVA majority (3) for B → a SECOND Nova cert exists (expected under equivocation).
|
||||
// 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, so a second Nova cert exists — expected under equivocation.
|
||||
addRaw(B.id, 1, 2, 4)
|
||||
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
|
||||
// form. Two conflicting export certs would need k ≥ 2α−n = 3 equivocators (>⅓ stake).
|
||||
// The invariant: B has only 3 distinct signers, below α=4, so no second Quasar (export) cert
|
||||
// can form. Two conflicting export certs would take k ≥ 2α−n = 3 equivocators, more than ⅓ of
|
||||
// stake.
|
||||
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
|
||||
@@ -567,7 +570,7 @@ func TestNovaQuasarMatrix_DegradedRPC(t *testing.T) {
|
||||
e.ReceiveVote(vs.signedVote(1, pos))
|
||||
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) }) {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
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))
|
||||
if !waitFor(2*time.Second, func() bool {
|
||||
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
|
||||
// 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
|
||||
// export cert only for a block whose OWN Nova cert carried >⅔ of stake; a block finalized on the
|
||||
// NovaQuorum majority — assembleCertLocked freezes at NovaQuorum(5)=3, and α does not gate cert
|
||||
// assembly — without that supermajority attesting stays Nova-only, permanently and by design:
|
||||
// "no per-block retroactive promotion is needed for liveness" precisely because a later
|
||||
// fully-voted block carries the frontier past it, and a Quasar tip finalizes all its ancestors.
|
||||
// A test that builds ONE block and demands that block export is therefore asserting a property
|
||||
// the engine never promised, and it fails on exactly the runs where the first cert froze at 3.
|
||||
// This is the liveness property the two-tier design guarantees. promoteQuasar emits an export
|
||||
// cert only for a block whose own Nova cert carried more than ⅔ of stake. A block finalized on
|
||||
// the bare NovaQuorum majority — assembleCertLocked freezes at NovaQuorum(5)=3, and α does not
|
||||
// gate cert assembly — stays Nova-only, permanently and by design: no per-block retroactive
|
||||
// promotion is needed for liveness, precisely because a later fully-voted block carries the
|
||||
// frontier past it, and a Quasar tip finalizes all of its ancestors. So demanding that one
|
||||
// particular block export, with no successor built, asks for a property the engine never
|
||||
// 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
|
||||
// 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")
|
||||
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
|
||||
// would then be reported as a pile of unconvergeable garbage instead of as a stall.
|
||||
if !waitFor(emergeTO, func() bool {
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// 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
|
||||
// ran AHEAD of, or diverged from, the consensus finality ledger: when the engine steered
|
||||
// the VM (SetPreference) onto the block it had just finalized, the EVM refused with "cannot
|
||||
// orphan finalized block at height N to common block at height N-1" (core/blockchain.go:
|
||||
// reorg guard). The engine responded with log.Crit → os.Exit(1): a healthy validator was
|
||||
// killed even though its CONSENSUS finality was correct.
|
||||
// The two components hold different heads, and only one of them is the authority. A node's
|
||||
// inner EVM accepts blocks locally, so its accepted head can run ahead of the finality
|
||||
// ledger, or sit on a sibling the ledger did not certify. When the engine then steers the VM
|
||||
// onto the block consensus just finalized, the EVM's reorg guard refuses the SetPreference
|
||||
// with "cannot orphan finalized block at height N to common block at height N-1". That
|
||||
// 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
|
||||
// the VM's diverged head against the finality LEDGER (the sole authority) and:
|
||||
// - drops an UNCERTIFIED provisional tip (above the finalized frontier, or a losing
|
||||
// sibling of a finalized block) by reconciling the VM to the certified block — no crash;
|
||||
// - HALTS fail-closed ONLY when the tip the VM would orphan is itself the consensus-
|
||||
// certified block at its height (a two-blocks-at-one-height double-finalization).
|
||||
// reconcileVMToCertified therefore classifies the VM's diverged head against the finality
|
||||
// ledger, which is the sole authority on what is final, and:
|
||||
// - 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;
|
||||
// - halts fail-closed when the tip the VM would orphan is itself the consensus-certified
|
||||
// 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
|
||||
// byHeight, so NO certified block is ever orphaned to gain liveness.
|
||||
// The safety invariant proven here: a tip is dropped only when its canonical is provably
|
||||
// absent from byHeight, so no certified block is ever orphaned to buy liveness.
|
||||
package chain
|
||||
|
||||
import (
|
||||
@@ -34,8 +37,8 @@ import (
|
||||
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
|
||||
// and whose LastAccepted returns a caller-controlled diverged head. It does NOT implement
|
||||
// PreferenceReconciler (the no-live-reconcile case).
|
||||
// and whose LastAccepted returns a caller-controlled diverged head. It does not implement
|
||||
// PreferenceReconciler — the case where no live reconcile is available.
|
||||
type orphanVMBase struct {
|
||||
head ids.ID
|
||||
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 --------------------------------
|
||||
|
||||
// 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
|
||||
// there). It is uncertified ⇒ safe to drop ⇒ the VM is reconciled to the certified block and
|
||||
// the node does NOT halt.
|
||||
// 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
|
||||
// there. Uncertified, so safe to drop: the VM is reconciled to the certified block and the
|
||||
// node does not halt.
|
||||
func TestReconcile_UncertifiedSibling_Reconciles(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
|
||||
@@ -91,7 +94,7 @@ func TestReconcile_UncertifiedSibling_Reconciles(t *testing.T) {
|
||||
certifiedCanon := certifiedOuter
|
||||
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{
|
||||
head: sibling,
|
||||
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)
|
||||
}
|
||||
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
|
||||
// frontier — the node built + accepted ahead of the quorum (the exact task scenario). Above
|
||||
// the frontier there is no byHeight entry ⇒ uncertified ⇒ safe ⇒ reconcile, no halt.
|
||||
// TestReconcile_BuildAheadAboveFrontier_Reconciles: the VM's head is above the finalized
|
||||
// frontier, because the node built and accepted ahead of the quorum. Above the frontier there
|
||||
// is no byHeight entry, so the head is uncertified and safe to drop: reconcile, no halt.
|
||||
func TestReconcile_BuildAheadAboveFrontier_Reconciles(t *testing.T) {
|
||||
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
|
||||
// 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
|
||||
// refuse to reconcile (return false ⇒ caller halts fail-closed) and MUST NOT orphan the
|
||||
// certified block.
|
||||
// TestReconcile_CertifiedHead_HaltsFailClosed is the safety assertion. The VM's head is itself
|
||||
// the consensus-certified block at its height, and the newly finalized block is a different
|
||||
// canonical there — two blocks certified at one height. The engine has to refuse to reconcile
|
||||
// (return false, so the caller halts fail-closed) rather than orphan the certified block.
|
||||
func TestReconcile_CertifiedHead_HaltsFailClosed(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
|
||||
head := ids.GenerateTestID() // the VM's accepted head at height 7
|
||||
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
|
||||
// canonical, not certifiedOther's) and not by an unreadable id — this is the genuine
|
||||
// two-blocks-at-one-height shape, not an artefact of the mock.
|
||||
// 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. That keeps this the genuine
|
||||
// two-blocks-at-one-height shape rather than an artefact of the mock.
|
||||
vm := &reconcilingOrphanVM{orphanVMBase: &orphanVMBase{
|
||||
head: head,
|
||||
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)
|
||||
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 {
|
||||
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
|
||||
// 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) {
|
||||
e := newTestEngine()
|
||||
certified := ids.GenerateTestID()
|
||||
@@ -180,21 +183,21 @@ func TestReconcile_HeadEqualsCertified_NoOp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcile_NoLiveReconcile_DefersNonFatal: a VM WITHOUT the optional PreferenceReconciler
|
||||
// and an uncertified diverged head. The engine must NOT crash: it keeps its correct consensus
|
||||
// finality and defers the VM head reconcile to offline recovery (handled=true, no reconcile).
|
||||
// TestReconcile_NoLiveReconcile_DefersNonFatal: a VM without the optional PreferenceReconciler
|
||||
// and an uncertified diverged head. The engine keeps its correct consensus finality and defers
|
||||
// the VM head reconcile to offline recovery (handled=true, no reconcile) rather than crashing.
|
||||
func TestReconcile_NoLiveReconcile_DefersNonFatal(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
certified := ids.GenerateTestID()
|
||||
e.consensus.ledger = seedLedger(certified, certified, 7)
|
||||
|
||||
sibling := ids.GenerateTestID()
|
||||
vm := &orphanVMBase{ // does NOT implement PreferenceReconciler
|
||||
vm := &orphanVMBase{ // deliberately does not implement PreferenceReconciler
|
||||
head: sibling,
|
||||
blocks: map[ids.ID]*mockBlock{sibling: mb(sibling, 7)},
|
||||
}
|
||||
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) {
|
||||
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
|
||||
// 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) {
|
||||
e := newTestEngine()
|
||||
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)")
|
||||
}
|
||||
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 -------------------------------------------
|
||||
|
||||
// TestAcceptWithCert_OrphanRefusal_ReconcilesInsteadOfHalting drives the FULL finalize path
|
||||
// (acceptWithCertCore → ApplyCert → applyBranchFinalization(VM.Accept) → SetPreference) with
|
||||
// a VM that refuses SetPreference with the orphan error and whose accepted head diverged from
|
||||
// the just-finalized block. Pre-fix this path hit log.Crit → os.Exit(1); the test asserts the
|
||||
// engine now finalizes cleanly (returns nil) AND reconciled the VM — the node did not halt.
|
||||
// TestAcceptWithCert_OrphanRefusal_ReconcilesInsteadOfHalting drives the whole finalize path
|
||||
// (acceptWithCertCore → ApplyCert → applyBranchFinalization(VM.Accept) → SetPreference) with a
|
||||
// VM that refuses SetPreference with the orphan error and whose accepted head diverged from the
|
||||
// just-finalized block. The classification above is only worth anything if it is reached from
|
||||
// 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) {
|
||||
e := newTestEngine()
|
||||
ctx := context.Background()
|
||||
@@ -238,9 +243,9 @@ func TestAcceptWithCert_OrphanRefusal_ReconcilesInsteadOfHalting(t *testing.T) {
|
||||
t.Fatalf("AddBlock: %v", err)
|
||||
}
|
||||
|
||||
// A DIVERGED VM head: a different height-1 block the VM accepted ahead/locally. SetPreference
|
||||
// to finalID would orphan it, so the VM refuses; the head is uncertified (byHeight[1] will be
|
||||
// finalID's canonical), so the engine must reconcile — not crash.
|
||||
// A diverged VM head: a different height-1 block the VM accepted locally. SetPreference to
|
||||
// finalID would orphan it, so the VM refuses; the head is uncertified, since byHeight[1] will
|
||||
// be finalID's canonical, so the engine reconciles rather than crashing.
|
||||
sibling := ids.GenerateTestID()
|
||||
vm := &reconcilingOrphanVM{orphanVMBase: &orphanVMBase{
|
||||
head: sibling,
|
||||
@@ -262,12 +267,13 @@ func TestAcceptWithCert_OrphanRefusal_ReconcilesInsteadOfHalting(t *testing.T) {
|
||||
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 {
|
||||
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 {
|
||||
t.Fatalf("expected the engine to reconcile the VM exactly once, got %d", vm.reconcileCalls)
|
||||
}
|
||||
|
||||
@@ -10,32 +10,28 @@ import (
|
||||
"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
|
||||
// only sound source for it.
|
||||
//
|
||||
// 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
|
||||
//
|
||||
// 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
|
||||
// halts, where a valid block was built, verified, and gossiped but was unsignable, so no
|
||||
// certificate could ever assemble and the chain stopped with zero errors logged.
|
||||
// the validator at a height the fleet never agreed on: the honest rebuild at that height can be
|
||||
// built, verified and gossiped and still be unsignable, so no certificate ever assembles and the
|
||||
// chain stops without anything failing.
|
||||
//
|
||||
// Nova is NOT gated away: it keeps the ledger, VM.Accept, preference, catch-up, and rejoin.
|
||||
// An earlier attempt to refuse Nova certs at intake broke three heal/rejoin tests, because Nova
|
||||
// certs ARE the recovery transport. The cut is narrower and exact: Nova may move everything
|
||||
// except the permanent floor.
|
||||
// Nova is not gated away: it keeps the ledger, VM.Accept, preference, catch-up, and rejoin. Nova
|
||||
// certs are the recovery transport, so refusing them at intake would take heal and rejoin with
|
||||
// them. The cut is narrower and exact: Nova may move everything except the permanent floor.
|
||||
|
||||
// TestQuasarFloor_NovaAcceptDoesNotCloseHeights is the core regression. A Nova majority accept
|
||||
// must advance the ledger and leave BOTH the export frontier and the durable guard floor
|
||||
// untouched. The deleted line in acceptWithCertCore was
|
||||
//
|
||||
// if highestAccepted > 0 { t.pruneCommittedSlotsBelow(highestAccepted) }
|
||||
//
|
||||
// which fsync'd a permanent refusal at a bare-majority height.
|
||||
// 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
|
||||
// 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.
|
||||
func TestQuasarFloor_NovaAcceptDoesNotCloseHeights(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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.
|
||||
if got := store.FinalizedThrough(); got != 0 {
|
||||
t.Fatalf("PERMANENT WELD: 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 "+
|
||||
"cleared — mainnet-1098192 / testnet-11367.", got)
|
||||
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 cleared", got)
|
||||
}
|
||||
// Therefore the height stays signable: a restarted node can still sign the honest rebuild.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// TestQuasarFloor_PromotionAdvancesFloorAtomically proves the other half: a ⅔-by-stake cert does
|
||||
// close the height, and the guard floor and binding compaction land in one fsync'd write.
|
||||
func TestQuasarFloor_PromotionAdvancesFloorAtomically(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
path := filepath.Join(t.TempDir(), "vote-guard")
|
||||
@@ -100,7 +96,7 @@ func TestQuasarFloor_PromotionAdvancesFloorAtomically(t *testing.T) {
|
||||
}
|
||||
|
||||
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.
|
||||
e.slotMu.Lock()
|
||||
@@ -113,10 +109,10 @@ func TestQuasarFloor_PromotionAdvancesFloorAtomically(t *testing.T) {
|
||||
Q, has298, has299)
|
||||
}
|
||||
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)
|
||||
}
|
||||
// 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.
|
||||
re, err := OpenVoteGuard(path)
|
||||
if err != nil {
|
||||
@@ -137,8 +133,8 @@ func TestQuasarFloor_PromotionAdvancesFloorAtomically(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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
|
||||
// carry) and the caller must NOT publish the export frontier — otherwise a crash leaves the VM's
|
||||
// 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
|
||||
// durable LastQuasarHeight naming a height whose compaction never landed, and the two durable
|
||||
// records disagree about what is closed.
|
||||
func TestQuasarFloor_GuardWriteFailureWithholdsExport(t *testing.T) {
|
||||
@@ -148,20 +144,20 @@ func TestQuasarFloor_GuardWriteFailureWithholdsExport(t *testing.T) {
|
||||
const Q = uint64(77)
|
||||
err := e.compactVoteGuardThroughQuasar(Q)
|
||||
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)")
|
||||
}
|
||||
e.slotMu.Lock()
|
||||
floor := e.decidedFloor
|
||||
e.slotMu.Unlock()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// import must advance the Nova build anchor and leave the signing floor alone.
|
||||
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))
|
||||
|
||||
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 {
|
||||
t.Fatalf("SyncState(import): %v", err)
|
||||
}
|
||||
@@ -181,29 +177,28 @@ func TestQuasarFloor_ImportIsRecoveryHintNotDecision(t *testing.T) {
|
||||
if got := e.consensus.GetNovaAcceptedFloor(); got != imported {
|
||||
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 {
|
||||
t.Fatalf("import must not advance the quasar signing floor, got %d", got)
|
||||
}
|
||||
if got := store.FinalizedThrough(); got != 0 {
|
||||
t.Fatalf("IMPORT WELD: an RLP import fsync'd a sign-refusal floor of %d. The operator supplied "+
|
||||
"block data, not a certificate — and an import happens exactly when the fleet is below quorum.", got)
|
||||
t.Fatalf("an import fsync'd a sign-refusal floor of %d. The operator supplied block data, not a "+
|
||||
"certificate — and an import happens exactly when the fleet is below quorum", got)
|
||||
}
|
||||
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
|
||||
// miniature, and the property the owner actually asked for. A node runs ahead on Nova, restarts,
|
||||
// and must come back with its floor at the CERTIFIED height — so every height the fleet has not
|
||||
// agreed on is still available for it to re-sign.
|
||||
// TestQuasarFloor_RestartKeepsFloorAtQuasarNotNova is the restart-under-upgrade property. A node
|
||||
// runs ahead on Nova, restarts, and must come back with its floor at the certified height — so
|
||||
// every height the fleet has not agreed on is still available for it to re-sign.
|
||||
func TestQuasarFloor_RestartKeepsFloorAtQuasarNotNova(t *testing.T) {
|
||||
vs := newTestValidatorSet(5)
|
||||
path := filepath.Join(t.TempDir(), "vote-guard")
|
||||
|
||||
const Q = uint64(11363) // last ⅔-certified height (the real testnet value)
|
||||
const novaHead = uint64(11367)
|
||||
const Q = uint64(11363) // last ⅔-certified height
|
||||
const novaHead = uint64(11367) // four heights of bare-majority run-ahead above it
|
||||
|
||||
store1, err := OpenVoteGuard(path)
|
||||
if err != nil {
|
||||
@@ -215,8 +210,8 @@ func TestQuasarFloor_RestartKeepsFloorAtQuasarNotNova(t *testing.T) {
|
||||
if err := e1.compactVoteGuardThroughQuasar(Q); err != nil {
|
||||
t.Fatalf("compact(Q): %v", err)
|
||||
}
|
||||
// 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.
|
||||
// Then this node runs ahead alone on bare Nova majorities through novaHead, binding a vote
|
||||
// at each height while no cert forms.
|
||||
for h := Q + 1; h <= novaHead; h++ {
|
||||
if !e1.reserveSlotForSign(h, ids.GenerateTestID()) {
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("OpenVoteGuard(store2): %v", err)
|
||||
}
|
||||
if got := store2.FinalizedThrough(); got != Q {
|
||||
t.Fatalf("ROLLING-UPGRADE BLOCKER: the durable floor came back at %d, but only %d was ⅔-certified. "+
|
||||
"A restarted node must never carry a refusal above the height its peers agree on.", got, Q)
|
||||
t.Fatalf("the durable floor came back at %d, but only %d was ⅔-certified. A restarted node must "+
|
||||
"not carry a refusal above the height its peers agree on", got, Q)
|
||||
}
|
||||
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()) {
|
||||
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
|
||||
// INDIVIDUALLY by its retained binding. That is the whole decomplection: one signature per
|
||||
// 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
|
||||
// height, enforced by memory of the vote, not by a blunt permanent floor.
|
||||
for h := Q + 1; h <= novaHead; 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if !e2.reserveSlotForSign(h, bound) {
|
||||
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)
|
||||
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 form", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,11 +40,15 @@
|
||||
// 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
|
||||
// at h was purely INTERNAL to the consensus layer.
|
||||
// P3. The cert material for h (the α-of-K precommit signatures and the assembled cert)
|
||||
// lived ONLY in memory (finalizedByCert, the served-cert window, the roundView
|
||||
// precommit sets) — all cleared on the restart this reconcile runs within. The durable
|
||||
// vote-guard persists NO signatures. So after the restart NO node holds or can
|
||||
// reconstruct a presentable cert for h.
|
||||
// P3. No node holds a presentable cert for h. The in-memory homes (finalizedByCert, the
|
||||
// served-cert window, the roundView precommit sets) clear on the restart this reconcile
|
||||
// runs within, and the durable vote-guard persists bindings and a floor, never
|
||||
// signatures. The durable cert store is bounded by the SAME condition as P1:
|
||||
// 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
|
||||
// h. Re-finalizing a fresh block at h yields exactly ONE presentable cert there. No
|
||||
// double-cert is observable. ∎
|
||||
|
||||
+51
-53
@@ -11,14 +11,14 @@ import (
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// TestSyncState_RefusesBackwardRegression is the regression test for the
|
||||
// SyncState monotonic-height guard. SyncState bypasses markFinalizedLocked by
|
||||
// design (an import is an out-of-band reconcile, not an α-of-K finalize), so the
|
||||
// monotonic invariant must be re-asserted there explicitly: a backward import
|
||||
// (height below the already-finalized height) MUST be refused and MUST leave the
|
||||
// finalized head untouched. Without the guard a shorter/older imported chain
|
||||
// would silently regress finalizedHeight and un-finalize already-finalized
|
||||
// blocks — re-opening the fork window the per-height guard closes.
|
||||
// TestSyncState_RefusesBackwardRegression pins the SyncState monotonic-height
|
||||
// rule. SyncState bypasses markFinalizedLocked by design (an import is an
|
||||
// out-of-band reconcile, not an α-of-K finalize), so the monotonic invariant must
|
||||
// be re-asserted there explicitly: a backward import (height below the
|
||||
// already-finalized height) is refused and leaves the finalized head untouched.
|
||||
// Otherwise a shorter or older imported chain silently regresses finalizedHeight
|
||||
// and un-finalizes finalized blocks, re-opening the fork window the per-height
|
||||
// guard closes.
|
||||
func TestSyncState_RefusesBackwardRegression(t *testing.T) {
|
||||
c := NewChainConsensus(4, 3, 2)
|
||||
|
||||
@@ -29,12 +29,12 @@ func TestSyncState_RefusesBackwardRegression(t *testing.T) {
|
||||
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()
|
||||
if err := c.SyncState(older, 99); !errors.Is(err, ErrSyncStateRegression) {
|
||||
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 {
|
||||
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
|
||||
// regression at the SyncState level. The PRE-FIX code SEEDED FINALITY from
|
||||
// vm.LastAccepted, so a re-import at the already-finalized height with a DIFFERENT
|
||||
// block was treated as equivocation (the fatal path). That was the BUG: a local
|
||||
// import is not a finality source. Under the fix, SyncState is a NON-AUTHORITATIVE
|
||||
// recovery HINT — a different block at a certified height does NOT equivocate, does
|
||||
// NOT touch the certified frontier, and returns cleanly. Equivocation is decided
|
||||
// EXCLUSIVELY by conflicting CERTS over canonical commitments, never by a seed.
|
||||
// TestSyncState_DifferentBlockIsHintNotEquivocation pins what an import may and may
|
||||
// not conclude. Seeding finality from vm.LastAccepted makes a re-import at an
|
||||
// already-finalized height with a different block look like equivocation, which is
|
||||
// fatal — but a local import is not a finality source, so it cannot witness a
|
||||
// conflict. SyncState is a non-authoritative recovery hint: a different block at a
|
||||
// certified height does not equivocate, does not touch the certified frontier, and
|
||||
// returns cleanly. Equivocation is decided exclusively by conflicting certs over
|
||||
// canonical commitments.
|
||||
func TestSyncState_DifferentBlockIsHintNotEquivocation(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
|
||||
// DIFFERENT block at the same certified height → a HINT, NOT equivocation. The
|
||||
// pre-fix code returned ErrHeightAlreadyFinalized here (the bug). The fix accepts
|
||||
// it as a non-authoritative hint and leaves certified finality untouched.
|
||||
// A different block at the same certified height is a hint, not equivocation:
|
||||
// accepted as non-authoritative, certified finality untouched, no
|
||||
// ErrHeightAlreadyFinalized.
|
||||
other := ids.GenerateTestID()
|
||||
if err := c.SyncState(other, 100); err != nil {
|
||||
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).
|
||||
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)
|
||||
@@ -87,12 +87,12 @@ func TestSyncState_DifferentBlockIsHintNotEquivocation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncState_EmptyResetYieldsCleanGenesis is the LOW-2 regression: an empty import
|
||||
// head (ids.Empty, height 0) on a ledger ALREADY finalized above genesis resets finality
|
||||
// to a CLEAN genesis VALUE — tip Empty, height 0, unset — never the prior half-reset that
|
||||
// nulled the tip while leaving height/byHeight stale (a tip-vs-height desync that wedged
|
||||
// every future cert in pathFromTip). The proof the desync is gone: a fresh cert finalizes
|
||||
// cleanly afterward instead of wedging.
|
||||
// TestSyncState_EmptyResetYieldsCleanGenesis pins the reset as a whole value: an empty
|
||||
// import head (ids.Empty, height 0) on a ledger already finalized above genesis resets
|
||||
// finality to a clean genesis — tip Empty, height 0, unset. A half reset that nulls the
|
||||
// tip while leaving height and byHeight stale desyncs tip from height, and pathFromTip
|
||||
// then seeks a tip that is not there, wedging every future cert. So the test finalizes a
|
||||
// fresh cert after the reset: a usable ledger is the observable proof of a whole reset.
|
||||
func TestSyncState_EmptyResetYieldsCleanGenesis(t *testing.T) {
|
||||
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.
|
||||
next := ids.GenerateTestID()
|
||||
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 {
|
||||
t.Fatalf("post-reset finalize did not advance: got %s want %s", got, next)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncState_ForwardImportAdvancesBuildAnchorNotFinality verifies the corrected
|
||||
// (incident-1082814 PART-A) semantics of a forward import: it advances the BUILD
|
||||
// ANCHOR (where the VM builds — GetFinalizedTip) so the node builds on the imported
|
||||
// head, but it does NOT advance CERTIFIED finality. vm.LastAccepted / import is a
|
||||
// recovery HINT, never a finality source: the certified height stays at the last
|
||||
// QC-backed height and byHeight gains NO entry at the imported height (only a cert
|
||||
// can write there). Finality at the imported height is established later via the
|
||||
// bootstrap/cert path, not by the import claiming it.
|
||||
// TestSyncState_ForwardImportAdvancesBuildAnchorNotFinality splits the two things a
|
||||
// forward import moves. It advances the build anchor (where the VM builds —
|
||||
// GetFinalizedTip) so the node builds on the imported head, and it does not advance
|
||||
// certified finality: vm.LastAccepted is a recovery hint, so the certified height
|
||||
// stays at the last QC-backed height and byHeight gains no entry at the imported
|
||||
// height, which only a cert may write. Finality at that height arrives later through
|
||||
// the bootstrap/cert path.
|
||||
func TestSyncState_ForwardImportAdvancesBuildAnchorNotFinality(t *testing.T) {
|
||||
c := NewChainConsensus(4, 3, 2)
|
||||
|
||||
@@ -144,24 +143,24 @@ func TestSyncState_ForwardImportAdvancesBuildAnchorNotFinality(t *testing.T) {
|
||||
if err := c.SyncState(h5000, 5000); err != nil {
|
||||
t.Fatalf("forward SyncState(h=5000) should succeed, got: %v", err)
|
||||
}
|
||||
// BUILD ANCHOR advances to the imported head (the forward hint outranks the lower
|
||||
// certified tip) so the VM builds where the node has state.
|
||||
// The build anchor advances to the imported head (the forward hint outranks the
|
||||
// lower certified tip) so the VM builds where the node has state.
|
||||
if got := c.GetFinalizedTip(); 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).
|
||||
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).
|
||||
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
|
||||
// (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.
|
||||
func TestSyncState_EmptyResetAllowed(t *testing.T) {
|
||||
c := NewChainConsensus(4, 3, 2)
|
||||
@@ -179,14 +178,13 @@ func TestSyncState_EmptyResetAllowed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSyncState_EmptyHeadWithPositiveHeightRefused is the INFO-6 regression. An
|
||||
// EMPTY import head paired with a POSITIVE height is contradictory (an empty head
|
||||
// is the genesis/teardown reset, valid only at height 0). If allowed it would set
|
||||
// finalizedTip=Empty while the seed branch is skipped (finalizedHeight stays
|
||||
// stale) AND prune blocks below the positive height — the exact
|
||||
// finalizedTip-vs-finalizedHeight desync ForcePreference was hardened against.
|
||||
// SyncState must REFUSE it fail-closed, leaving finalized state AND the block pool
|
||||
// untouched (no pruning).
|
||||
// TestSyncState_EmptyHeadWithPositiveHeightRefused pins the one import shape that
|
||||
// contradicts itself. An empty head is the genesis/teardown reset and is meaningful
|
||||
// only at height 0; paired with a positive height it would set finalizedTip=Empty
|
||||
// while skipping the seed branch, so finalizedHeight stays stale, and it would prune
|
||||
// every block below that height — the same finalizedTip-vs-finalizedHeight desync
|
||||
// ForcePreference is hardened against. SyncState refuses it fail-closed, leaving
|
||||
// finalized state and the block pool untouched.
|
||||
func TestSyncState_EmptyHeadWithPositiveHeightRefused(t *testing.T) {
|
||||
c := NewChainConsensus(4, 3, 2)
|
||||
|
||||
@@ -196,7 +194,7 @@ func TestSyncState_EmptyHeadWithPositiveHeightRefused(t *testing.T) {
|
||||
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
|
||||
// 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}
|
||||
if err := c.AddBlock(context.Background(), low); err != nil {
|
||||
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 {
|
||||
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 {
|
||||
t.Fatal("refused empty-at-height import pruned a live block below the height (the desync the guard prevents)")
|
||||
}
|
||||
|
||||
+120
-125
@@ -1,32 +1,32 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// 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
|
||||
// tree (blocks/tips), the build preference, and the vote/poll surface that drives
|
||||
// each block's confidence counter. It is the half of the preference tree that
|
||||
// stays MUTABLE — siblings coexist, votes accumulate, preference moves. The OTHER
|
||||
// half of the preference tree — the committed-prefix advance (lastAcceptedID /
|
||||
// acceptPreferredChild / rejectTransitively, β-driven there) — is decomplected out:
|
||||
// in Lux it is CERT-driven and lives as the pure fold in ledger.go, applied by the
|
||||
// shell in consensus.go. Same tree shape; finality trigger swapped from β to cert.
|
||||
// This is the topological half of a linear-chain engine: the live block tree
|
||||
// (blocks/tips), the build preference, and the vote/poll surface that drives each
|
||||
// block's confidence counter. It is the half that stays mutable — siblings coexist,
|
||||
// votes accumulate, preference moves. The other half — the committed-prefix advance
|
||||
// (lastAcceptedID / acceptPreferredChild / rejectTransitively, β-driven in a
|
||||
// classical engine) — is decomplected out: here it is cert-driven and lives as the
|
||||
// pure fold in ledger.go, applied by the shell in consensus.go. Same tree shape;
|
||||
// finality trigger swapped from β to cert.
|
||||
//
|
||||
// Method roles:
|
||||
//
|
||||
// Block / *Block -> the upstream tree-node block
|
||||
// AddBlock -> Add (admit a child of a known block)
|
||||
// ProcessVote / Poll -> RecordPoll (accumulate votes into the per-block
|
||||
// confidence driver; LIVENESS only — finality is the cert)
|
||||
// IsAccepted / IsRejected -> block status (Decided)
|
||||
// GetBlock -> blocks[id] lookup (Processing)
|
||||
// Preference / ForcePreference -> Preference / preferredIDs (the build tail)
|
||||
// EpochHeightOf -> (Lux-only) the block's pinned P-chain epoch height
|
||||
// ancestry / blocksAncestry -> GetParent + children, exposed as the read-only
|
||||
// Block / *Block -> the tree-node block
|
||||
// AddBlock -> admit a child of a known block
|
||||
// ProcessVote / Poll -> accumulate votes into the per-block confidence
|
||||
// driver; liveness only — finality is the cert
|
||||
// IsAccepted / IsRejected -> block status (decided)
|
||||
// GetBlock -> blocks[id] lookup (processing)
|
||||
// Preference / ForcePreference -> the build tail
|
||||
// EpochHeightOf -> the block's pinned P-chain epoch height
|
||||
// ancestry / blocksAncestry -> parent + children, exposed as the read-only
|
||||
// Ancestry the pure Finalize fold reads
|
||||
//
|
||||
// The Photon -> Wave -> Focus per-block driver (engine.Driver) is the
|
||||
// per-node confidence instance — orthogonal to the tree and kept AS-IS.
|
||||
// The Photon -> Wave -> Focus per-block driver (engine.Driver) is the per-node
|
||||
// confidence instance — orthogonal to the tree and untouched by it.
|
||||
package chain
|
||||
|
||||
import (
|
||||
@@ -37,9 +37,9 @@ import (
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// Block represents a block in the chain — the preference tree's node (the upstream tree
|
||||
// node). Tracks the value-chain linkage (id/parent/height), the pinned
|
||||
// P-chain epoch, the per-block Photon->Wave->Focus driver, and the decided flags.
|
||||
// Block represents a block in the chain — the preference tree's node. Tracks the
|
||||
// value-chain linkage (id/parent/height), the pinned P-chain epoch, the per-block
|
||||
// Photon->Wave->Focus driver, and the decided flags.
|
||||
type Block struct {
|
||||
id ids.ID
|
||||
parentID ids.ID
|
||||
@@ -48,38 +48,37 @@ type Block struct {
|
||||
data []byte
|
||||
|
||||
// canonicalID / parentCanonicalID / execStateRoot / payloadRoot are the inner
|
||||
// EXECUTION commitment (the incident-1082814 canonical identity). For a
|
||||
// proposervm-wrapped block these are the inner block's identity; for a bare block
|
||||
// canonicalID == id (graceful degrade). canonicalID is what finality, the
|
||||
// per-height equivocation index, and the cert position all key on; the outer
|
||||
// id/parentID stay the transport/DAG keys. ids.Empty roots mean "not exposed".
|
||||
// execution commitment — the block's canonical identity. For a proposervm-wrapped
|
||||
// block these are the inner block's identity; for a bare block canonicalID == id
|
||||
// (graceful degrade). canonicalID is what finality, the per-height equivocation
|
||||
// index, and the cert position all key on; the outer id/parentID stay the
|
||||
// transport/DAG keys. ids.Empty roots mean "not exposed".
|
||||
canonicalID ids.ID
|
||||
parentCanonicalID ids.ID
|
||||
execStateRoot ids.ID
|
||||
payloadRoot ids.ID
|
||||
|
||||
// pChainHeight is the P-CHAIN epoch height the block's weighted validator set
|
||||
// is pinned to (MEDIUM-1 / CRITICAL-1): the set-root commitment, the ⅔-by-stake
|
||||
// tally, AND the per-voter pubkey resolution are ALL read from the height-
|
||||
// indexed validators.State at THIS height — never the value-chain `height`.
|
||||
// They differ fundamentally: platformvm.GetValidatorSet interprets its argument
|
||||
// as a P-CHAIN height and returns errUnfinalizedHeight when the current P-chain
|
||||
// height < the argument, and the value-chain height races far ahead of the
|
||||
// P-chain height on a busy chain — feeding `height` there yields an empty set
|
||||
// and stalls finality FOREVER (the mainnet-bricking bug this fixes).
|
||||
// pChainHeight is the P-chain epoch height the block's weighted validator set is
|
||||
// pinned to: the set-root commitment, the ⅔-by-stake tally, and the per-voter
|
||||
// pubkey resolution all read the height-indexed validators.State at this height,
|
||||
// never the value-chain `height`. The two are different quantities.
|
||||
// platformvm.GetValidatorSet interprets its argument as a P-chain height and
|
||||
// returns errUnfinalizedHeight once that argument exceeds the current P-chain
|
||||
// height, and the value-chain height races ahead of the P-chain height on a busy
|
||||
// chain. Passing `height` there therefore resolves an empty validator set, and no
|
||||
// tally over an empty set can ever reach ⅔, so finality stops for good.
|
||||
//
|
||||
// Source: a proposervm signed block carries its PChainHeight
|
||||
// (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
|
||||
// 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
|
||||
// agrees on genesis), and ≤ the current P-chain height, so finality is LIVE and
|
||||
// consistent — and EXACT for any chain whose validator set is unchanged since
|
||||
// genesis. The refinement that pins post-genesis STAKING-CHANGE epochs requires
|
||||
// the proposervm to deliver its PChainHeight to the engine's block (the
|
||||
// remaining (b2) wiring); the mechanism here is proven by
|
||||
// TestPChainEpochFinality_RealWiring, which feeds a block that DOES carry it.
|
||||
// genesis validator set. That is non-empty, identical on every node (everyone
|
||||
// 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
|
||||
// genesis. Pinning post-genesis staking-change epochs instead needs the proposervm
|
||||
// to deliver its PChainHeight to the engine's block; the mechanism here is covered
|
||||
// by TestPChainEpochFinality_RealWiring, which feeds a block that carries one.
|
||||
pChainHeight uint64
|
||||
|
||||
// Consensus state - Photon -> Wave -> Focus finality
|
||||
@@ -87,31 +86,30 @@ type Block struct {
|
||||
accepted bool
|
||||
rejected bool
|
||||
|
||||
// acceptVoters / rejectVoters ARE the tally. α means "α DISTINCT validators
|
||||
// agreed"; a count that any single voter can advance by repeating itself does
|
||||
// not mean that. ProcessVote used to take only (blockID, accept) — the voter
|
||||
// was not a parameter, so this layer could not tell one validator answering
|
||||
// four times from four validators answering once, and at α=4-of-5 one node
|
||||
// finalized a block alone. Polls ARE re-solicited and peers DO answer twice,
|
||||
// so it needed no malice; a peer that wanted it could just resend.
|
||||
// acceptVoters / rejectVoters are the tally. α means "α distinct validators
|
||||
// agreed"; a plain counter any single voter can advance by repeating itself
|
||||
// does not mean that — without the voter's identity this layer cannot tell one
|
||||
// validator answering four times from four validators answering once, and one
|
||||
// node alone carries a block past α. That takes no malice: polls are
|
||||
// re-solicited and peers do answer twice.
|
||||
//
|
||||
// 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.
|
||||
acceptVoters 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) }
|
||||
|
||||
// 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) }
|
||||
|
||||
// AddBlock admits a block into the preference tree. It
|
||||
// is tracking-only and PERMISSIVE: any child is admitted, siblings coexist, and the
|
||||
// new block becomes the sole build tip of its parent. Unknown-parent / fetch safety
|
||||
// is enforced at FINALIZE (the fold's ErrAncestorNotTracked), not here — tracking is
|
||||
// decomplected from finality.
|
||||
// AddBlock admits a block into the preference tree. It is tracking-only and
|
||||
// permissive: any child is admitted, siblings coexist, and the new block becomes the
|
||||
// sole build tip of its parent. Unknown-parent and fetch safety are enforced at
|
||||
// finalize (the fold's ErrAncestorNotTracked), not here — tracking is decomplected
|
||||
// from finality.
|
||||
func (c *ChainConsensus) AddBlock(ctx context.Context, block *Block) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
@@ -137,10 +135,10 @@ func (c *ChainConsensus) AddBlock(ctx context.Context, block *Block) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessVote records one vote into a block's Photon->Wave->Focus driver
|
||||
// (per block). Reaching the α accept count sets the LIVENESS
|
||||
// flag block.accepted — the engine's DrainAccepted trigger — but NEVER advances the
|
||||
// committed ledger. Finality is the cert fold's job alone.
|
||||
// ProcessVote records one vote into a block's Photon->Wave->Focus driver (per
|
||||
// block). Reaching the α accept count sets the liveness flag block.accepted — the
|
||||
// engine's DrainAccepted trigger — and never advances the committed ledger. Finality
|
||||
// is the cert fold's job alone.
|
||||
func (c *ChainConsensus) ProcessVote(ctx context.Context, blockID ids.ID, voter ids.NodeID, accept bool) error {
|
||||
c.mu.Lock()
|
||||
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")
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
if accept {
|
||||
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{}{}
|
||||
|
||||
// CONFIDENCE ADVANCES PER SUCCESSFUL POLL, NEVER PER VOTE.
|
||||
// β is the number of consecutive polls that EACH reached α — that is what
|
||||
// makes a decision worth βα agreements. Calling RecordVote on every arriving
|
||||
// vote collapses that to β agreements total: with K=5, α=4, β=2 a block
|
||||
// reached the driver's decision after THREE distinct accepts and VM.Accept
|
||||
// ran while the α=4 quorum had never been met. Two of five validators could
|
||||
// decide a block — far below the BFT threshold the parameters promise.
|
||||
// Confidence advances per successful poll, not per vote. β is the number of
|
||||
// consecutive polls that each reached α — that is what makes a decision worth
|
||||
// βα agreements. Recording a poll on every arriving vote collapses that to β
|
||||
// agreements total: at K=5, α=4, β=2 the driver decides after three distinct
|
||||
// accepts, so VM.Accept runs on a block the α=4 quorum never carried and two
|
||||
// of five validators settle it, far under the threshold the parameters name.
|
||||
//
|
||||
// Gating on the α predicate restores the floor: no confidence accrues, and
|
||||
// so nothing can be decided, until α DISTINCT validators have accepted.
|
||||
// Testing the α predicate first restores the floor: no confidence accrues,
|
||||
// and so nothing can be decided, until α distinct validators have accepted.
|
||||
if len(block.acceptVoters) >= c.alpha {
|
||||
block.driver.RecordVote(blockID)
|
||||
}
|
||||
@@ -191,10 +188,10 @@ func (c *ChainConsensus) ProcessVote(ctx context.Context, blockID ids.ID, voter
|
||||
block.rejectVoters[voter] = struct{}{}
|
||||
}
|
||||
|
||||
// LIVENESS only (decomplected from finality). Reaching the α accept count marks
|
||||
// the block worth a finalize ATTEMPT — block.accepted is the engine's
|
||||
// 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),
|
||||
// Liveness only (decomplected from finality). Reaching the α accept count marks
|
||||
// the block worth a finalize attempt — block.accepted is the engine's
|
||||
// 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),
|
||||
// which also performs the sibling reorg. A sibling reaching α-count here is
|
||||
// harmless: the cert decides which branch finalizes, and the loser is pruned.
|
||||
if block.acceptVotes() >= c.alpha && !block.accepted {
|
||||
@@ -211,9 +208,9 @@ func (c *ChainConsensus) ProcessVote(ctx context.Context, blockID ids.ID, voter
|
||||
return nil
|
||||
}
|
||||
|
||||
// Poll conducts a consensus poll over a batch of vote responses (
|
||||
// RecordPoll). It drives each block's Wave->Focus driver; convergence + the α accept
|
||||
// count sets the LIVENESS flag only. Finality (and the reorg) is the cert path.
|
||||
// Poll conducts a consensus poll over a batch of vote responses. It drives each
|
||||
// block's Wave->Focus driver; convergence plus the α accept count sets the liveness
|
||||
// flag only. Finality (and the reorg) is the cert path.
|
||||
func (c *ChainConsensus) Poll(ctx context.Context, responses map[ids.ID]int) error {
|
||||
c.mu.Lock()
|
||||
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)
|
||||
decided := block.driver.Decided()
|
||||
|
||||
// Focus convergence + α accept count → LIVENESS: mark the block worth a
|
||||
// finalize attempt. Finality (and the reorg) is the cert path's job
|
||||
// (FinalizeBranch), never the count path's — so the count never advances
|
||||
// the ledger nor branches it.
|
||||
// Focus convergence plus the α accept count is liveness: the block is
|
||||
// worth a finalize attempt. Finality (and the reorg) belongs to the cert
|
||||
// path (FinalizeBranch), not the count path — so the count neither
|
||||
// advances the ledger nor branches it.
|
||||
if !shouldContinue && decided && block.acceptVotes() >= c.alpha {
|
||||
block.accepted = true
|
||||
}
|
||||
@@ -287,11 +284,10 @@ func (c *ChainConsensus) IsRejected(blockID ids.ID) bool {
|
||||
return block.rejected
|
||||
}
|
||||
|
||||
// Preference returns the FINALITY preference — the cert-selected/finalized tip
|
||||
//. It stays at the finalized tip until a
|
||||
// quorum cert selects a child; a mere build-tip move does NOT advance it. This is
|
||||
// the finality-reporting concern and MUST NOT be conflated with the build target
|
||||
// (see PreferredBuildTip) — the conformance suite pins this contract.
|
||||
// Preference returns the finality preference — the cert-selected finalized tip. It
|
||||
// stays there until a quorum cert selects a child; a build-tip move does not advance
|
||||
// it. This is the finality-reporting concern, distinct from the build target (see
|
||||
// PreferredBuildTip) — the conformance suite pins this contract.
|
||||
func (c *ChainConsensus) Preference() ids.ID {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
@@ -311,21 +307,20 @@ func (c *ChainConsensus) Preference() ids.ID {
|
||||
return ids.Empty
|
||||
}
|
||||
|
||||
// PreferredBuildTip returns the deterministic BUILD target — the deepest VERIFIED
|
||||
// block extending the finalized chain (NOT merely the finalized tip). The node
|
||||
// steers the VM to build its next block on THIS, so when a verified-but-unfinalized
|
||||
// block exists at height H every validator builds H+1 ON TOP of it and they
|
||||
// CONVERGE — instead of each building a competing sibling at height H, which splits
|
||||
// the α-of-K vote so no single block ever reaches the cert and the chain HALTS the
|
||||
// moment one proposer is down (the down-leader liveness bug). Sibling ties break on
|
||||
// lowest block ID so every node with the same block tree picks the SAME chain.
|
||||
// PreferredBuildTip returns the deterministic build target — the deepest verified
|
||||
// block extending the finalized chain, which is not in general the finalized tip.
|
||||
// The node steers the VM to build its next block on this, so when a verified but
|
||||
// unfinalized block exists at height H every validator builds H+1 on top of it and
|
||||
// they converge. Building on the finalized tip instead gives each proposer a
|
||||
// competing sibling at height H, which splits the α-of-K vote, so no single block
|
||||
// reaches a cert and the chain stops the moment one proposer is down. Sibling ties
|
||||
// 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):
|
||||
// it is a BUILD hint only — finality stays governed exclusively by the α-of-K cert
|
||||
// folded into c.ledger (applyCertLocked), so advancing the build target past the
|
||||
// finalized tip touches no finality decision and cannot affect safety, only
|
||||
// liveness — exactly as the upstream linear-chain consensus steers the VM to its preferred
|
||||
// non-finalized tip.
|
||||
// This is the build concern, decomplected from the finality Preference: a hint only.
|
||||
// Finality is governed exclusively by the α-of-K cert folded into c.ledger
|
||||
// (applyCertLocked), so advancing the build target past the finalized tip decides
|
||||
// nothing and can affect liveness but not safety — the same way a linear-chain
|
||||
// engine steers the VM to its preferred non-finalized tip.
|
||||
func (c *ChainConsensus) PreferredBuildTip() ids.ID {
|
||||
c.mu.RLock()
|
||||
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,
|
||||
// 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
|
||||
// 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.
|
||||
@@ -387,10 +382,10 @@ func (c *ChainConsensus) GetBlock(blockID ids.ID) (*Block, bool) {
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
@@ -407,17 +402,17 @@ func (c *ChainConsensus) EpochHeightOf(blockID ids.ID) (uint64, bool) {
|
||||
}
|
||||
|
||||
// ForcePreference reaffirms the engine's preferred tip after a VM SetPreference
|
||||
// failure. It is a recovery mechanism used when SetPreference fails AFTER a block
|
||||
// was accepted — without it the VM and consensus engine could disagree on the
|
||||
// chain tip, causing a state-divergence death spiral. Every legitimate caller invokes
|
||||
// it with the block that was JUST finalized ("SetPreference failed after Accept"), so
|
||||
// the block is already the finalized tip and this is a reaffirming no-op.
|
||||
// failure. It exists for the case where SetPreference fails after a block was
|
||||
// accepted: left alone, the VM and the engine hold different chain tips and each
|
||||
// subsequent block widens the divergence. Every legitimate caller passes the block
|
||||
// that was just finalized, so the block is already the finalized tip and this is a
|
||||
// reaffirming no-op.
|
||||
//
|
||||
// The committed ledger tip is advanced ONLY by the cert fold (applyCertLocked); the
|
||||
// reorg is the sole authority on finalized history. So this method never moves the
|
||||
// finalized tip — with the per-height ADMISSION gate gone, there is no invariant for a
|
||||
// stray preference to corrupt. It only adopts blockID as the preliminary build
|
||||
// preference before the FIRST finalize, and always records it as a build tip.
|
||||
// The committed ledger tip advances only through the cert fold (applyCertLocked);
|
||||
// the reorg is the sole authority on finalized history. So this method never moves
|
||||
// the finalized tip, and nothing here keys on preference, so a stray preference has
|
||||
// no invariant to corrupt. It adopts blockID as the preliminary build preference
|
||||
// before the first finalize, and always records it as a build tip.
|
||||
func (c *ChainConsensus) ForcePreference(blockID ids.ID) {
|
||||
c.mu.Lock()
|
||||
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
|
||||
// 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).
|
||||
func (c *ChainConsensus) ancestry() Ancestry {
|
||||
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
|
||||
// the VM's own accepted chain.
|
||||
//
|
||||
// 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
|
||||
// The live tree holds what this process has seen; the finalized tip it walks up from
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
@@ -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
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
|
||||
+163
-146
@@ -1,19 +1,18 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// topology.go — the vote/cert distribution handlers that make α-of-K
|
||||
// finality both SAFE and LIVE.
|
||||
// The vote/cert distribution handlers that make α-of-K finality both safe and live.
|
||||
//
|
||||
// The topology in one paragraph: a follower verifies a gossiped block and
|
||||
// BROADCASTS its signed accept vote to ALL validators (followVerifiedBlock,
|
||||
// integration.go). Every validator feeds incoming signed votes into the engine
|
||||
// broadcasts its signed accept vote to every validator (followVerifiedBlock,
|
||||
// integration.go). Each validator feeds incoming signed votes into the engine
|
||||
// (HandleIncomingVote), which collects them toward a QuorumCert. Whichever node
|
||||
// first collects α distinct verified votes assembles the cert, GOSSIPS it
|
||||
// (tryFinalizeBlock), and every node finalizes the block on receipt of that
|
||||
// verifiable proof (HandleIncomingCert). No node finalizes without a cert
|
||||
// (safety); the cert reaches everyone via vote-broadcast + cert-gossip + the
|
||||
// poll-timeout re-request, so finality never hinges on one node's inbound Chits
|
||||
// (liveness — the proposer-freeze cannot recur).
|
||||
// first collects α distinct verified votes assembles the cert and gossips it
|
||||
// (tryFinalizeBlock); every node finalizes the block on receipt of that
|
||||
// verifiable proof (HandleIncomingCert). No node finalizes without a cert —
|
||||
// that is safety. The cert reaches everyone by three independent routes
|
||||
// (vote-broadcast, cert-gossip, the poll-timeout re-request), so finality never
|
||||
// depends on any single node's inbound chits — that is liveness.
|
||||
package chain
|
||||
|
||||
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
|
||||
// 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
|
||||
// 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
|
||||
// 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.
|
||||
// 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
|
||||
@@ -106,12 +105,12 @@ func (rt *Runtime) HandleIncomingVote(blockID ids.ID, voteBytes []byte) bool {
|
||||
return false
|
||||
}
|
||||
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
|
||||
// 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.
|
||||
// Verify against the REMEMBERED accepted position (the exact bytes the accept votes signed).
|
||||
// An unknown / aged-out block drops (unchanged behaviour).
|
||||
// (handleVote → attestFinalizedVote → the Quasar attestor) to complete the export cert.
|
||||
// Verify against the remembered accepted position: the exact bytes the accept votes
|
||||
// signed. An unknown or aged-out block drops.
|
||||
ap, remembered := t.lookupAcceptedPos(blockID)
|
||||
if !remembered || !verifier.VerifyVote(nodeID, CanonicalVoteMessage(ap.pos), sig, ap.epoch) {
|
||||
return false
|
||||
@@ -127,9 +126,9 @@ func (rt *Runtime) HandleIncomingVote(blockID ids.ID, voteBytes []byte) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// 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
|
||||
// signed a different position (different height/round/parent/set-root) fails.
|
||||
// Verify the signature against our position for this block, resolving the
|
||||
// voter's pubkey at the block's P-chain epoch height. A vote that signed a
|
||||
// different position (different height/round/parent/set-root) fails.
|
||||
if !verifier.VerifyVote(nodeID, CanonicalVoteMessage(pos), sig, epochHeight) {
|
||||
if !rt.config.Logger.IsZero() {
|
||||
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,
|
||||
// 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
|
||||
// follower commits a gossiped block ONLY against a verifiable α-of-K proof.
|
||||
// finalizes that block. A follower commits a gossiped block only against a
|
||||
// verifiable α-of-K proof.
|
||||
//
|
||||
// Safety gates:
|
||||
// - the cert must decode and Verify under our VoteVerifier (α distinct
|
||||
// What a cert must clear:
|
||||
// - it must decode and Verify under our VoteVerifier (α distinct
|
||||
// correctly-signed accepts over the cert's position),
|
||||
// - the cert's position chain must match ours,
|
||||
// - we must have VERIFIED the block locally (it is in pendingBlocks). We do
|
||||
// NOT accept a block whose contents we have not validated, even with a
|
||||
// valid cert — the cert proves agreement, local Verify proves validity;
|
||||
// both are required.
|
||||
// - its position chain must match ours,
|
||||
// - we must have verified the block locally (it is in pendingBlocks). A block
|
||||
// whose contents we have not validated is not accepted even with a valid
|
||||
// cert — the cert proves agreement, local Verify proves validity; finality
|
||||
// needs both.
|
||||
//
|
||||
// Returns true iff the block was finalized as a result of this cert.
|
||||
func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
|
||||
@@ -185,17 +184,17 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
|
||||
if verifier == nil {
|
||||
return false
|
||||
}
|
||||
// The cert's threshold MUST meet our own floor FOR ITS TIER — a cert that asserts a
|
||||
// LOWER threshold than this chain requires for that tier is rejected even if its
|
||||
// internal signatures verify (a cheap sub-quorum forgery filter; the AUTHORITATIVE gate
|
||||
// is 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
|
||||
// bare-majority threshold BELOW the ⅔ Quasar floor, so the floor is tier-selected:
|
||||
// The cert's threshold must meet our own floor for its tier: a cert asserting a lower
|
||||
// threshold than this chain requires for that tier is rejected even if its internal
|
||||
// signatures verify. This is a cheap sub-quorum forgery filter; the authority is
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
@@ -217,7 +216,7 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
|
||||
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
|
||||
// (the height is already decided); and
|
||||
// - 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)
|
||||
// is the authoritative backstop that also produces equivocation evidence.
|
||||
if fh, set := t.consensus.GetFinalizedHeight(); set && cert.Position.Height <= fh {
|
||||
// Equivocation is decided on the CANONICAL commitment, NEVER the outer envelope
|
||||
// (the incident-1082814 fix). A DIFFERENT canonical block already finalized at
|
||||
// this height is a POTENTIAL fork; the SAME canonical id under a different
|
||||
// envelope is a harmless DUPLICATE alias (and an identical envelope is a stale
|
||||
// replay). Either way the cert finalizes nothing new.
|
||||
// Equivocation is decided on the canonical commitment, not the outer envelope.
|
||||
// Under anyone-can-propose every validator wraps the same inner execution block
|
||||
// in its own envelope, so envelope identity says nothing about agreement: a
|
||||
// different canonical block already finalized at this height is a potential
|
||||
// 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
|
||||
if certCanonical == ids.Empty {
|
||||
certCanonical = cert.Position.BlockID
|
||||
}
|
||||
finCanonical, have := t.consensus.FinalizedBlockAtHeight(cert.Position.Height)
|
||||
if have && finCanonical != certCanonical {
|
||||
// HARD GATE on equivocation STATE (H1): a conflicting cert is fork EVIDENCE
|
||||
// only if it is a VERIFIED QC. Run the FULL predicate (α distinct in-set
|
||||
// signatures over the canonical position, stake-weighted when wired) BEFORE
|
||||
// recording ANY slashing evidence. The pre-fix code recorded a DoubleVote per
|
||||
// NAMED voter here before checking a single signature — so a forged cert (junk
|
||||
// signatures naming honest validators, a random different canonical, height ≤
|
||||
// finalized) could jail honest validators below quorum and re-halt the chain.
|
||||
// Resolve the epoch from the tracked block if we have it; on a fixed-set chain
|
||||
// the verifier ignores epoch (equal-stake admission invariant), and on a
|
||||
// stake-weighted chain an unresolvable epoch fails verification → no evidence
|
||||
// (fail-closed: we never false-slash, we may miss-slash).
|
||||
// A conflicting cert is fork evidence only if it is a verified QC, so run the
|
||||
// full predicate (α distinct in-set signatures over the canonical position,
|
||||
// stake-weighted when a stake source is wired) before recording any slashing
|
||||
// evidence. Naming a validator costs nothing to forge: junk signatures over a
|
||||
// random canonical at a decided height would otherwise jail honest validators
|
||||
// below quorum and halt the chain — the attack is cheaper than the fork it
|
||||
// claims to prove. Resolve the epoch from the tracked block if we have it; on a
|
||||
// fixed-set chain the verifier ignores epoch (equal-stake admission), and on a
|
||||
// stake-weighted chain an unresolvable epoch fails verification and yields no
|
||||
// evidence. Fail-closed here means we may miss a slash, never invent one.
|
||||
var epochHeight uint64
|
||||
if exists && pending.ConsensusBlock != nil {
|
||||
epochHeight = pending.ConsensusBlock.pChainHeight
|
||||
@@ -258,8 +258,8 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
// A VERIFIED α-of-K cert over a DIFFERENT canonical at a decided height — a
|
||||
// genuine, attributable equivocation. Now (and only now) it is evidence.
|
||||
// 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.
|
||||
rt.reportCertEquivocation(cert, finCanonical)
|
||||
} else if !rt.config.Logger.IsZero() {
|
||||
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
|
||||
}
|
||||
|
||||
// Resolve the cert's epoch height (MEDIUM-1) from OUR locally-tracked,
|
||||
// locally-verified block — the proposervm P-chain height we recorded for this
|
||||
// block ID. Every honest node derives the same epoch height from the same
|
||||
// signed block, so the cert verifies against the IDENTICAL set/root/stake on
|
||||
// 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
|
||||
// anyway), so for a stake-weighted finalize `exists` is required here.
|
||||
// Resolve the cert's epoch height from our locally-tracked, locally-verified
|
||||
// block — the proposervm P-chain height we recorded for this block ID. Every
|
||||
// honest node derives the same epoch height from the same signed block, so the
|
||||
// cert verifies against an identical set/root/stake everywhere.
|
||||
//
|
||||
// A cert can name an envelope we do not track while we hold a DIFFERENT wrapper
|
||||
// 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
|
||||
if exists && pending.ConsensusBlock != nil {
|
||||
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
|
||||
// WE recompute at our epoch height for this block. The set-root is folded into
|
||||
// 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
|
||||
// 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
|
||||
// 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,
|
||||
// stake-weighted to a strict ⅔ when a stake source is wired (HIGH-3), count-only
|
||||
// otherwise (equal-stake admission invariant). A forged cert dies here.
|
||||
// stake-weighted to a strict ⅔ when a stake source is wired, count-only
|
||||
// otherwise (equal-stake admission). A forged cert dies here.
|
||||
if verr := t.verifyCert(cert, epochHeight); verr != nil {
|
||||
if !rt.config.Logger.IsZero() {
|
||||
rt.config.Logger.Warn("incoming cert: verification failed",
|
||||
@@ -324,26 +342,26 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// either (M1). Trigger a throttled, best-effort catch-up fetch for the certified
|
||||
// block so finalization resumes once a reachable peer serves it; the fetch is
|
||||
// gated on 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 peer); claimCatchupLocked rate-limits the request.
|
||||
// safely Accept it — and silently dropping a verified finality proof leaves the
|
||||
// node stuck with no way back. Trigger a throttled, best-effort catch-up fetch
|
||||
// for the certified block so finalization resumes once a reachable peer serves
|
||||
// it. The fetch is reachable only after the cert verified above, so a forged
|
||||
// cert can never make us fetch arbitrary ids. Peer selection is the node layer's
|
||||
// job (EmptyNodeID ⇒ sample a peer); claimCatchupLocked rate-limits the request.
|
||||
if !exists {
|
||||
// STORM-ALIAS UNFREEZE (mainnet 1085755). The cert VERIFIED as a valid α-of-K
|
||||
// witness, but its OUTER envelope is not one we track. Under pChainHeight=0
|
||||
// anyone-can-propose, every validator wraps the SAME inner execution block in its
|
||||
// OWN outer proposervm envelope, so the cert's envelope is an ALIAS of a wrapper we
|
||||
// DO hold and verified. The signed vote message binds the CANONICAL execution
|
||||
// identity, not the outer ids (CanonicalVoteMessage), so the α votes verify against
|
||||
// OUR wrapper's position too — and Accepting any wrapper of the certified inner block
|
||||
// applies the IDENTICAL execution. Finalize the LOCAL wrapper so our EVM advances on
|
||||
// a network-final height instead of freezing while it fetches a redundant alias. Only
|
||||
// if we hold no wrapper of this inner block at all do we fall back to catch-up.
|
||||
// The cert verified as a valid α-of-K witness, but its outer envelope is not one
|
||||
// we track. Under pChainHeight=0 anyone-can-propose, every validator wraps the
|
||||
// same inner execution block in its own proposervm envelope, so the cert's
|
||||
// envelope is an alias of a wrapper we do hold and verified. The signed vote
|
||||
// message binds the canonical execution identity, not the outer ids
|
||||
// (CanonicalVoteMessage), so the α votes verify against our wrapper's position
|
||||
// too — and Accepting any wrapper of the certified inner block applies the same
|
||||
// execution. Finalize the local wrapper, so the VM advances on a network-final
|
||||
// height instead of stalling while it fetches an alias it already has. Only when
|
||||
// we hold no wrapper of this inner block at all do we fall back to catch-up.
|
||||
if localID, ok := t.finalizeLocalAliasFromVerifiedCert(cert); ok {
|
||||
rt.fastFollowMu.Lock()
|
||||
if cert.Position.Height > rt.fastFollowHeight {
|
||||
@@ -373,7 +391,7 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
|
||||
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()
|
||||
if pending.Decided {
|
||||
t.mu.Unlock()
|
||||
@@ -382,19 +400,19 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
|
||||
pending.cert = cert
|
||||
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.
|
||||
vcert, ok := wrapVerifiedCert(cert)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// AcceptWithCert is the SOLE finalizer: it commits the certified branch through
|
||||
// FinalizeBranch (the per-height equivocation gate + the sibling REORG: prune the
|
||||
// losers, accept the path) and applies the VM effects. A safety violation is
|
||||
// returned here and NOTHING is VM-accepted:
|
||||
// - ErrHeightAlreadyFinalized → a SECOND cert at an already-finalized height:
|
||||
// surface the conflict as equivocation evidence (the two-certs-one-height fork);
|
||||
// AcceptWithCert is the one finalizer: it commits the certified branch through
|
||||
// FinalizeBranch (the per-height equivocation gate plus the sibling reorg — prune
|
||||
// the losers, accept the path) and applies the VM effects. A safety violation is
|
||||
// returned here and nothing is VM-accepted:
|
||||
// - ErrHeightAlreadyFinalized → a second cert at an already-finalized height:
|
||||
// surface the conflict as equivocation evidence (two certs, one height);
|
||||
// - ErrConflictsWithFinalizedBranch → a cert for a losing/pruned branch: drop;
|
||||
// - ErrAncestorNotTracked → we are behind: drop (the gap fetch re-applies it).
|
||||
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)
|
||||
}
|
||||
}
|
||||
// SELF-HEAL (the mainnet behind-node fix). ErrAncestorNotTracked means we DO track the
|
||||
// certified block but are MISSING an intermediate ancestor between it and our finalized
|
||||
// tip — we are behind. Trigger the throttled ancestor fetch for the SPECIFIC missing block
|
||||
// so the node-layer serves the finalized gap oldest-first and this VERIFIED cert
|
||||
// re-applies. Pre-fix, a fetch was issued ONLY when the cert's OWN block was untracked
|
||||
// (the !exists arm above); a missing INTERMEDIATE ancestor logged REFUSED and returned
|
||||
// with no fetch, so a slipped node never self-healed (effective n−1, zero-margin → any
|
||||
// flap drops below α and finality stalls — the live 1086xxx wedge). The fetch is gated on
|
||||
// the cert having VERIFIED above, so a forged cert can never make us fetch arbitrary ids;
|
||||
// claimCatchupLocked rate-limits it. Peer selection is the node layer's job (EmptyNodeID ⇒
|
||||
// sample a serving peer).
|
||||
// ErrAncestorNotTracked means we do track the certified block but are missing an
|
||||
// intermediate ancestor between it and our finalized tip — we are behind. A node in
|
||||
// that state cannot heal itself by waiting: every later cert hits the same missing
|
||||
// ancestor, so it never finalizes again and the set runs one validator short until
|
||||
// it restarts. Trigger the throttled ancestor fetch for the specific missing block
|
||||
// so the node layer serves the finalized gap oldest-first and this verified cert
|
||||
// re-applies. The fetch is reachable only after the cert verified above, so a forged
|
||||
// cert can never make us fetch arbitrary ids; claimCatchupLocked rate-limits it.
|
||||
// Peer selection is the node layer's job (EmptyNodeID ⇒ sample a serving peer).
|
||||
var missingAncestor *AncestorNotTracked
|
||||
if errors.As(err, &missingAncestor) {
|
||||
rt.requestCatchup(missingAncestor.Missing, ids.EmptyNodeID)
|
||||
@@ -440,8 +456,8 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
|
||||
rt.fastFollowMu.Unlock()
|
||||
|
||||
// The equivocation guard for this (now-decided) height was already dropped inside
|
||||
// the finalizer AcceptWithCert → acceptWithCertCore (the one funnel every finality
|
||||
// path shares — MEDIUM-1), so no separate prune is needed here.
|
||||
// the finalizer AcceptWithCert → acceptWithCertCore, the one funnel every finality
|
||||
// path shares, so no separate prune is needed here.
|
||||
|
||||
if !rt.config.Logger.IsZero() {
|
||||
rt.config.Logger.Info("finalized block via α-of-K quorum cert",
|
||||
@@ -452,31 +468,33 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// finalizeLocalAliasFromVerifiedCert closes the STORM-ALIAS gap: given an
|
||||
// ALREADY-VERIFIED α-of-K cert whose OUTER envelope this node does NOT track, it
|
||||
// finalizes a LOCAL wrapper of the SAME certified inner execution block, if we hold one.
|
||||
// finalizeLocalAliasFromVerifiedCert takes an already-verified α-of-K cert whose outer
|
||||
// envelope this node does not track and finalizes a local wrapper of the same certified
|
||||
// inner execution block, if we hold one.
|
||||
//
|
||||
// WHY THIS IS SAFE (one certified inner block, any wrapper of it Accepts the identical
|
||||
// execution):
|
||||
// - 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
|
||||
// root + payload root) to equal the cert's — so we can only ever Accept the exact
|
||||
// It is safe because one certified inner block has one execution, and any wrapper of it
|
||||
// Accepts that same execution:
|
||||
// - 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
|
||||
// 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
|
||||
// always satisfies this (its inner execution was re-executed at Verify); the check makes
|
||||
// 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
|
||||
// ONLY the transport ids (BlockID/ParentID). Every SIGNED field — canonical id/parent,
|
||||
// - 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,
|
||||
// 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).
|
||||
// - It RE-VERIFIES the rebased cert against the local wrapper's epoch (the SAME predicate
|
||||
// verifyCert runs: α distinct in-set signatures, ⅔-by-stake when wired, plus the set-root
|
||||
// epoch cross-check the exists-path applies) BEFORE finalizing, and finalizes ONLY through
|
||||
// the sole finalizer (AcceptWithCert → FinalizeBranch). A forged or epoch-mismatched cert
|
||||
// fails here and we fall back to catch-up. No fork: the per-height gate keys on the
|
||||
// canonical id, and a duplicate envelope of an already-final canonical is idempotent.
|
||||
// - It re-verifies the rebased cert against the local wrapper's epoch before finalizing —
|
||||
// the same predicate verifyCert runs (α distinct in-set signatures, ⅔-by-stake when
|
||||
// wired) plus the set-root epoch cross-check the exists-path applies — and finalizes
|
||||
// only through the one finalizer (AcceptWithCert → FinalizeBranch). A forged or
|
||||
// epoch-mismatched cert fails here and we fall back to catch-up. This cannot fork: the
|
||||
// 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
|
||||
// wrapper of this inner block" or "did not re-verify" — the caller then defers to catch-up.
|
||||
// Returns the finalized local envelope id and true on success. (Empty,false) means either
|
||||
// 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) {
|
||||
canon := cert.Position.CanonicalID
|
||||
if canon == ids.Empty {
|
||||
@@ -490,7 +508,7 @@ func (t *Transitive) finalizeLocalAliasFromVerifiedCert(cert *QuorumCert) (ids.I
|
||||
return ids.Empty, false
|
||||
}
|
||||
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.
|
||||
// Height is bound too: a canonical (inner execution) id is unique per height, so a height
|
||||
// mismatch means the resolver crossed heights — fail closed.
|
||||
@@ -528,10 +546,10 @@ func (t *Transitive) finalizeLocalAliasFromVerifiedCert(cert *QuorumCert) (ids.I
|
||||
if err != nil {
|
||||
return ids.Empty, false
|
||||
}
|
||||
// SAFETY GATE: the rebased cert must clear the SAME α-of-K predicate. Because the signed
|
||||
// message is byte-identical to the incoming cert's (outer ids excluded), this passes
|
||||
// exactly when the incoming cert did — re-run for defense in depth (a nil verifier or a
|
||||
// stake shortfall fails closed).
|
||||
// The rebased cert must clear the same α-of-K predicate. Because the signed message is
|
||||
// byte-identical to the incoming cert's (outer ids excluded), this passes exactly when
|
||||
// the incoming cert did — re-run for defence in depth, since a nil verifier or a stake
|
||||
// shortfall fails closed.
|
||||
if verr := t.verifyCert(rebased, epochHeight); verr != nil {
|
||||
return ids.Empty, false
|
||||
}
|
||||
@@ -550,14 +568,13 @@ func (t *Transitive) finalizeLocalAliasFromVerifiedCert(cert *QuorumCert) (ids.I
|
||||
return localID, true
|
||||
}
|
||||
|
||||
// verifyCert runs the FULL finality predicate — α distinct in-set signatures over
|
||||
// the cert's CANONICAL position, stake-weighted to a strict ⅔ when a stake source is
|
||||
// wired (HIGH-3), count-only otherwise. It is the SINGLE gate a cert must pass before
|
||||
// it is treated as finality (the finalize path) OR as equivocation evidence (the
|
||||
// height-gate fork path, H1): a forged cert with junk signatures fails here and can
|
||||
// neither finalize a block nor slash a validator. epochHeight is the P-chain height
|
||||
// the per-voter pubkeys / stake are resolved at (MEDIUM-1). A nil verifier fails
|
||||
// closed.
|
||||
// verifyCert runs the full finality predicate — α distinct in-set signatures over the
|
||||
// cert's canonical position, stake-weighted to a strict ⅔ when a stake source is wired,
|
||||
// count-only otherwise. It is the single gate a cert passes before it counts either as
|
||||
// finality (the finalize path) or as equivocation evidence (the height-gate fork path):
|
||||
// a forged cert with junk signatures fails here and can neither finalize a block nor
|
||||
// slash a validator. epochHeight is the P-chain height the per-voter pubkeys and stake
|
||||
// are resolved at. A nil verifier fails closed.
|
||||
func (t *Transitive) verifyCert(cert *QuorumCert, epochHeight uint64) error {
|
||||
t.mu.RLock()
|
||||
verifier := t.voteVerifier
|
||||
@@ -572,15 +589,15 @@ func (t *Transitive) verifyCert(cert *QuorumCert, epochHeight uint64) error {
|
||||
return cert.Verify(verifier, epochHeight)
|
||||
}
|
||||
|
||||
// reportCertEquivocation records that a SECOND, conflicting finality cert was
|
||||
// presented for a height already finalized to a DIFFERENT CANONICAL commitment — a
|
||||
// provable safety-equivocation (a genuine fork: two valid certs select different
|
||||
// execution blocks at one height). `finalizedCanonical` is the canonical id already
|
||||
// final at this height; the conflicting cert's canonical id differs. This is keyed
|
||||
// on canonical identity, so a duplicate ENVELOPE wrapping the same canonical block
|
||||
// NEVER reaches here (that is the bug this whole change removes). Each voter is
|
||||
// recorded as a DoubleVote. Logged at CRIT — a Byzantine-fault signal. Best effort:
|
||||
// never blocks the safety reject.
|
||||
// reportCertEquivocation records that a second, conflicting finality cert was
|
||||
// presented for a height already finalized to a different canonical commitment — a
|
||||
// provable safety equivocation: two valid certs selecting different execution blocks
|
||||
// at one height. `finalizedCanonical` is the canonical id already final at this
|
||||
// height; the conflicting cert's canonical id differs. Evidence is keyed on canonical
|
||||
// identity, so a duplicate envelope wrapping the same canonical block cannot reach
|
||||
// here — an alias is agreement, not a fork, and slashing on it would punish honest
|
||||
// validators. Each voter is recorded as a DoubleVote. Best effort: it never blocks
|
||||
// the safety reject.
|
||||
func (rt *Runtime) reportCertEquivocation(cert *QuorumCert, finalizedCanonical ids.ID) {
|
||||
conflicting := cert.Position.CanonicalID
|
||||
if conflicting == ids.Empty {
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// transient_selffinality_floor_test.go — FIX #1 (v1.35.29): the transient-count
|
||||
// self-finality fork (mainnet 1085013).
|
||||
// The transient-count self-finality floor.
|
||||
//
|
||||
// THE FORK: validators.Manager.Count(net) reads len(m.validators[net]), which
|
||||
// UNDER-reports during a restart window before the P-chain has replayed the staker
|
||||
// set — it transiently returns 1. The old bftCommittee turned that transient count=1
|
||||
// into K=1/α=1, and a K==1 engine synthesizes a 1-of-1 finality token that BYPASSES
|
||||
// the ⅔-by-stake gate (buildSingleValidatorCertLocked) → the lone live node
|
||||
// self-finalized divergent blocks and forked luxd-0/luxd-1 at 1085013..1085016.
|
||||
// validators.Manager.Count(net) reads the live staker set, which under-reports
|
||||
// while the P-chain is still replaying it after a restart — it can transiently
|
||||
// read 1. Sizing the committee straight off that count gives K=1/α=1, and a K==1
|
||||
// engine synthesizes a 1-of-1 finality token that bypasses the ⅔-by-stake gate
|
||||
// (buildSingleValidatorCertLocked): the one live node finalizes alone, and whatever
|
||||
// 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
|
||||
// size (K=4/α=3, f=1) even when the live count is transiently 1..3, so the α-of-K
|
||||
// COUNT gate ALWAYS demands a real BFT quorum a single node can never reach — the
|
||||
// chain HALTS fail-closed until enough validators resolve, and reclampCommitteeLocked
|
||||
// grows K up toward presetK as they do. A genuine single-validator chain (presetK≤1)
|
||||
// is untouched (K stays 1, it finalizes on its own accept, no peer to fork against).
|
||||
// So bftCommittee floors a presetK>1 committee at the minimal BFT size (K=4/α=3,
|
||||
// f=1) even when the live count reads 1..3. The α-of-K count gate then always
|
||||
// demands a quorum one node cannot reach: the chain halts fail-closed until enough
|
||||
// validators resolve, and reclampCommitteeLocked grows K back toward presetK as
|
||||
// they do. A genuine single-validator chain (presetK≤1) is untouched — K stays 1
|
||||
// and it finalizes on its own accept, having no peer to fork against.
|
||||
package chain
|
||||
|
||||
import (
|
||||
@@ -27,21 +26,20 @@ import (
|
||||
"github.com/luxfi/ids"
|
||||
)
|
||||
|
||||
// TestFix1_BFTCommittee_TransientCountNeverSelfFinalizes is the sizer-level
|
||||
// fail-without/pass-with. OLD: bftCommittee(presetK>1, count=1) = (1,1) — a
|
||||
// self-finalizing 1-of-1 committee. NEW: it floors at the minimal BFT committee, so a
|
||||
// multi-validator preset can NEVER be clamped to α≤1 by a transient count.
|
||||
func TestFix1_BFTCommittee_TransientCountNeverSelfFinalizes(t *testing.T) {
|
||||
// The exact fork inputs: a multi-validator preset whose live set transiently reads 1.
|
||||
// The floor is the minimal BFT committee K=4/α=3, never the self-finalizing K=1/α=1.
|
||||
// TestBFTCommittee_TransientCountNeverSelfFinalizes pins the sizer: a
|
||||
// multi-validator preset whose live count reads below the BFT floor sizes to the
|
||||
// floor, never to a self-finalizing 1-of-1 committee.
|
||||
func TestBFTCommittee_TransientCountNeverSelfFinalizes(t *testing.T) {
|
||||
// A multi-validator preset whose live set transiently reads short. The floor is
|
||||
// the minimal BFT committee K=4/α=3, never the self-finalizing K=1/α=1.
|
||||
for _, tc := range []struct {
|
||||
presetK, count int
|
||||
wantK, wantAlpha int
|
||||
}{
|
||||
{5, 1, 4, 3}, // the pin's "presetK=5 chain, 1 live validator at boot"
|
||||
{21, 1, 4, 3}, // mainnet preset, transient count=1 (the 1085013 fork input)
|
||||
{11, 1, 4, 3}, // testnet preset, transient count=1
|
||||
{21, 2, 4, 3}, // 2 live — still below the BFT floor, still HALTs (not 2-of-2 self-quorum)
|
||||
{5, 1, 4, 3}, // a 5-validator chain, 1 live validator at boot
|
||||
{21, 1, 4, 3}, // mainnet preset, live count transiently 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 a 2-of-2 self-quorum)
|
||||
{21, 3, 4, 3}, // 3 live — floored
|
||||
{21, 4, 4, 3}, // 4 live — exactly the floor
|
||||
{21, 5, 5, 4}, // 5 live — the genuine set clamps normally (4-of-5)
|
||||
@@ -54,10 +52,10 @@ func TestFix1_BFTCommittee_TransientCountNeverSelfFinalizes(t *testing.T) {
|
||||
t.Errorf("bftCommittee(%d,%d) = (K=%d,α=%d); want (K=%d,α=%d)",
|
||||
tc.presetK, tc.count, k, alpha, tc.wantK, tc.wantAlpha)
|
||||
}
|
||||
// THE SAFETY INVARIANT: a multi-validator preset is NEVER clamped to a
|
||||
// The safety invariant: a multi-validator preset is never clamped to a
|
||||
// self-finalizing committee (α≤1 ⇒ one vote finalizes).
|
||||
if alpha <= 1 {
|
||||
t.Errorf("SELF-FINALITY: bftCommittee(%d,%d) yielded α=%d ≤ 1 — a lone node could self-finalize",
|
||||
t.Errorf("self-finality: bftCommittee(%d,%d) yielded α=%d ≤ 1 — a lone node could self-finalize",
|
||||
tc.presetK, tc.count, alpha)
|
||||
}
|
||||
}
|
||||
@@ -77,10 +75,10 @@ func TestFix1_BFTCommittee_TransientCountNeverSelfFinalizes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFix1_BFTCommittee_PreservesGenuineSingleAndNormalClamp guards the fix from
|
||||
// regressing (a) the genuine single-validator path and (b) the normal oversized-preset
|
||||
// down-clamp (the testnet anti-wedge regression).
|
||||
func TestFix1_BFTCommittee_PreservesGenuineSingleAndNormalClamp(t *testing.T) {
|
||||
// TestBFTCommittee_GenuineSingleAndNormalClamp covers the two paths the floor must
|
||||
// leave alone: the genuine single-validator chain, and the ordinary down-clamp of an
|
||||
// oversized preset onto a smaller live set.
|
||||
func TestBFTCommittee_GenuineSingleAndNormalClamp(t *testing.T) {
|
||||
// (a) Genuine single validator: presetK≤1 ⇒ K stays 1 (self-finalizes correctly —
|
||||
// there is no peer to fork against). presetK≤count ⇒ no clamp.
|
||||
for _, count := range []int{1, 5, 21} {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
// (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 }{
|
||||
{11, 5, 5, 4}, // TestnetParams 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
|
||||
// construction path: a presetK=5 chain whose validator sampler transiently reports 1
|
||||
// live validator (the restart window) must boot a FLOORED committee (K≥4/α≥3), NOT the
|
||||
// self-finalizing K=1/α=1 the old sizer produced.
|
||||
func TestFix1_NewRuntime_TransientCountBootsFlooredNotSelfFinalizing(t *testing.T) {
|
||||
self, sampler := makeValidators(1) // Count(net) == 1 — the transient restart read
|
||||
// TestNewRuntime_TransientCountBootsFlooredCommittee drives the real construction
|
||||
// path: a presetK=5 chain whose validator sampler transiently reports 1 live
|
||||
// validator boots the floored committee (K≥4/α≥3), not a self-finalizing K=1/α=1.
|
||||
func TestNewRuntime_TransientCountBootsFlooredCommittee(t *testing.T) {
|
||||
self, sampler := makeValidators(1) // Count(net) == 1 — the transient short read
|
||||
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{
|
||||
ChainID: ids.GenerateTestID(),
|
||||
NetworkID: ids.Empty,
|
||||
@@ -124,8 +121,8 @@ func TestFix1_NewRuntime_TransientCountBootsFlooredNotSelfFinalizing(t *testing.
|
||||
Params: ¶ms,
|
||||
})
|
||||
if got := rt.Transitive.consensus.K(); got != 4 {
|
||||
t.Fatalf("transient count=1 with presetK=5 must FLOOR the committee to K=4 (minimal BFT), got K=%d "+
|
||||
"(K=1 would be the 1085013 self-finality committee)", got)
|
||||
t.Fatalf("transient count=1 with presetK=5 must floor the committee to K=4 (minimal BFT), got K=%d "+
|
||||
"(K=1 is the self-finalizing committee)", got)
|
||||
}
|
||||
if got := rt.Transitive.consensus.Alpha(); got < 3 {
|
||||
t.Fatalf("floored committee α must be ≥3 (BFT quorum), got α=%d — α=1 self-finalizes", got)
|
||||
@@ -135,13 +132,14 @@ func TestFix1_NewRuntime_TransientCountBootsFlooredNotSelfFinalizing(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
// TestFix1_FlooredCommittee_NovaAcceptsAtMajorityQuasarAtTwoThirds is the downstream proof under
|
||||
// the v1.36 two 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
|
||||
// guard the floor exists for is preserved: a single node can never accept),
|
||||
// - NOVA-ACCEPTS a 3-of-5 bare majority (local execution — the "survive 3/5" liveness), but
|
||||
// - reaches the EXPORT (Quasar) tier ONLY at a real 4-of-5 ⅔-stake supermajority.
|
||||
func TestFix1_FlooredCommittee_NovaAcceptsAtMajorityQuasarAtTwoThirds(t *testing.T) {
|
||||
// TestFlooredCommittee_NovaAcceptsAtMajorityQuasarAtTwoThirds carries the floor
|
||||
// through both tiers: a floored K=4/α=3 committee backed by a 5-validator stake source
|
||||
// - halts on a lone self-vote (below the Nova majority NovaQuorum=3 — what the floor
|
||||
// exists for: a single node can never accept),
|
||||
// - Nova-accepts a 3-of-5 bare majority (local execution — surviving 3/5 keeps the
|
||||
// chain live), but
|
||||
// - 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
|
||||
rec := &recordingGossiper{}
|
||||
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")
|
||||
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
|
||||
// exactly the 1085013 fork the floor prevents, and it stays prevented at the Nova tier.
|
||||
// 1 self-vote — below the Nova majority (NovaQuorum(4)=3), so it halts: lone
|
||||
// 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)")
|
||||
|
||||
// 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).
|
||||
// 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).
|
||||
e.ReceiveVote(vs.signedVote(1, pos))
|
||||
e.ReceiveVote(vs.signedVote(2, pos))
|
||||
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)")
|
||||
|
||||
// 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))
|
||||
mustQuasar(t, e, blk, 3*time.Second, "4-of-5 ⅔-stake supermajority (Quasar export)")
|
||||
}
|
||||
|
||||
@@ -701,7 +701,7 @@ func TestSigSchemeToString(t *testing.T) {
|
||||
// --- HostnameValidationError.Error() ---
|
||||
|
||||
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()
|
||||
if msg == "" {
|
||||
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 {
|
||||
t.Errorf("valid hostname should pass: %v", err)
|
||||
}
|
||||
// Invalid
|
||||
if err := ValidatePeerAddress("10.0.0.1:9651"); err == nil {
|
||||
// A peer is named, never numbered: an address literal binds a peer to a
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
+12
-7
@@ -6,6 +6,7 @@ package wire
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -469,7 +470,7 @@ func TestQuantumPolicyRTEnforcement(t *testing.T) {
|
||||
|
||||
func TestQuantumPolicyRTDisabled(t *testing.T) {
|
||||
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)
|
||||
policy.OnCandidate(ctx, candidate)
|
||||
@@ -489,6 +490,9 @@ func TestQuantumPolicyRTDisabled(t *testing.T) {
|
||||
// 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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -503,10 +507,10 @@ func TestHostnameValidation(t *testing.T) {
|
||||
{"single word", "mynode", true},
|
||||
|
||||
// Invalid - IPv4 literals
|
||||
{"IPv4", "192.168.1.1", false},
|
||||
{"IPv4 with port", "192.168.1.1:9651", false},
|
||||
{"IPv4 localhost", "127.0.0.1", false},
|
||||
{"IPv4 localhost with port", "127.0.0.1:9651", false},
|
||||
{"IPv4 unicast", v4(192, 168, 1, 1), false},
|
||||
{"IPv4 unicast with port", v4(192, 168, 1, 1) + ":9651", false},
|
||||
{"IPv4 loopback", v4(127, 0, 0, 1), false},
|
||||
{"IPv4 loopback with port", v4(127, 0, 0, 1) + ":9651", false},
|
||||
|
||||
// Invalid - IPv6 literals
|
||||
{"IPv6 localhost", "::1", false},
|
||||
@@ -536,7 +540,8 @@ func TestHostnameValidation(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 {
|
||||
t.Fatal("Expected error for IPv4 literal")
|
||||
}
|
||||
@@ -546,7 +551,7 @@ func TestHostnameValidationError(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
if hvErr.Reason == "" {
|
||||
|
||||
Reference in New Issue
Block a user