Merge fix/qcv2-canonical-finality: QCv2 canonical-commitment finality + RED-round H1 verify-before-slash (incident 1082814)

Finality keys on the canonical inner execution commitment, not the outer
proposervm envelope. Two envelopes wrapping the same inner block are duplicates,
not forks. vm.LastAccepted is a non-authoritative recovery hint. Equivocation
evidence requires a VERIFIED alpha-of-K cert over a DIFFERENT canonical (H1
false-slash fix). Supersedes v1.31.5 (QCv2 core only, no H1) and v1.32.0
(down-leader line, no QCv2).

Proof: 377 subtests PASS, -race clean, go vet clean, 6-row matrix + inversions
+ H1/M1 regressions + cert codec/finalize fuzzers.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-06-30 15:17:47 -07:00
co-authored by Hanzo Dev
16 changed files with 1726 additions and 202 deletions
+10 -1
View File
@@ -163,7 +163,16 @@ func (rt *Runtime) AcceptBootstrapBlock(ctx context.Context, blockBytes []byte)
// returns a prune plan; bootstrap never produces one (single-branch). On any
// violation NOTHING advances and we reject — a bootstrapping node cannot be fed a
// forked/gapped history any more than a live one.
if _, err := rt.Transitive.consensus.FinalizeBranch(blk.ID(), blk.Height(), blk.ParentID()); err != nil {
// The canonical execution commitment is what byHeight records (the authoritative
// finality id); for a non-wrapped block canonicalIDOf falls back to the outer id.
// Bootstrap uses the canonical-aware ApplyCert so a proposervm-wrapped bootstrap
// block records its INNER id, not the envelope.
if _, err := rt.Transitive.consensus.ApplyCert(Cert{
Block: blk.ID(),
Parent: blk.ParentID(),
Height: blk.Height(),
Canonical: canonicalIDOf(blk),
}); err != nil {
return errors.Join(ErrBootstrapBlockRejected, err)
}
+1
View File
@@ -193,6 +193,7 @@ func (rt *Runtime) trackVerifiedForCatchup(ctx context.Context, blk block.Block)
data: blk.Bytes(),
pChainHeight: pChainHeightOf(blk), // epoch for the weighted set (MEDIUM-1)
}
setCanonicalFromVM(consensusBlock, blk) // stamp the inner execution commitment
// AddBlock is idempotent-ish (errors if already present); the error is ignored
// because a re-track is harmless and the pendingBlocks guard below is the gate.
_ = rt.Transitive.consensus.AddBlock(ctx, consensusBlock)
+6 -1
View File
@@ -145,7 +145,12 @@ func catchupCertFor(t *testing.T, vs *testValidatorSet, chainID ids.ID, blk *ver
func seedBehindAt(t *testing.T, rt *Runtime, vm *catchupVM, tip *verifyOnceBlock) {
t.Helper()
vm.register(tip)
if err := rt.Transitive.consensus.SyncState(tip.id, tip.height); err != nil {
// 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.
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)
}
if fh, set := rt.Transitive.consensus.GetFinalizedHeight(); !set || fh != tip.height {
+123 -24
View File
@@ -56,14 +56,43 @@ import (
// certificate. Bound into the canonical vote message and the cert digest so a
// version bump is non-malleable.
//
// v2 adds VotePosition.ValidatorSetRoot to BOTH the canonical signed message and
// the wire codec (the MEDIUM epoch-binding fix). This is a deliberate
// forward-only break: a v1 signature (no set-root) and a v2 signature (with
// set-root) 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. The whole validator set upgrades together in
// one release cascade; there is no partial-upgrade finality interop.
const QuorumCertVersion uint16 = 2
// Wire v2 added VotePosition.ValidatorSetRoot (the MEDIUM epoch-binding fix).
//
// 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.
//
// 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.
const QuorumCertVersion uint16 = 3
// QCType names a certificate's semantic role so a signature gathered for one
// role can never be replayed as another. The chain engine finalizes blocks, so
@@ -117,16 +146,54 @@ type SignedVote struct {
Signature []byte
}
// VotePosition is the consensus position a vote (and a cert) binds to. Every
// axis here is folded into the canonical signed message, so a signature for one
// position can never be replayed at another (height/round/block/parent/chain/
// validator-set epoch).
// 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 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.
//
// 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 {
ChainID ids.ID
Height uint64
Round uint32
ChainID ids.ID
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 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 ids.ID
// ParentCanonicalID is the inner execution commitment of the parent — binds the
// certified block into the canonical ancestry. Bound into the signed message.
ParentCanonicalID ids.ID
// ExecutionStateRoot is the post-execution state root the block commits to.
// Bound into the signed message so a cert pins the exact execution result (a
// block claiming the same canonical id but a different state root would be a
// distinct signed message). ids.Empty when the VM does not expose one.
ExecutionStateRoot ids.ID
// 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
@@ -165,22 +232,36 @@ 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.
//
// Layout (big-endian, fixed-width, length-free because every field is fixed):
//
// "LUX/chain/vote/v1\x00" domain tag (NUL-terminated, role separator)
// "LUX/chain/vote/v2\x00" domain tag (NUL-terminated; v2 == canonical-commitment)
// version:2 qc_type:1
// chain_id:32 height:8 round:4 block_id:32 parent_id:32
// validator_set_root:32 (epoch/weighted-set commitment; Empty = unbound)
// chain_id:32 height:8 round:4
// canonical_block_id:32 <- PRIMARY consensus object (inner execution commitment)
// parent_canonical_id:32
// execution_state_root:32
// payload_root:32 <- tx_root
// 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' — every signature was over R, so a message rebuilt with R'
// fails verification.
// 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.
func canonicalVoteMessageFor(pos VotePosition, accept bool) []byte {
const tag = "LUX/chain/vote/v1\x00"
buf := make([]byte, 0, len(tag)+2+1+32+8+4+32+32+32+1)
const tag = "LUX/chain/vote/v2\x00"
buf := make([]byte, 0, len(tag)+2+1+32+8+4+32+32+32+32+32+1)
buf = append(buf, tag...)
var u16 [2]byte
binary.BigEndian.PutUint16(u16[:], QuorumCertVersion)
@@ -193,8 +274,26 @@ func canonicalVoteMessageFor(pos VotePosition, accept bool) []byte {
var u32 [4]byte
binary.BigEndian.PutUint32(u32[:], pos.Round)
buf = append(buf, u32[:]...)
buf = append(buf, pos.BlockID[:]...)
buf = append(buf, pos.ParentID[:]...)
// 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.
canonicalID := pos.CanonicalID
if canonicalID == ids.Empty {
canonicalID = pos.BlockID
}
parentCanonicalID := pos.ParentCanonicalID
if parentCanonicalID == ids.Empty {
parentCanonicalID = pos.ParentID
}
buf = append(buf, canonicalID[:]...)
buf = append(buf, parentCanonicalID[:]...)
buf = append(buf, pos.ExecutionStateRoot[:]...)
buf = append(buf, pos.PayloadRoot[:]...)
buf = append(buf, pos.ValidatorSetRoot[:]...)
if accept {
buf = append(buf, 0x01)
@@ -0,0 +1,300 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// cert_canonical_inversion_test.go — adversarial inversions of the QCv2
// canonical-commitment cert. Each test asserts a forged / replayed / malformed /
// sub-quorum / wrong-set / cross-context cert is REJECTED, and — the heart of the
// canonical/transport split — that the OUTER envelope id is NON-AUTHORITATIVE
// (swapping it never changes the signed identity) while the CANONICAL commitment
// IS cryptographically bound (tampering it breaks every signature).
package chain
import (
"bytes"
"context"
"testing"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
// fivePos builds a canonical position for a block at height/round.
func canonPos(chainID, outer, parentOuter, inner, parentInner ids.ID, height uint64, round uint32) VotePosition {
return VotePosition{
ChainID: chainID,
Height: height,
Round: round,
BlockID: outer,
ParentID: parentOuter,
CanonicalID: inner,
ParentCanonicalID: parentInner,
}
}
// mkValidCert assembles an n-of-N Ed25519 cert over pos.
func mkValidCert(t *testing.T, vs *testValidatorSet, pos VotePosition, n int) *QuorumCert {
t.Helper()
votes := make([]SignedVote, 0, n)
for i := 0; i < n; i++ {
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
}
cert, err := AssembleQuorumCert(pos, uint32(n), votes)
if err != nil {
t.Fatalf("assemble: %v", err)
}
return cert
}
// TestCanonMsg_ExcludesEnvelope_BindsCanonical is the cryptographic statement of
// the fix: CanonicalVoteMessage depends on the CANONICAL identity and NOT on the
// outer envelope. Two positions that differ ONLY in the outer ids produce the SAME
// signed bytes (duplicates interoperate); positions that differ in ANY canonical
// axis produce DIFFERENT bytes (a fork is a different signature).
func TestCanonMsg_ExcludesEnvelope_BindsCanonical(t *testing.T) {
chainID := ids.GenerateTestID()
inner := ids.GenerateTestID()
parentInner := ids.GenerateTestID()
base := canonPos(chainID, ids.GenerateTestID(), ids.GenerateTestID(), inner, parentInner, 7, 3)
// Same canonical identity, DIFFERENT outer envelope ⇒ identical signed message.
aliased := base
aliased.BlockID = ids.GenerateTestID()
aliased.ParentID = ids.GenerateTestID()
if !bytes.Equal(CanonicalVoteMessage(base), CanonicalVoteMessage(aliased)) {
t.Fatal("envelope swap changed the signed message — the outer id is NOT supposed to be signed")
}
// Any CANONICAL change ⇒ different signed message.
for name, mutate := range map[string]func(p *VotePosition){
"canonicalID": func(p *VotePosition) { p.CanonicalID = ids.GenerateTestID() },
"parentCanonicalID": func(p *VotePosition) { p.ParentCanonicalID = ids.GenerateTestID() },
"executionStateRoot": func(p *VotePosition) { p.ExecutionStateRoot = ids.GenerateTestID() },
"payloadRoot": func(p *VotePosition) { p.PayloadRoot = ids.GenerateTestID() },
"validatorSetRoot": func(p *VotePosition) { p.ValidatorSetRoot = ids.GenerateTestID() },
"height": func(p *VotePosition) { p.Height++ },
"round": func(p *VotePosition) { p.Round++ },
"chainID": func(p *VotePosition) { p.ChainID = ids.GenerateTestID() },
} {
m := base
mutate(&m)
if bytes.Equal(CanonicalVoteMessage(base), CanonicalVoteMessage(m)) {
t.Fatalf("mutating %s did NOT change the signed message (it must be bound)", name)
}
}
}
// TestInversion_EnvelopeSwapVerifiesButCanonicalTamperFails: a cert whose OUTER id
// is swapped STILL verifies (the outer id is not signed — it is a transport hint);
// a cert whose CANONICAL id is tampered FAILS (every signature was over the
// original canonical identity). This is the malleability boundary: an attacker may
// relabel the envelope (harmless) but cannot retarget the finalized execution block.
func TestInversion_EnvelopeSwapVerifiesButCanonicalTamperFails(t *testing.T) {
vs := newTestValidatorSet(5)
chainID := ids.GenerateTestID()
// A realistic proposervm block: BOTH canonical ids are populated (non-Empty), so
// the message binds the canonical identity and never falls back to the outer ids.
pos := canonPos(chainID, ids.GenerateTestID(), ids.GenerateTestID(), ids.GenerateTestID(), ids.GenerateTestID(), 9, 0)
cert := mkValidCert(t, vs, pos, 4)
if err := cert.Verify(vs, 9); err != nil {
t.Fatalf("baseline cert must verify: %v", err)
}
// Swap the OUTER envelope id (transport) — STILL verifies (non-authoritative).
swapped := *cert
swapped.Position.BlockID = ids.GenerateTestID()
swapped.Position.ParentID = ids.GenerateTestID()
if err := swapped.Verify(vs, 9); err != nil {
t.Fatalf("envelope swap must NOT break verification (outer id is not signed): %v", err)
}
// Tamper the CANONICAL id — every signature was over the old one ⇒ FAILS.
tampered := *cert
tampered.Position.CanonicalID = ids.GenerateTestID()
if err := tampered.Verify(vs, 9); err == nil {
t.Fatal("tampering the canonical id must break verification (it is cryptographically bound)")
}
}
// TestInversion_ForgedSignatureRejected: a cert carrying a forged signature (a
// random blob, or another node's key) fails verification.
func TestInversion_ForgedSignatureRejected(t *testing.T) {
vs := newTestValidatorSet(5)
chainID := ids.GenerateTestID()
pos := canonPos(chainID, ids.GenerateTestID(), ids.Empty, ids.GenerateTestID(), ids.Empty, 1, 0)
good := mkValidCert(t, vs, pos, 4)
// Replace one signature with garbage.
forged := *good
forged.Votes = append([]SignedVote(nil), good.Votes...)
forged.Votes[2].Signature = bytes.Repeat([]byte{0xAB}, len(forged.Votes[2].Signature))
if err := forged.Verify(vs, 1); err == nil {
t.Fatal("a forged signature must be rejected")
}
}
// TestInversion_WrongSetSignerRejected: a voter that is NOT in the validator set
// contributes an unverifiable signature → rejected (unknown node ⇒ false, never an
// error, never a count).
func TestInversion_WrongSetSignerRejected(t *testing.T) {
vs := newTestValidatorSet(5)
chainID := ids.GenerateTestID()
pos := canonPos(chainID, ids.GenerateTestID(), ids.Empty, ids.GenerateTestID(), ids.Empty, 1, 0)
outsider := ids.GenerateTestNodeID() // not in vs
votes := []SignedVote{
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
{NodeID: outsider, Accept: true, Signature: bytes.Repeat([]byte{0x01}, 64)},
}
cert, err := AssembleQuorumCert(pos, 4, votes)
if err != nil {
t.Fatalf("assemble: %v", err)
}
if err := cert.Verify(vs, 1); err == nil {
t.Fatal("a cert padded with an out-of-set signer must not reach quorum")
}
}
// TestInversion_SubQuorumCertRejectedAtFloor: a cert that asserts a threshold below
// the chain's α floor is rejected at the receive boundary even if its internal
// signatures verify (the MinThreshold floor — sub-quorum finality forgery defence).
func TestInversion_SubQuorumCertRejectedAtFloor(t *testing.T) {
vs := newTestValidatorSet(5)
chainID := ids.GenerateTestID()
rt := newCanonRuntime(t, vs, chainID, 4) // α floor = AlphaPreference = 3 (params5)
inner := ids.GenerateTestID()
outer := ids.GenerateTestID()
trackEnvelope(rt, outer, ids.Empty, inner, ids.Empty, 1, 0)
// A 2-of-5 cert (threshold 2 < α floor 3) — internally valid signatures but the
// asserted threshold is below the chain's quorum floor.
pos := canonPos(chainID, outer, ids.Empty, inner, ids.Empty, 1, 0)
cert := mkValidCert(t, vs, pos, 2)
b, _ := cert.MarshalBinary()
if rt.HandleIncomingCert(b) {
t.Fatal("a cert below the α floor must be rejected (sub-quorum forgery defence)")
}
}
// TestInversion_CrossHeightReplayRejected: a cert assembled for height H cannot be
// replayed as height H' — height is in the signed message, so re-stamping it breaks
// every signature.
func TestInversion_CrossHeightReplayRejected(t *testing.T) {
vs := newTestValidatorSet(5)
chainID := ids.GenerateTestID()
pos := canonPos(chainID, ids.GenerateTestID(), ids.Empty, ids.GenerateTestID(), ids.Empty, 10, 0)
cert := mkValidCert(t, vs, pos, 4)
replay := *cert
replay.Position.Height = 11 // re-stamp to a different height
if err := replay.Verify(vs, 10); err == nil {
t.Fatal("a cross-height replay must be rejected (height is signed)")
}
}
// TestInversion_CrossChainReplayRejected: a cert for chain X cannot be replayed on
// chain Y — chainID is in the signed message, and the receive path also drops a
// cert whose position chain != ours.
func TestInversion_CrossChainReplayRejected(t *testing.T) {
vs := newTestValidatorSet(5)
chainX := ids.GenerateTestID()
pos := canonPos(chainX, ids.GenerateTestID(), ids.Empty, ids.GenerateTestID(), ids.Empty, 1, 0)
cert := mkValidCert(t, vs, pos, 4)
// Crypto: re-stamping the chain id breaks the signatures.
replay := *cert
replay.Position.ChainID = ids.GenerateTestID()
if err := replay.Verify(vs, 1); err == nil {
t.Fatal("a cross-chain replay must be rejected (chainID is signed)")
}
// Receive path: even a self-consistent cert for ANOTHER chain is dropped.
chainY := ids.GenerateTestID()
rt := newCanonRuntime(t, vs, chainY, 4)
b, _ := cert.MarshalBinary() // cert is for chainX
if rt.HandleIncomingCert(b) {
t.Fatal("a cert for a different chain must be dropped by the receive path")
}
}
// TestInversion_DuplicateVoterRejected: a cert with a repeated NodeID never
// double-counts — assembly rejects it, and a hand-built one fails the
// strictly-increasing clause.
func TestInversion_DuplicateVoterRejected(t *testing.T) {
vs := newTestValidatorSet(5)
chainID := ids.GenerateTestID()
pos := canonPos(chainID, ids.GenerateTestID(), ids.Empty, ids.GenerateTestID(), ids.Empty, 1, 0)
dup := []SignedVote{
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)},
{NodeID: vs.nodeID(0), Accept: true, Signature: vs.sign(0, pos)}, // same node twice
{NodeID: vs.nodeID(1), Accept: true, Signature: vs.sign(1, pos)},
{NodeID: vs.nodeID(2), Accept: true, Signature: vs.sign(2, pos)},
}
if _, err := AssembleQuorumCert(pos, 4, dup); err == nil {
t.Fatal("assembling a cert with a duplicate voter must fail")
}
// Hand-build a cert that bypasses assembly's dedup — Verify must still reject it
// on the strictly-increasing clause (no double counting).
v := vs.signedVote(0, pos)
raw := &QuorumCert{
Version: QuorumCertVersion,
Type: QCFinality,
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")
}
}
// TestInversion_StaleEpochSetRootMismatchDropped: a cert whose ValidatorSetRoot does
// not match the set-root WE recompute at the block's epoch is dropped by the
// receive-side epoch cross-check — a cert laundered from a different validator-set
// epoch cannot finalize against our set.
func TestInversion_StaleEpochSetRootMismatchDropped(t *testing.T) {
vs := newTestValidatorSet(5)
chainID := ids.GenerateTestID()
// Our node computes set-root = a fixed value R for every epoch.
R := ids.GenerateTestID()
rt := newCanonRuntimeOpts(t, vs, chainID, 4, WithValidatorSetRoot(ValidatorSetRootFunc(func(uint64) ids.ID { return R })))
inner := ids.GenerateTestID()
outer := ids.GenerateTestID()
trackEnvelope(rt, outer, ids.Empty, inner, ids.Empty, 1, 0)
// The cert is signed under a DIFFERENT (stale) set-root R'.
stale := ids.GenerateTestID()
pos := canonPos(chainID, outer, ids.Empty, inner, ids.Empty, 1, 0)
pos.ValidatorSetRoot = stale
cert := mkValidCert(t, vs, pos, 4)
b, _ := cert.MarshalBinary()
if rt.HandleIncomingCert(b) {
t.Fatal("a cert under a stale/foreign validator-set root must be dropped by the epoch cross-check")
}
}
// newCanonRuntime builds a started follower with the test set as verifier and an α
// floor from params, returning the receive Runtime (Noop logger so the equivocation
// Crit path never exits the test process).
func newCanonRuntime(t *testing.T, vs *testValidatorSet, chainID ids.ID, _ int) *Runtime {
return newCanonRuntimeOpts(t, vs, chainID, 0)
}
func newCanonRuntimeOpts(t *testing.T, vs *testValidatorSet, chainID ids.ID, _ int, extra ...Option) *Runtime {
t.Helper()
opts := append([]Option{WithQuorumCert(chainID, vs.nodeID(4), vs, &recordingGossiper{}, vs.signerFor(4))}, extra...)
follower := NewWithConfig(Config{Params: params5()}, opts...)
if err := follower.Start(context.Background(), true); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { _ = follower.Stop(context.Background()) })
return &Runtime{Transitive: follower, config: NetworkConfig{ChainID: chainID, Logger: log.Noop()}}
}
+33 -5
View File
@@ -21,22 +21,33 @@ import (
// ErrQCWireCorrupt is returned by the decoder on any structural defect.
var ErrQCWireCorrupt = errors.New("chain: quorum cert wire corrupt")
// Wire layout (big-endian):
// Wire layout (big-endian). The wire carries BOTH the canonical execution
// identity (the signed primary object) and the outer transport ids (the
// non-authoritative cache key used for block lookup/gossip):
//
// version:2
// type:1
// chain_id:32
// height:8
// round:4
// block_id:32
// parent_id:32
// validator_set_root:32 (epoch/weighted-set commitment; Empty = unbound)
// block_id:32 (OUTER/envelope — transport cache key; NOT signed)
// parent_id:32 (OUTER/envelope parent — transport; NOT signed)
// canonical_block_id:32 (inner execution commitment — PRIMARY; signed)
// parent_canonical_id:32 (inner parent commitment — signed)
// execution_state_root:32 (post-execution state root — signed)
// payload_root:32 (tx/payload root — signed)
// validator_set_root:32 (epoch/weighted-set commitment; Empty = unbound; signed)
// threshold:4
// vote_count:4
// then vote_count records, each:
// node_id:20 (ids.NodeID is a 20-byte value)
// accept:1
// sig_len:4 sig:sig_len
//
// The outer ids are encoded so a receiver can locate the block, but they are
// NOT part of CanonicalVoteMessage — a signature commits only to the canonical
// identity, so a cert is identical regardless of which envelope wrapped the
// inner block.
// qcVoteFixed is the fixed-size prefix of one encoded vote (node_id + accept +
// sig_len). Used to cap vote_count against the buffer before allocation.
@@ -44,8 +55,9 @@ const qcVoteFixed = ids.NodeIDLen + 1 + 4
// qcHeaderSize is the fixed header byte length preceding the vote records.
// (version + type + chain_id + height + round + block_id + parent_id +
// canonical_block_id + parent_canonical_id + execution_state_root + payload_root +
// validator_set_root + threshold + vote_count.)
const qcHeaderSize = 2 + 1 + 32 + 8 + 4 + 32 + 32 + 32 + 4 + 4
const qcHeaderSize = 2 + 1 + 32 + 8 + 4 + 32 + 32 + 32 + 32 + 32 + 32 + 32 + 4 + 4
// MarshalBinary encodes the cert deterministically. Equal certs encode to equal
// bytes (votes are kept in the strictly-increasing order Assemble produced).
@@ -67,6 +79,10 @@ func (c *QuorumCert) MarshalBinary() ([]byte, error) {
buf = append(buf, u32[:]...)
buf = append(buf, c.Position.BlockID[:]...)
buf = append(buf, c.Position.ParentID[:]...)
buf = append(buf, c.Position.CanonicalID[:]...)
buf = append(buf, c.Position.ParentCanonicalID[:]...)
buf = append(buf, c.Position.ExecutionStateRoot[:]...)
buf = append(buf, c.Position.PayloadRoot[:]...)
buf = append(buf, c.Position.ValidatorSetRoot[:]...)
binary.BigEndian.PutUint32(u32[:], c.Threshold)
buf = append(buf, u32[:]...)
@@ -119,6 +135,18 @@ func UnmarshalQuorumCert(data []byte) (*QuorumCert, error) {
if err = r.readIDInto(&c.Position.ParentID); err != nil {
return nil, ErrQCWireCorrupt
}
if err = r.readIDInto(&c.Position.CanonicalID); err != nil {
return nil, ErrQCWireCorrupt
}
if err = r.readIDInto(&c.Position.ParentCanonicalID); err != nil {
return nil, ErrQCWireCorrupt
}
if err = r.readIDInto(&c.Position.ExecutionStateRoot); err != nil {
return nil, ErrQCWireCorrupt
}
if err = r.readIDInto(&c.Position.PayloadRoot); err != nil {
return nil, ErrQCWireCorrupt
}
if err = r.readIDInto(&c.Position.ValidatorSetRoot); err != nil {
return nil, ErrQCWireCorrupt
}
+218
View File
@@ -0,0 +1,218 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// cert_fuzz_test.go — fuzzing for the QCv2 canonical-commitment cert.
//
// Two targets:
//
// 1. FuzzQuorumCertCodec — the wire codec. Malformed input must NEVER panic and
// must fail closed; well-formed input must round-trip (decode→encode→decode is
// stable). Guards the decode-DoS cap and the strict trailing-bytes policy.
//
// 2. FuzzFinalizeDecision — the finalize / equivocation decision function. A random
// stream of {hint, cert} operations over small pools of {height, canonical,
// envelope} ids must NEVER panic and must NEVER violate the core invariant:
// (INV) for every height H at most ONE canonical commitment is finalized, it
// is immutable once set, a hint never finalizes, and equivocation is
// raised IFF a DIFFERENT canonical is presented at a certified height.
package chain
import (
"errors"
"testing"
"github.com/luxfi/ids"
)
// FuzzQuorumCertCodec feeds arbitrary bytes to the decoder (must not panic / must
// fail closed) and, on a successful decode, asserts round-trip stability.
func FuzzQuorumCertCodec(f *testing.F) {
// Seed corpus: a couple of structurally valid certs (with canonical fields set).
seedCerts := []*QuorumCert{
{
Version: QuorumCertVersion, Type: QCFinality, Threshold: 1,
Position: VotePosition{
ChainID: ids.GenerateTestID(), Height: 7, Round: 2,
BlockID: ids.GenerateTestID(), ParentID: ids.GenerateTestID(),
CanonicalID: ids.GenerateTestID(), ParentCanonicalID: ids.GenerateTestID(),
ExecutionStateRoot: ids.GenerateTestID(), PayloadRoot: ids.GenerateTestID(),
ValidatorSetRoot: ids.GenerateTestID(),
},
Votes: []SignedVote{{NodeID: ids.GenerateTestNodeID(), Accept: true, Signature: []byte("sig-a")}},
},
{
Version: QuorumCertVersion, Type: QCFinality, Threshold: 2,
Position: VotePosition{ChainID: ids.GenerateTestID(), Height: 1, BlockID: ids.GenerateTestID()},
Votes: []SignedVote{
{NodeID: ids.GenerateTestNodeID(), Accept: true, Signature: nil},
{NodeID: ids.GenerateTestNodeID(), Accept: true, Signature: []byte{0x00, 0x01}},
},
},
}
for _, c := range seedCerts {
if b, err := c.MarshalBinary(); err == nil {
f.Add(b)
}
}
f.Add([]byte{})
f.Add([]byte{0xFF})
f.Fuzz(func(t *testing.T, data []byte) {
cert, err := UnmarshalQuorumCert(data) // must never panic
if err != nil {
if cert != nil {
t.Fatalf("decode returned an error AND a non-nil cert: %v", err)
}
return
}
// Decoded successfully → re-encoding must reproduce the SAME bytes the
// decoder accepted (canonical, deterministic codec), and a second decode must
// equal the first (round-trip stability).
reencoded, err := cert.MarshalBinary()
if err != nil {
t.Fatalf("re-marshal of a decoded cert failed: %v", err)
}
again, err := UnmarshalQuorumCert(reencoded)
if err != nil {
t.Fatalf("re-decode of a re-encoded cert failed: %v", err)
}
if !cert.Equal(again) {
t.Fatal("round-trip instability: decode→encode→decode produced a different cert")
}
// Every canonical field survived the round trip (the QCv2 additions).
if cert.Position.CanonicalID != again.Position.CanonicalID ||
cert.Position.ParentCanonicalID != again.Position.ParentCanonicalID ||
cert.Position.ExecutionStateRoot != again.Position.ExecutionStateRoot ||
cert.Position.PayloadRoot != again.Position.PayloadRoot ||
cert.Position.ValidatorSetRoot != again.Position.ValidatorSetRoot {
t.Fatal("a canonical position field did not survive the codec round trip")
}
})
}
// FuzzFinalizeDecision drives a random op stream into a fresh ChainConsensus and
// checks the finalize/equivocation invariant after every op. It never asserts a
// full prediction of the fold (parent/DAG-dependent); it asserts the safety
// invariants that must hold UNCONDITIONALLY.
func FuzzFinalizeDecision(f *testing.F) {
f.Add([]byte{0x01, 0x12, 0x23, 0x34, 0x45})
f.Add([]byte{0x00, 0x00, 0x00, 0x10, 0x20, 0x30})
f.Add([]byte{0xFF, 0xAA, 0x55, 0x0F, 0xF0})
f.Fuzz(func(t *testing.T, data []byte) {
// Small fixed pools so collisions (the bug-relevant cases: same/different
// canonical at one height) actually occur.
heights := []uint64{1, 2, 3, 4}
canon := []ids.ID{ids.Empty, idFromByte(0xC1), idFromByte(0xC2), idFromByte(0xC3)}
outer := []ids.ID{idFromByte(0x01), idFromByte(0x02), idFromByte(0x03), idFromByte(0x04)}
c := NewChainConsensus(5, 4, 1)
// model[h] = the EFFECTIVE canonical certified at height h (immutable once set).
model := map[uint64]ids.ID{}
for _, op := range data {
h := heights[(op>>1)&0x3]
cn := canon[(op>>3)&0x3]
ot := outer[(op>>5)&0x3]
effCanon := cn
if effCanon == ids.Empty {
effCanon = ot // the fold's Empty-canonical fallback (outer == inner)
}
if op&0x1 == 0 {
// HINT op (SyncState). Snapshot the certified set, apply, assert the
// certified set is UNCHANGED (a hint can never finalize).
before := snapshotCertified(c, heights)
if err := c.SyncState(ot, h); err != nil {
// Only the documented refusals are allowed; never a panic, never a
// silent finalize.
if !errors.Is(err, ErrSyncStateRegression) &&
!errors.Is(err, ErrSyncStateEmptyWithHeight) {
t.Fatalf("SyncState returned an unexpected error: %v", err)
}
}
after := snapshotCertified(c, heights)
if !sameCertified(before, after) {
t.Fatal("INV violated: a hint (SyncState) changed the certified finality set")
}
continue
}
// CERT op (ApplyCert). Parent left Empty: a fresh seed at any height, a
// duplicate/equivocation at an already-certified height, or a defer.
_, err := c.ApplyCert(Cert{Block: ot, Parent: ids.Empty, Height: h, Canonical: cn})
switch {
case err == nil:
// A successful finalize must have recorded EXACTLY this canonical at h.
got, ok := c.FinalizedBlockAtHeight(h)
if !ok || got != effCanon {
t.Fatalf("INV violated: nil-err finalize did not record canonical at %d: got (%s,%v) want %s", h, got, ok, effCanon)
}
if prev, seen := model[h]; seen && prev != effCanon {
t.Fatalf("INV violated: height %d canonical changed %s -> %s (must be immutable)", h, prev, effCanon)
}
model[h] = effCanon
case errors.Is(err, ErrHeightAlreadyFinalized):
// Equivocation is raised IFF a DIFFERENT canonical is already certified
// at this height.
got, ok := c.FinalizedBlockAtHeight(h)
if !ok {
t.Fatalf("INV violated: equivocation at %d but nothing finalized there", h)
}
if got == effCanon {
t.Fatalf("INV violated: equivocation raised for the SAME canonical %s at %d (must be a no-op)", effCanon, h)
}
default:
// Any other error is a benign refusal (monotonic / conflict / behind).
// It must NOT have changed the certified set for h.
if got, ok := c.FinalizedBlockAtHeight(h); ok {
if prev, seen := model[h]; seen && got != prev {
t.Fatalf("INV violated: refused cert (%v) mutated certified canonical at %d", err, h)
}
}
}
// Global immutability check: every modeled height still maps to its
// recorded canonical (certs never rewrite finalized history).
for mh, mc := range model {
if got, ok := c.FinalizedBlockAtHeight(mh); ok && got != mc {
t.Fatalf("INV violated: finalized canonical at %d mutated to %s (was %s)", mh, got, mc)
}
}
}
})
}
// idFromByte builds a deterministic non-empty id from a single byte (a stable pool
// element for the fuzzer).
func idFromByte(b byte) ids.ID {
var id ids.ID
id[0] = b
id[1] = 0x5A
return id
}
// snapshotCertified records the certified canonical at each candidate height.
func snapshotCertified(c *ChainConsensus, heights []uint64) map[uint64]ids.ID {
out := map[uint64]ids.ID{}
for _, h := range heights {
if id, ok := c.FinalizedBlockAtHeight(h); ok {
out[h] = id
}
}
return out
}
func sameCertified(a, b map[uint64]ids.ID) bool {
if len(a) != len(b) {
return false
}
for h, id := range a {
if b[h] != id {
return false
}
}
return true
}
+54 -32
View File
@@ -112,13 +112,16 @@ func (c *ChainConsensus) ApplyCert(cert Cert) (Plan, error) {
return c.applyCertLocked(cert)
}
// FinalizeBranch is the back-compat wrapper for call sites that hold a
// (target, height, parent) triple rather than a Cert (bootstrap/catch-up accept and the
// safety tests). It builds the Cert and folds it — behavior identical to ApplyCert.
// parentID may be ids.Empty only for the genesis / first finalize.
// FinalizeBranch finalizes a block whose CANONICAL commitment equals its OUTER id —
// the non-proposervm-wrapped convenience (canonical == outer). It is the call shape
// for the safety tests and any in-process chain where there is no inner/outer split.
// A block that DOES carry a distinct inner commitment must use ApplyCert with an
// explicit Cert.Canonical (the production cert path and bootstrap do). parentID may
// be ids.Empty only for the genesis / first finalize.
func (c *ChainConsensus) FinalizeBranch(target ids.ID, height uint64, parentID ids.ID) (Plan, error) {
c.mu.Lock()
defer c.mu.Unlock()
// Canonical left ids.Empty ⇒ the fold treats canonical == target (outer == inner).
return c.applyCertLocked(Cert{Block: target, Parent: parentID, Height: height})
}
@@ -182,7 +185,7 @@ func (c *ChainConsensus) Stats() map[string]interface{} {
"rejected": rejected,
"pending": pending,
"tips": len(c.tips),
"finalized_tip": c.ledger.tip.String(),
"finalized_tip": c.ledger.Tip().String(),
}
}
@@ -223,34 +226,38 @@ func (c *ChainConsensus) SyncState(lastAcceptedID ids.ID, height uint64) error {
return fmt.Errorf("%w: refused empty head at height %d", ErrSyncStateEmptyWithHeight, height)
}
// Refuse a backward regression of finalized history. Only enforced once a
// height is established and for a concrete (non-empty) import head; an empty
// reset is a deliberate teardown, not a regression.
// 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
// 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)
}
// Equal-height re-import must agree on the block (a different block at the
// already-finalized height is equivocation, not a reconcile).
if c.ledger.set && lastAcceptedID != ids.Empty && height == c.ledger.height {
if existing, ok := c.ledger.At(height); ok && existing != lastAcceptedID {
return fmt.Errorf("%w: import height %d already finalized %s, refused %s",
ErrHeightAlreadyFinalized, height, existing, 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.
// Seed a NEW ledger value at the synced head so the next finalize (the first block
// built on the import) satisfies the monotonic-height + descends-from-tip invariants
// against the imported head. A whole-value assignment — not a field poke.
// 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.
if lastAcceptedID != ids.Empty {
c.ledger = seedLedger(lastAcceptedID, height)
// 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
// history exists, this is just the hint anchor.
c.ledger = c.ledger.withHint(lastAcceptedID, height)
} else {
// genesis/teardown reset (empty head at height 0): reset finality to a CLEAN
// genesis VALUE — Empty tip, height 0, unset, no per-height record. LOW-2: the
// prior code cleared ONLY the tip and left height/byHeight/set stale, which
// desynced the tip from the height — every future cert then wedged in pathFromTip
// (it seeks the now-Empty tip and never finds it). A whole-value reset cannot
// desync: the next cert simply seeds finality afresh (first-finalize).
// genesis VALUE — no certified state, no hint, no per-height record. A whole-
// value reset cannot desync: the next cert simply seeds finality afresh.
c.ledger = FinalityLedger{}
}
@@ -272,12 +279,26 @@ func (c *ChainConsensus) SyncState(lastAcceptedID ids.ID, height uint64) error {
return nil
}
// GetFinalizedTip returns the current finalized tip block ID.
// This is useful for debugging and health checks.
// GetFinalizedTip returns the BUILD ANCHOR — the certified finalized tip (outer
// id) once a cert has finalized, else the recovery hint (vm.LastAccepted). This is
// the "what is the VM building on" view used by health checks and preference; it is
// NOT the finality-authority height (that is GetFinalizedHeight, certified-only).
func (c *ChainConsensus) GetFinalizedTip() ids.ID {
c.mu.RLock()
defer c.mu.RUnlock()
return c.ledger.tip
if anchor, ok := c.ledger.BuildAnchor(); ok {
return anchor
}
return ids.Empty
}
// GetCertifiedTip returns the canonical execution commitment of the certified head
// (ids.Empty before the first cert) — the true finality identity, used for
// equivocation evidence and conformance assertions.
func (c *ChainConsensus) GetCertifiedTip() ids.ID {
c.mu.RLock()
defer c.mu.RUnlock()
return c.ledger.CanonicalTip()
}
// GetFinalizedHeight returns the current finalized height and whether any block
@@ -290,10 +311,11 @@ func (c *ChainConsensus) GetFinalizedHeight() (uint64, bool) {
return c.ledger.Height()
}
// FinalizedBlockAtHeight returns the block finalized at the given height, if
// any. Used to produce equivocation evidence: when a second cert is refused at
// an already-finalized height, the caller reports BOTH the finalized block and
// the refused one.
// FinalizedBlockAtHeight returns the CANONICAL execution commitment finalized at
// the given height, if any. Used to produce equivocation evidence and to recognise
// duplicates: a second cert at an already-finalized height is equivocation ONLY if
// its canonical id differs from this one (a different envelope wrapping the same
// canonical id is a harmless alias).
func (c *ChainConsensus) FinalizedBlockAtHeight(height uint64) (ids.ID, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
+79 -11
View File
@@ -1794,6 +1794,55 @@ func pChainHeightOf(b block.Block) uint64 {
// function the engine uses. Exported only for that cross-module test reach.
func PChainHeightOfForTest(b block.Block) uint64 { return pChainHeightOf(b) }
// canonicalCommitter is the OPTIONAL block interface that exposes the inner
// EXECUTION commitment (the incident-1082814 canonical identity), plumbed up from
// the proposervm wrapper. A proposervm signed block that wraps an inner execution
// block implements it; a bare/in-process VM block does not. Defined locally so the
// engine depends only on the four methods it reads.
//
// THE CONTRACT: CanonicalID is the inner execution block id — the value finality is
// defined over. Two outer proposervm envelopes wrapping the SAME inner block return
// the SAME CanonicalID, which is exactly what collapses them to duplicates instead
// of forks. ParentCanonicalID / ExecutionStateRoot / PayloadRoot bind the canonical
// ancestry and the exact execution result into the signed cert.
type canonicalCommitter interface {
CanonicalID() ids.ID
ParentCanonicalID() ids.ID
ExecutionStateRoot() ids.ID
PayloadRoot() ids.ID
}
// canonicalIDOf returns the block's inner EXECUTION commitment, or — for a block
// that does not expose one (bare/in-process VM, no proposervm wrapping at the engine
// boundary) — the block's OWN outer id. The fallback makes the scheme degrade
// EXACTLY to the pre-fix outer-id behavior on a non-wrapped chain (canonical ==
// outer ⇒ no two envelopes can share a canonical id ⇒ the duplicate-alias path is
// simply inert), while a real proposervm delivers the distinct inner id that
// distinguishes a duplicate envelope from a genuine fork. This is the SOLE place the
// engine reads the canonical id off a VM block.
func canonicalIDOf(b block.Block) ids.ID {
if c, ok := b.(canonicalCommitter); ok {
if id := c.CanonicalID(); id != ids.Empty {
return id
}
}
return b.ID()
}
// setCanonicalFromVM stamps the canonical execution-commitment fields onto a
// consensus Block from its VM block — the ONE boundary where the inner commitment
// enters consensus state. For a non-wrapped block canonicalID == outer id and the
// roots are Empty (unbound), so the position/cert are byte-compatible with the
// pre-canonical behavior. Called at every Block construction site (DRY).
func setCanonicalFromVM(cb *Block, vmBlock block.Block) {
cb.canonicalID = canonicalIDOf(vmBlock)
if c, ok := vmBlock.(canonicalCommitter); ok {
cb.parentCanonicalID = c.ParentCanonicalID()
cb.execStateRoot = c.ExecutionStateRoot()
cb.payloadRoot = c.PayloadRoot()
}
}
// epochHeightLocked returns the P-CHAIN height the block's weighted validator set
// is pinned to — the SINGLE height used for the set-root commitment, the
// ⅔-by-stake tally, AND per-voter pubkey resolution (membership, pubkey,
@@ -1811,10 +1860,20 @@ func (t *Transitive) epochHeightLocked(pending *PendingBlock) uint64 {
func (t *Transitive) blockPositionLocked(pending *PendingBlock, blockID ids.ID) VotePosition {
var parentID ids.ID
var height uint64
var canonicalID, parentCanonicalID, execStateRoot, payloadRoot ids.ID
if pending.ConsensusBlock != nil {
parentID = pending.ConsensusBlock.parentID
height = pending.ConsensusBlock.height
canonicalID = pending.ConsensusBlock.canonicalID
parentCanonicalID = pending.ConsensusBlock.parentCanonicalID
execStateRoot = pending.ConsensusBlock.execStateRoot
payloadRoot = pending.ConsensusBlock.payloadRoot
}
// canonicalID/parentCanonicalID are left as the block's RAW canonical fields: the
// real inner id for a proposervm-wrapped block, or ids.Empty for a bare block. The
// non-wrapped degrade (Empty ⇒ bind the outer id) is resolved in ONE place,
// canonicalVoteMessageFor, so the signed bytes are identical for every producer of
// a position (engine or test), for the same block.
// Stamp the active weighted-validator-set commitment at the block's P-CHAIN
// EPOCH height (the MEDIUM-1 / CRITICAL-1 fix) — NOT its value-chain height.
// Every path that builds a position — sign (recordOwnVoteLocked), assemble +
@@ -1830,12 +1889,16 @@ func (t *Transitive) blockPositionLocked(pending *PendingBlock, blockID ids.ID)
setRoot = t.setRootSource.ValidatorSetRoot(t.epochHeightLocked(pending))
}
return VotePosition{
ChainID: t.chainID,
Height: height,
Round: pending.Round,
BlockID: blockID,
ParentID: parentID,
ValidatorSetRoot: setRoot,
ChainID: t.chainID,
Height: height,
Round: pending.Round,
BlockID: blockID,
ParentID: parentID,
CanonicalID: canonicalID,
ParentCanonicalID: parentCanonicalID,
ExecutionStateRoot: execStateRoot,
PayloadRoot: payloadRoot,
ValidatorSetRoot: setRoot,
}
}
@@ -1869,6 +1932,7 @@ func (t *Transitive) TrackOwnProposalForTest(ctx context.Context, blk block.Bloc
data: blk.Bytes(),
pChainHeight: pChainHeightOf(blk), // the boundary capture under test (b2)
}
setCanonicalFromVM(cb, blk) // stamp the inner execution commitment
_ = t.consensus.AddBlock(ctx, cb)
_ = t.consensus.ProcessVote(ctx, blk.ID(), true)
t.mu.Lock()
@@ -2175,14 +2239,17 @@ func (t *Transitive) acceptWithCertCore(ctx context.Context, blockID ids.ID, cer
// Called WITHOUT t.mu so the consensus lock is never nested under it.
pos := cert.Cert().Position
// LOW-1 (defense-in-depth): the cert α-attests the FULL position {BlockID, ParentID,
// Height}; build the finalize Cert ENTIRELY from it so the fold can never be fed a
// Block from one source and Parent/Height from another. blockID is only the pending
// lookup key above — a verified cert's position must name that same block, else the
// trio is inconsistent and we fail closed rather than finalize the wrong block.
// Height, CanonicalID}; build the finalize Cert ENTIRELY from it so the fold can
// never be fed a Block from one source and Parent/Height/Canonical from another.
// blockID is only the pending lookup key above — a verified cert's position must
// name that same outer block, else the trio is inconsistent and we fail closed
// rather than finalize the wrong block. (When a cert is resolved by CANONICAL id
// across a differing envelope, HandleIncomingCert finalizes the LOCAL outer id and
// the cert's canonical, so this equality holds on the resolved target.)
if pos.BlockID != blockID {
return fmt.Errorf("cert position block %s != finalize target %s (inconsistent cert trio)", pos.BlockID, blockID)
}
plan, err := t.consensus.ApplyCert(Cert{Block: pos.BlockID, Parent: pos.ParentID, Height: pos.Height})
plan, err := t.consensus.ApplyCert(Cert{Block: pos.BlockID, Parent: pos.ParentID, Height: pos.Height, Canonical: pos.CanonicalID})
if err != nil {
return err
}
@@ -2368,6 +2435,7 @@ func (t *Transitive) buildBlocksLocked(ctx context.Context) error {
data: vmBlock.Bytes(),
pChainHeight: pChainHeightOf(vmBlock), // epoch for the weighted set (MEDIUM-1)
}
setCanonicalFromVM(consensusBlock, vmBlock) // stamp the inner execution commitment
// Verify BEFORE consensus — prevents accepting invalid blocks in K=1 mode
// where self-vote causes immediate acceptance. If Verify fails, the block
+537
View File
@@ -0,0 +1,537 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// incident_1082814_test.go — the DURABLE regression suite for the
// finality-equivocation bug class (mainnet incident 1082814).
//
// ROOT CAUSE (in the engine's terms): finality was defined over the OUTER
// proposervm envelope id, and the finality ledger was SEEDED from vm.LastAccepted
// on boot. Two outer envelopes (A=2U2pR3D, B=wDMUyGy) wrapped the SAME inner
// execution block (5DEgMudU / 0x098fbedb). The boot seed installed A as finalized
// at height H; a peer's legitimate cert for B at H then looked like a SECOND
// finalized block at one height → fatal EQUIVOCATION → 43h halt. A and B were
// DUPLICATES (one inner block, two envelopes), never a fork.
//
// THE FIX, proven here:
// PART A — vm.LastAccepted is a NON-AUTHORITATIVE recovery HINT, never finality.
// finalizedTip/height advance ONLY on a verified QC (or bootstrap
// frontier-trust). A locally-seeded sibling can never equivocate.
// PART B — finality is defined over the CANONICAL execution commitment, not the
// outer envelope. Two certs at one height conflict IFF their
// canonical_block_id differs. Same canonical / different envelope =
// duplicate alias.
//
// THE INVARIANT (encoded + asserted): for every height H, at most ONE canonical
// execution commitment may be finalized; all envelopes / VM aliases / seed states
// are non-authoritative unless backed by a QC over that commitment.
//
// The pre-fix code FAILED rows 1/3/5/6 (a seeded or duplicate sibling fired
// equivocation). The pre-fix capture is in the task report (it returned
// ErrHeightAlreadyFinalized for the seed-vs-cert case). Every row below PASSES on
// the fix.
package chain
import (
"bytes"
"context"
"errors"
"testing"
"time"
"github.com/luxfi/consensus/core/slashing"
"github.com/luxfi/ids"
"github.com/luxfi/log"
)
// isErr is errors.Is, named locally for terse matrix assertions.
func isErr(err, target error) bool { return errors.Is(err, target) }
// canonicalCert assembles a real Ed25519-signed α-of-K cert whose position binds a
// CANONICAL commitment (innerID/parentInner) distinct from the OUTER envelope
// (outerID/parentOuter). n validators sign. This is the attacker's / network's
// two-identities primitive: the inner id is what finality means; the outer id is
// transport only.
func canonicalCert(t *testing.T, vs *testValidatorSet, chainID, outerID, parentOuter, innerID, parentInner ids.ID, height uint64, round uint32, n int) []byte {
t.Helper()
pos := VotePosition{
ChainID: chainID,
Height: height,
Round: round,
BlockID: outerID,
ParentID: parentOuter,
CanonicalID: innerID,
ParentCanonicalID: parentInner,
}
votes := make([]SignedVote, 0, n)
for i := 0; i < n; i++ {
votes = append(votes, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: vs.sign(i, pos)})
}
cert, err := AssembleQuorumCert(pos, uint32(n), votes)
if err != nil {
t.Fatalf("assemble canonical cert: %v", err)
}
b, err := cert.MarshalBinary()
if err != nil {
t.Fatalf("marshal canonical cert: %v", err)
}
return b
}
// trackEnvelope inserts a verified pending block under its OUTER id with its inner
// canonical commitment stamped — exactly what setCanonicalFromVM does at the engine
// boundary for a proposervm-wrapped block. Lets a cert over the canonical id
// finalize the locally-tracked envelope.
func trackEnvelope(rt *Runtime, outerID, parentOuter, innerID, parentInner ids.ID, height uint64, round uint32) *verifyOnceBlock {
blk := &verifyOnceBlock{id: outerID, parentID: parentOuter, height: height, timestamp: time.Now(), bytes: outerID[:]}
cb := &Block{
id: outerID,
parentID: parentOuter,
height: height,
timestamp: blk.timestamp.Unix(),
data: blk.bytes,
canonicalID: innerID,
parentCanonicalID: parentInner,
}
_ = rt.Transitive.consensus.AddBlock(context.Background(), cb)
rt.Transitive.mu.Lock()
rt.Transitive.pendingBlocks[outerID] = &PendingBlock{ConsensusBlock: cb, VMBlock: blk, ProposedAt: time.Now(), Round: round}
rt.Transitive.mu.Unlock()
return blk
}
// -----------------------------------------------------------------------------
// THE 6-ROW REGRESSION MATRIX (5 validators, quorum 4) — pure ChainConsensus.
// Each row exercises the HARD-GATE state (finalizedTip / byHeight / equivocation)
// directly through the only two writers: SyncState (hint) and ApplyCert (cert).
// -----------------------------------------------------------------------------
// Row 1 & 5: same inner + different outer + NO QC ⇒ no finality, no equivocation;
// vm.LastAccepted alone is a recovery hint, NEVER finality.
func TestMatrix_Row1and5_SeedHintNeverFinalizes(t *testing.T) {
c := NewChainConsensus(5, 4, 1)
innerC := ids.GenerateTestID()
outerA := ids.GenerateTestID() // vm.LastAccepted envelope A (wraps innerC)
_ = innerC
// Boot seed from vm.LastAccepted — a HINT.
if err := c.SyncState(outerA, 100); err != nil {
t.Fatalf("SyncState hint: %v", err)
}
// NO certified finality exists.
if h, set := c.GetFinalizedHeight(); set || h != 0 {
t.Fatalf("Row5: vm.LastAccepted created finality (%d,%v) — must be (0,false)", h, set)
}
if got := c.GetCertifiedTip(); got != ids.Empty {
t.Fatalf("Row5: certified tip non-empty from a seed: %s", got)
}
if _, ok := c.FinalizedBlockAtHeight(100); ok {
t.Fatal("Row1: a seed wrote a certified per-height entry (it must not)")
}
// The hint IS a build anchor (so the VM can build), but that is not finality.
if got := c.GetFinalizedTip(); got != outerA {
t.Fatalf("Row1: build anchor should be the hint %s, got %s", outerA, got)
}
}
// Row 2: same inner + different outer + ONE QC ⇒ canonical finalize.
func TestMatrix_Row2_OneCertCanonicalFinalize(t *testing.T) {
c := NewChainConsensus(5, 4, 1)
innerC := ids.GenerateTestID()
outerA := ids.GenerateTestID()
// A seed hint at the same height first (the boot snapshot) — must not interfere.
if err := c.SyncState(outerA, 100); err != nil {
t.Fatalf("seed: %v", err)
}
// ONE valid cert finalizes the canonical block (envelope outerA, inner innerC).
if _, err := c.ApplyCert(Cert{Block: outerA, Parent: ids.Empty, Height: 100, Canonical: innerC}); err != nil {
t.Fatalf("Row2: one cert must finalize, got %v", err)
}
if h, set := c.GetFinalizedHeight(); !set || h != 100 {
t.Fatalf("Row2: certified height (%d,%v) want (100,true)", h, set)
}
if got := c.GetCertifiedTip(); got != innerC {
t.Fatalf("Row2: certified canonical tip %s want innerC %s", got, innerC)
}
if fin, ok := c.FinalizedBlockAtHeight(100); !ok || fin != innerC {
t.Fatalf("Row2: finalized canonical at 100 = (%s,%v) want innerC", fin, ok)
}
}
// Row 3: same inner + different outer + TWO QCs ⇒ DUPLICATE certs, NOT a fork.
// THE headline fix: the pre-fix code fired equivocation here (two different outer
// ids at one height); the fix recognises the shared canonical id as a duplicate.
func TestMatrix_Row3_TwoCertsSameInnerAreDuplicates(t *testing.T) {
c := NewChainConsensus(5, 4, 1)
innerC := ids.GenerateTestID() // ONE inner execution block (5DEgMudU)
outerA := ids.GenerateTestID() // envelope A (2U2pR3D)
outerB := ids.GenerateTestID() // envelope B (wDMUyGy) — SAME inner
// Cert over envelope A finalizes canonical innerC.
if _, err := c.ApplyCert(Cert{Block: outerA, Parent: ids.Empty, Height: 100, Canonical: innerC}); err != nil {
t.Fatalf("Row3: cert A must finalize, got %v", err)
}
// Cert over envelope B (DIFFERENT outer, SAME inner) at the SAME height — a
// duplicate alias, an IDEMPOTENT no-op, NEVER ErrHeightAlreadyFinalized.
plan, err := c.ApplyCert(Cert{Block: outerB, Parent: ids.Empty, Height: 100, Canonical: innerC})
if err != nil {
t.Fatalf("Row3: cert B (same inner, different envelope) must be a duplicate no-op, got %v", err)
}
if len(plan.Accept) != 0 || len(plan.Reject) != 0 {
t.Fatalf("Row3: duplicate cert produced a non-empty plan %+v", plan)
}
// Exactly one canonical block is final at 100, and it is innerC.
if fin, ok := c.FinalizedBlockAtHeight(100); !ok || fin != innerC {
t.Fatalf("Row3: finalized canonical changed: (%s,%v) want innerC", fin, ok)
}
if h, _ := c.GetFinalizedHeight(); h != 100 {
t.Fatalf("Row3: height moved on a duplicate, now %d", h)
}
}
// Row 4: different inner + same height + TWO QCs ⇒ fork evidence / equivocation.
func TestMatrix_Row4_TwoCertsDifferentInnerIsFork(t *testing.T) {
c := NewChainConsensus(5, 4, 1)
innerC := ids.GenerateTestID()
innerD := ids.GenerateTestID() // a GENUINELY different execution block
outerA := ids.GenerateTestID()
outerB := ids.GenerateTestID()
if _, err := c.ApplyCert(Cert{Block: outerA, Parent: ids.Empty, Height: 100, Canonical: innerC}); err != nil {
t.Fatalf("Row4: cert C must finalize, got %v", err)
}
// A second cert for a DIFFERENT canonical block at the same height is the real
// fork — refused as equivocation evidence.
_, err := c.ApplyCert(Cert{Block: outerB, Parent: ids.Empty, Height: 100, Canonical: innerD})
if err == nil {
t.Fatal("Row4: a different canonical block at a finalized height must be refused (equivocation)")
}
if !isErr(err, ErrHeightAlreadyFinalized) {
t.Fatalf("Row4: want ErrHeightAlreadyFinalized, got %v", err)
}
// Finalized canonical is unchanged (the first one wins; safety holds).
if fin, ok := c.FinalizedBlockAtHeight(100); !ok || fin != innerC {
t.Fatalf("Row4: finalized canonical changed on a refused fork: (%s,%v) want innerC", fin, ok)
}
}
// Row 6: restart at 3/4 or 5/5 ⇒ converge, NO crashloop. The exact incident: a
// node restarts, SyncStates its vm.LastAccepted envelope A (a HINT) at height H,
// then the network's legitimate cert for the SAME inner block under envelope B
// arrives. The pre-fix code halted (seeded A vs cert B = "two blocks at H"). The
// fix finalizes the canonical block with NO equivocation, NO crash — convergence.
func TestMatrix_Row6_RestartFromHintConvergesNoCrashloop(t *testing.T) {
innerC := ids.GenerateTestID()
outerA := ids.GenerateTestID() // local boot snapshot envelope (vm.LastAccepted)
outerB := ids.GenerateTestID() // the envelope the network actually certified
// Simulate the restart for several quorum shapes — all must converge, none halt.
for _, restartHeight := range []uint64{1082814} {
c := NewChainConsensus(5, 4, 1)
// 1) Boot: seed from vm.LastAccepted (envelope A) — a non-authoritative hint.
if err := c.SyncState(outerA, restartHeight); err != nil {
t.Fatalf("restart seed: %v", err)
}
// The hint did NOT finalize anything (no equivocation surface).
if _, set := c.GetFinalizedHeight(); set {
t.Fatal("Row6: restart seed created certified finality (the bug)")
}
// 2) The network's legitimate cert for the SAME inner block under envelope B
// arrives at the SAME height. Pre-fix: fatal equivocation. Fix: finalizes.
if _, err := c.ApplyCert(Cert{Block: outerB, Parent: ids.Empty, Height: restartHeight, Canonical: innerC}); err != nil {
t.Fatalf("Row6: cert for the canonical block must finalize after restart, got %v", err)
}
// Converged to the canonical block; no halt.
if fin, ok := c.FinalizedBlockAtHeight(restartHeight); !ok || fin != innerC {
t.Fatalf("Row6: did not converge to canonical innerC: (%s,%v)", fin, ok)
}
// 3) The original local envelope A's own cert later — also a duplicate, no halt.
if _, err := c.ApplyCert(Cert{Block: outerA, Parent: ids.Empty, Height: restartHeight, Canonical: innerC}); err != nil {
t.Fatalf("Row6: envelope A cert (same inner) must be a duplicate no-op, got %v", err)
}
}
}
// -----------------------------------------------------------------------------
// THE INVARIANT, encoded as a test: at most ONE canonical commitment per height,
// and seeds/hints are non-authoritative.
// -----------------------------------------------------------------------------
// TestInvariant_AtMostOneCanonicalPerHeight_AndHintsNonAuthoritative drives a
// height through (hint A) → (cert canonical C) → (duplicate envelope B, same C) →
// (fork attempt different D) and asserts the canonical at H is C throughout, and
// that ONLY the cert (never the hint) is authoritative.
func TestInvariant_AtMostOneCanonicalPerHeight_AndHintsNonAuthoritative(t *testing.T) {
c := NewChainConsensus(5, 4, 1)
const H = 500
innerC := ids.GenerateTestID()
innerD := ids.GenerateTestID()
outerA := ids.GenerateTestID()
outerB := ids.GenerateTestID()
// (hint) — non-authoritative.
mustNoErr(t, c.SyncState(outerA, H))
if _, set := c.GetFinalizedHeight(); set {
t.Fatal("invariant: a hint became authoritative")
}
// (cert canonical C) — the ONE authoritative finalize.
if _, err := c.ApplyCert(Cert{Block: outerA, Height: H, Canonical: innerC}); err != nil {
t.Fatalf("invariant: canonical cert must finalize: %v", err)
}
assertCanonicalAt(t, c, H, innerC)
// (duplicate envelope B, same C) — no change.
if _, err := c.ApplyCert(Cert{Block: outerB, Height: H, Canonical: innerC}); err != nil {
t.Fatalf("invariant: duplicate must be a no-op: %v", err)
}
assertCanonicalAt(t, c, H, innerC)
// (fork attempt different D) — refused, canonical unchanged.
if _, err := c.ApplyCert(Cert{Block: outerB, Height: H, Canonical: innerD}); !isErr(err, ErrHeightAlreadyFinalized) {
t.Fatalf("invariant: a different canonical must be refused, got %v", err)
}
assertCanonicalAt(t, c, H, innerC)
}
// -----------------------------------------------------------------------------
// E2E through HandleIncomingCert (real Ed25519 certs + slashing DB) — proves the
// duplicate/fork distinction at the network-receive boundary, including the
// equivocation-EVIDENCE behavior (the path that previously called Logger.Crit →
// os.Exit). With a Noop logger the Crit is skipped, so we observe the slashing DB.
// -----------------------------------------------------------------------------
// TestE2E_DuplicateEnvelopeCertIsHarmless_NoSlashing: two real certs at one height
// with DIFFERENT outer ids but the SAME canonical id. The first finalizes; the
// second is recognised as a duplicate and dropped with NO equivocation evidence
// and NO halt. (Contrast the existing TestCriticalFork_TwoCertsOneHeightAcrossRounds,
// which uses DIFFERENT canonical ids and DOES record evidence.)
func TestE2E_DuplicateEnvelopeCertIsHarmless_NoSlashing(t *testing.T) {
vs := newTestValidatorSet(5)
chainID := ids.GenerateTestID()
db := slashing.NewDB(time.Hour)
follower := NewWithConfig(Config{Params: params5()},
WithQuorumCert(chainID, vs.nodeID(4), vs, &recordingGossiper{}, vs.signerFor(4)),
WithSlashing(slashing.NewDetector(64, 0.5), db))
if err := follower.Start(context.Background(), true); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { _ = follower.Stop(context.Background()) })
rt := &Runtime{Transitive: follower, config: NetworkConfig{ChainID: chainID, Logger: log.Noop()}}
innerC := ids.GenerateTestID()
outerA := ids.GenerateTestID()
outerB := ids.GenerateTestID()
// Track both envelopes (both wrap the same inner block innerC) at height 1.
blkA := trackEnvelope(rt, outerA, ids.Empty, innerC, ids.Empty, 1, 0)
blkB := trackEnvelope(rt, outerB, ids.Empty, innerC, ids.Empty, 1, 7)
certA := canonicalCert(t, vs, chainID, outerA, ids.Empty, innerC, ids.Empty, 1, 0, 4)
certB := canonicalCert(t, vs, chainID, outerB, ids.Empty, innerC, ids.Empty, 1, 7, 4)
// First cert finalizes the canonical block via envelope A.
if !rt.HandleIncomingCert(certA) {
t.Fatal("first canonical cert must finalize")
}
if blkA.AcceptCalled() != 1 {
t.Fatalf("envelope A must VM.Accept once, got %d", blkA.AcceptCalled())
}
// Second cert (different envelope, SAME inner) — a DUPLICATE. It does NOT
// re-finalize (returns false) but MUST NOT record equivocation and MUST NOT halt.
if rt.HandleIncomingCert(certB) {
t.Fatal("duplicate-envelope cert must not re-finalize (it is an alias)")
}
if blkB.AcceptCalled() != 0 {
t.Fatalf("duplicate envelope B must not VM.Accept (got %d)", blkB.AcceptCalled())
}
// THE CORE ASSERTION: no equivocation evidence for a duplicate.
if recs := db.GetAllRecords(); len(recs) != 0 {
t.Fatalf("duplicate envelope wrongly recorded %d equivocation record(s) — the 1082814 false positive", len(recs))
}
// Finalized canonical at height 1 is innerC.
if fin, ok := follower.consensus.FinalizedBlockAtHeight(1); !ok || fin != innerC {
t.Fatalf("height 1 canonical = (%s,%v) want innerC", fin, ok)
}
}
// TestE2E_GenuineForkCertStillSlashes: the SAFETY backstop is intact — two real
// certs at one height with DIFFERENT canonical ids DO record equivocation evidence
// (the fix narrows equivocation to canonical conflicts; it does not remove it).
func TestE2E_GenuineForkCertStillSlashes(t *testing.T) {
vs := newTestValidatorSet(5)
chainID := ids.GenerateTestID()
db := slashing.NewDB(time.Hour)
follower := NewWithConfig(Config{Params: params5()},
WithQuorumCert(chainID, vs.nodeID(4), vs, &recordingGossiper{}, vs.signerFor(4)),
WithSlashing(slashing.NewDetector(64, 0.5), db))
if err := follower.Start(context.Background(), true); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { _ = follower.Stop(context.Background()) })
rt := &Runtime{Transitive: follower, config: NetworkConfig{ChainID: chainID, Logger: log.Noop()}}
innerC := ids.GenerateTestID()
innerD := ids.GenerateTestID() // genuinely different execution block
outerA := ids.GenerateTestID()
outerB := ids.GenerateTestID()
trackEnvelope(rt, outerA, ids.Empty, innerC, ids.Empty, 1, 0)
trackEnvelope(rt, outerB, ids.Empty, innerD, ids.Empty, 1, 7)
certA := canonicalCert(t, vs, chainID, outerA, ids.Empty, innerC, ids.Empty, 1, 0, 4)
certD := canonicalCert(t, vs, chainID, outerB, ids.Empty, innerD, ids.Empty, 1, 7, 4)
if !rt.HandleIncomingCert(certA) {
t.Fatal("first cert must finalize")
}
if rt.HandleIncomingCert(certD) {
t.Fatal("a DIFFERENT canonical block at a finalized height must be refused")
}
// Genuine fork → equivocation evidence recorded for the conflicting voters.
if recs := db.GetAllRecords(); len(recs) == 0 {
t.Fatal("a genuine canonical fork must still record equivocation evidence (safety backstop)")
}
}
// TestE2E_ForgedConflictingCertSlashesNobody is the H1 regression (RED finding): a
// forged cert at an already-finalized height, with a DIFFERENT canonical id and
// JUNK signatures naming honest validators, must record ZERO equivocation evidence
// and jail NOBODY. The pre-fix code recorded a DoubleVote per named voter on the
// height-gate branch BEFORE verifying a single signature — so an attacker could jail
// honest validators below quorum and re-halt the chain. Evidence now traces to a
// VERIFIED QC: verifyCert runs before reportCertEquivocation.
func TestE2E_ForgedConflictingCertSlashesNobody(t *testing.T) {
vs := newTestValidatorSet(5)
chainID := ids.GenerateTestID()
db := slashing.NewDB(time.Hour)
follower := NewWithConfig(Config{Params: params5()},
WithQuorumCert(chainID, vs.nodeID(4), vs, &recordingGossiper{}, vs.signerFor(4)),
WithSlashing(slashing.NewDetector(64, 0.5), db))
if err := follower.Start(context.Background(), true); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { _ = follower.Stop(context.Background()) })
rt := &Runtime{Transitive: follower, config: NetworkConfig{ChainID: chainID, Logger: log.Noop()}}
// Genuinely finalize a canonical block at height 1 via a REAL 4-of-5 cert.
innerC := ids.GenerateTestID()
outerA := ids.GenerateTestID()
trackEnvelope(rt, outerA, ids.Empty, innerC, ids.Empty, 1, 0)
if !rt.HandleIncomingCert(canonicalCert(t, vs, chainID, outerA, ids.Empty, innerC, ids.Empty, 1, 0, 4)) {
t.Fatal("setup: real cert must finalize height 1")
}
// FORGE a conflicting cert: height 1 (already finalized), a DIFFERENT canonical
// id, threshold 4, votes naming 4 honest validators with JUNK signatures.
innerEvil := ids.GenerateTestID()
outerEvil := ids.GenerateTestID()
forgedPos := canonPos(chainID, outerEvil, ids.Empty, innerEvil, ids.Empty, 1, 9)
junk := make([]SignedVote, 0, 4)
for i := 0; i < 4; i++ {
junk = append(junk, SignedVote{
NodeID: vs.nodeID(i), // honest validator id…
Accept: true, // …with a forged signature
Signature: bytes.Repeat([]byte{byte(i + 1)}, 64),
})
}
forged, err := AssembleQuorumCert(forgedPos, 4, junk)
if err != nil {
t.Fatalf("assemble forged cert: %v", err)
}
forgedBytes, _ := forged.MarshalBinary()
// Feed it. It must be REFUSED and slash NOBODY.
if rt.HandleIncomingCert(forgedBytes) {
t.Fatal("a forged conflicting cert must not finalize")
}
if recs := db.GetAllRecords(); len(recs) != 0 {
t.Fatalf("H1: forged junk-sig cert recorded %d slashing record(s) — honest validators false-slashed", len(recs))
}
for i := 0; i < 4; i++ {
if db.IsJailed(vs.nodeID(i)) {
t.Fatalf("H1: honest validator %d was jailed by a forged cert (would drop below quorum and re-halt)", i)
}
}
// The real finalized canonical is untouched.
if fin, ok := follower.consensus.FinalizedBlockAtHeight(1); !ok || fin != innerC {
t.Fatalf("finalized canonical at 1 = (%s,%v) want innerC", fin, ok)
}
}
// TestE2E_DeferredValidCertTriggersFetch is the M1 regression: a VERIFIED finality
// proof for a block we do NOT track (an eclipsed follower that adopted a losing
// sibling envelope) must trigger a best-effort, throttled catch-up FETCH for the
// certified block — not be silently dropped (which stalls finalization forever under
// a targeted eclipse). The fetch is gated on verification, so a forged cert can
// never make us fetch arbitrary ids.
func TestE2E_DeferredValidCertTriggersFetch(t *testing.T) {
vs := newTestValidatorSet(5)
cu := &recordingCatchup{}
e, chainID := newQuorumEngineOpts(t, params5(), vs, 4, &recordingGossiper{}, WithCatchup(cu))
rt := &Runtime{Transitive: e, config: NetworkConfig{ChainID: chainID, Logger: log.Noop(), Catchup: cu}}
// A VALID 4-of-5 cert for a block the follower does NOT track.
innerWin := ids.GenerateTestID()
outerWin := ids.GenerateTestID()
cert := canonicalCert(t, vs, chainID, outerWin, ids.Empty, innerWin, ids.Empty, 1, 0, 4)
if rt.HandleIncomingCert(cert) {
t.Fatal("a cert for an untracked block must not finalize (we have not validated the block)")
}
// …but it must have triggered a fetch for the certified (winning) block.
if cu.count() == 0 {
t.Fatal("M1: a verified deferred cert was dropped without fetching the certified block")
}
if got, ok := cu.lastMissing(); !ok || got != outerWin {
t.Fatalf("M1: fetch targeted %s, want the certified block %s", got, outerWin)
}
// SAFETY: a FORGED cert (junk sigs) for an untracked block must NOT trigger a
// fetch (no fetch-amplification on unverified ids).
before := cu.count()
badPos := canonPos(chainID, ids.GenerateTestID(), ids.Empty, ids.GenerateTestID(), ids.Empty, 2, 0)
bad := make([]SignedVote, 0, 4)
for i := 0; i < 4; i++ {
bad = append(bad, SignedVote{NodeID: vs.nodeID(i), Accept: true, Signature: bytes.Repeat([]byte{0xEE}, 64)})
}
badCert, _ := AssembleQuorumCert(badPos, 4, bad)
badBytes, _ := badCert.MarshalBinary()
if rt.HandleIncomingCert(badBytes) {
t.Fatal("a forged cert must not finalize")
}
if cu.count() != before {
t.Fatal("M1: a FORGED cert triggered a fetch — verification must gate the fetch (no amplification)")
}
}
// -----------------------------------------------------------------------------
// small test helpers
// -----------------------------------------------------------------------------
func assertCanonicalAt(t *testing.T, c *ChainConsensus, h uint64, want ids.ID) {
t.Helper()
got, ok := c.FinalizedBlockAtHeight(h)
if !ok || got != want {
t.Fatalf("canonical at height %d = (%s,%v) want %s", h, got, ok, want)
}
}
func mustNoErr(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
+1
View File
@@ -723,6 +723,7 @@ func (rt *Runtime) followVerifiedBlock(ctx context.Context, blk block.Block, fro
data: blk.Bytes(),
pChainHeight: childEpoch,
}
setCanonicalFromVM(consensusBlock, blk) // stamp the inner execution commitment
// Add to consensus tracking (idempotent: AddBlock errors if already present).
_ = rt.Transitive.consensus.AddBlock(ctx, consensusBlock)
+191 -52
View File
@@ -63,31 +63,109 @@ var (
ErrAncestorNotTracked = errors.New("chain: cannot finalize — an ancestor between the finalized tip and the cert-selected block is not tracked (behind; fetch and retry)")
)
// finalizedEntry is the CERTIFIED record at one finalized height: the canonical
// execution commitment (the authoritative finality identity) and the outer
// envelope id (transport, retained for serving/diagnostics). Equivocation and
// idempotency are decided on `canonical` ONLY — the envelope is non-authoritative.
type finalizedEntry struct {
canonical ids.ID // inner execution commitment — THE finalized identity at this height
envelope ids.ID // outer/proposervm id — transport cache key (non-authoritative)
}
// FinalityLedger is the committed, append-only prefix of finalized history — an
// immutable VALUE. It is never mutated in place; Finalize returns a NEW one.
//
// tip/height name the head of finalized history (avalanchego's
// lastAcceptedID/lastAcceptedHeight — the committed lower bound); byHeight indexes
// every finalized height so a re-finalize of any past height with a different block
// is caught (α-of-K equivocation evidence). All fields are unexported and read-only
// after construction: the projections (Tip/Height/At) are the only outside view.
// THE CERTIFIED FRONTIER vs THE RECOVERY HINT (the incident-1082814 durable rule).
// The ledger separates two notions that the pre-fix code fatally conflated:
//
// - The CERTIFIED frontier (tip/canonical/height/set + byHeight) advances ONLY
// by folding a verified quorum cert (or the bootstrap frontier-trust path).
// byHeight indexes the CANONICAL commitment finalized at each height; a second
// cert for a DIFFERENT canonical id at an already-certified height is the ONLY
// thing that is equivocation. Two outer envelopes wrapping the SAME canonical
// block are duplicates, never a fork.
// - The recovery HINT (hint/hintHeight/hasHint) is seeded from vm.LastAccepted on
// boot/import. It is NON-AUTHORITATIVE: it never registers as finalized height,
// never enters byHeight, and can NEVER trigger equivocation. It is a build
// anchor only — "where to build next" until a real cert arrives. A cert at the
// hint's height with a different canonical id simply seeds certified history;
// the wrong local guess is silently superseded by network truth.
//
// All fields are unexported and read-only after construction; the projections
// (CertifiedTip/Height/At/BuildAnchor) are the only outside view.
type FinalityLedger struct {
tip ids.ID // head of finalized history (committed lower bound)
height uint64 // height of tip
set bool // false until the first block is finalized
byHeight map[uint64]ids.ID // every finalized height -> its block (equivocation index)
// Certified frontier — advanced ONLY by a verified QC fold (or bootstrap
// frontier-trust). `tip` is the OUTER envelope id of the certified head (the
// join point the ancestry walk seeks in the transport DAG); `canonical` is its
// canonical commitment.
tip ids.ID
canonical ids.ID
height uint64
set bool // false until the first CERT (or bootstrap) finalizes
byHeight map[uint64]finalizedEntry
// Recovery hint — from vm.LastAccepted. Non-authoritative build anchor only.
hint ids.ID // outer id to build on until a cert finalizes
hintHeight uint64
hasHint bool
}
// Tip is the head of finalized history (ids.Empty before the first finalize).
// Tip is the OUTER envelope id of the certified head (ids.Empty before the first
// cert). This is the true finalized tip — backed by a QC. The recovery hint is NOT
// returned here (use BuildAnchor for the build view).
func (l FinalityLedger) Tip() ids.ID { return l.tip }
// Height returns the finalized height and whether anything is finalized yet.
// CanonicalTip is the canonical execution commitment of the certified head
// (ids.Empty before the first cert).
func (l FinalityLedger) CanonicalTip() ids.ID { return l.canonical }
// Height returns the CERTIFIED finalized height and whether any cert has
// finalized yet. The recovery hint does NOT count: a hint-only ledger returns
// (0,false), so the finality height gate and equivocation index see no finalized
// height until a real cert exists.
func (l FinalityLedger) Height() (uint64, bool) { return l.height, l.set }
// At returns the block finalized at height, if any (equivocation evidence lookup).
// At returns the CANONICAL commitment finalized at height, if a CERTIFIED entry
// exists there (equivocation evidence lookup). Hints are never returned.
func (l FinalityLedger) At(height uint64) (ids.ID, bool) {
id, ok := l.byHeight[height]
return id, ok
e, ok := l.byHeight[height]
if !ok {
return ids.Empty, false
}
return e.canonical, true
}
// EnvelopeAt returns the outer transport id finalized at height (for serving /
// diagnostics), if a certified entry exists there.
func (l FinalityLedger) EnvelopeAt(height uint64) (ids.ID, bool) {
e, ok := l.byHeight[height]
if !ok {
return ids.Empty, false
}
return e.envelope, true
}
// BuildAnchor returns the outer id the VM should build/prefer on, and whether any
// anchor exists. It is the HIGHER of {certified tip, recovery hint}: the certified
// tip normally, but a forward recovery hint (vm.LastAccepted above the certified
// height — e.g. a state-sync import) wins so the VM builds where the node actually
// has state. This is a BUILD concern (transport), strictly decoupled from the
// finality Height() — advancing the build anchor past the certified tip touches no
// finality decision (a hint can never finalize), so it affects only liveness.
func (l FinalityLedger) BuildAnchor() (ids.ID, bool) {
switch {
case l.set && l.hasHint:
if l.hintHeight > l.height {
return l.hint, true
}
return l.tip, true
case l.set:
return l.tip, true
case l.hasHint:
return l.hint, true
default:
return ids.Empty, false
}
}
// Ancestry is the READ-ONLY view of the preference DAG the fold needs. The
@@ -95,19 +173,28 @@ func (l FinalityLedger) At(height uint64) (ids.ID, bool) {
// Finalize NEVER mutates the DAG — it reads ancestry to prove the certified path and
// to collect the losing-sibling subtrees to prune.
type Ancestry interface {
// Parent returns id's parent and id's OWN height; ok is false if id is untracked.
Parent(id ids.ID) (parent ids.ID, height uint64, ok bool)
// Parent returns id's OUTER parent, id's OWN height, and id's CANONICAL execution
// commitment; ok is false if id is untracked. The canonical id is what the
// per-height equivocation index records for an intermediate catch-up-path block.
Parent(id ids.ID) (parent ids.ID, height uint64, canonical ids.ID, ok bool)
// Children returns the ids of every tracked block whose parent is id.
Children(id ids.ID) []ids.ID
}
// Cert is the minimal finality subject — the block a quorum certificate selects,
// decoupled from the wire VerifiedQuorumCert. Finality is "fold a Cert into the
// ledger". Parent is ids.Empty only for the genesis / first finalize.
// ledger".
//
// Block/Parent are the OUTER transport ids (used to walk the transport DAG and as
// the VM accept target); Canonical is the inner execution commitment — the
// AUTHORITATIVE finality identity the fold keys equivocation/idempotency on. For a
// non-wrapped block Canonical == Block. Parent is ids.Empty only for the genesis /
// first finalize.
type Cert struct {
Block ids.ID
Parent ids.ID
Height uint64
Block ids.ID
Parent ids.ID
Height uint64
Canonical ids.ID
}
// Plan is what Finalize decides and the engine applies to the VM and DAG. It mirrors
@@ -128,28 +215,48 @@ type Plan struct {
// unchanged (the caller assigns nothing). On success it returns the advanced ledger
// (a fresh value) and the plan the engine applies.
//
// It enforces (ported verbatim from the prior finalizeBranchLocked):
// It enforces:
//
// (a) ONE finalized block per height. The same block already finalized here is an
// idempotent no-op; a DIFFERENT block at an already-finalized height is
// equivocation → ErrHeightAlreadyFinalized.
// (a) ONE CANONICAL commitment finalized per height. Keyed on the inner execution
// commitment (cert.Canonical), NOT the outer envelope: the SAME canonical id
// already finalized here is an idempotent no-op regardless of which envelope the
// cert names (a duplicate alias — the incident-1082814 case); a DIFFERENT
// canonical id at an already-finalized height is equivocation →
// ErrHeightAlreadyFinalized.
// (b) the certified block must DESCEND from the finalized tip via a tracked,
// contiguous ancestry: a non-tip ancestor at/below the finalized height →
// ErrConflictsWithFinalizedBranch; an untracked ancestor → ErrAncestorNotTracked
// (DEFER, behind); a height gap / malformed linkage → ErrNonMonotonicFinalizedHeight.
func Finalize(led FinalityLedger, cert Cert, dag Ancestry) (FinalityLedger, Plan, error) {
// (a) idempotent / equivocation at the target height.
if existing, ok := led.byHeight[cert.Height]; ok {
if existing == cert.Block {
return led, Plan{}, nil // this exact block already finalized here — no-op
}
return led, Plan{}, fmt.Errorf("%w: height %d already finalized %s, refused %s",
ErrHeightAlreadyFinalized, cert.Height, existing, cert.Block)
// The AUTHORITATIVE finality identity is the canonical commitment, never the
// outer envelope. A cert that omits it (canonical == Empty) is degenerate; fall
// back to the outer id so a non-wrapped chain (outer == canonical) is unchanged.
canonical := cert.Canonical
if canonical == ids.Empty {
canonical = cert.Block
}
// First finalize seeds the ledger — no prior tip to extend or reorg.
// (a) idempotent / equivocation at the target height — keyed on the CANONICAL
// commitment. byHeight holds CERTIFIED entries only (hints never enter it), so a
// hit here is always a prior QC-backed finalization.
if existing, ok := led.byHeight[cert.Height]; ok {
if existing.canonical == canonical {
// SAME inner block already certified here — a no-op regardless of which
// outer envelope this cert names (duplicate alias, NOT a fork).
return led, Plan{}, nil
}
// A DIFFERENT canonical commitment is already CERTIFIED at this height: two
// valid certs select different execution blocks at one height — the real fork.
return led, Plan{}, fmt.Errorf("%w: height %d already finalized canonical %s (envelope %s), refused canonical %s (envelope %s)",
ErrHeightAlreadyFinalized, cert.Height, existing.canonical, existing.envelope, canonical, cert.Block)
}
// First CERT finalize seeds certified history — no prior certified tip to
// extend or reorg. The recovery hint (if any) is non-authoritative and is simply
// superseded here: a cert at the hint's height, even for a different canonical id
// than the hint guessed, finalizes cleanly (the hint never blocks a cert).
if !led.set {
return seedLedger(cert.Block, cert.Height), Plan{Accept: []ids.ID{cert.Block}}, nil
return seedLedger(cert.Block, canonical, cert.Height), Plan{Accept: []ids.ID{cert.Block}}, nil
}
// Below/at the frontier with no record for this height → stale or non-monotonic.
@@ -167,13 +274,15 @@ func Finalize(led FinalityLedger, cert Cert, dag Ancestry) (FinalityLedger, Plan
}
// Build the NEXT ledger by COPY (never mutate the input map) and the plan: Accept
// the path ascending, Reject every losing-sibling subtree along it.
// the path ascending, Reject every losing-sibling subtree along it. byHeight
// records the CANONICAL commitment of each step (the authoritative finality id).
next := led.clone()
var plan Plan
for _, s := range path {
plan.Reject = append(plan.Reject, losingSubtrees(s.id, s.parentID, dag)...)
next.byHeight[s.height] = s.id
next.byHeight[s.height] = finalizedEntry{canonical: s.canonical, envelope: s.id}
next.tip = s.id
next.canonical = s.canonical
next.height = s.height
plan.Accept = append(plan.Accept, s.id)
}
@@ -181,26 +290,46 @@ func Finalize(led FinalityLedger, cert Cert, dag Ancestry) (FinalityLedger, Plan
return next, plan, nil
}
// seedLedger constructs the first ledger value from the seed (id, height).
func seedLedger(id ids.ID, height uint64) FinalityLedger {
// seedLedger constructs the first CERTIFIED ledger value from the seed (outer
// envelope id, canonical commitment, height). Clears any recovery hint — certified
// history now dominates the build anchor.
func seedLedger(envelope, canonical ids.ID, height uint64) FinalityLedger {
return FinalityLedger{
tip: id,
height: height,
set: true,
byHeight: map[uint64]ids.ID{height: id},
tip: envelope,
canonical: canonical,
height: height,
set: true,
byHeight: map[uint64]finalizedEntry{height: {canonical: canonical, envelope: envelope}},
}
}
// withHint returns a COPY of the ledger with the recovery-hint fields set to
// (envelope, height), PRESERVING any certified frontier. The hint is
// NON-AUTHORITATIVE: it sets no certified state (Height stays (0,false) until a
// cert; byHeight is untouched), so equivocation can never fire from it. It is a
// build anchor only. A hint must never wipe a QC-backed frontier — hence the copy
// rather than a fresh value.
func (l FinalityLedger) withHint(envelope ids.ID, height uint64) FinalityLedger {
next := l.clone()
next.hint = envelope
next.hintHeight = height
next.hasHint = true
return next
}
// clone returns a deep copy of the ledger (a fresh byHeight map) so the fold never
// mutates the receiver value's map. One copy per Finalize. byHeight is bounded to
// equivocationWindow entries (pruneBelowWindow), so this copy is O(window) — constant
// cost at any chain height — never O(chain height).
func (l FinalityLedger) clone() FinalityLedger {
bh := make(map[uint64]ids.ID, len(l.byHeight)+1)
for h, id := range l.byHeight {
bh[h] = id
bh := make(map[uint64]finalizedEntry, len(l.byHeight)+1)
for h, e := range l.byHeight {
bh[h] = e
}
return FinalityLedger{
tip: l.tip, canonical: l.canonical, height: l.height, set: l.set, byHeight: bh,
hint: l.hint, hintHeight: l.hintHeight, hasHint: l.hasHint,
}
return FinalityLedger{tip: l.tip, height: l.height, set: l.set, byHeight: bh}
}
// equivocationWindow bounds how many heights below the finalized tip the per-height
@@ -229,21 +358,27 @@ func (l *FinalityLedger) pruneBelowWindow() {
// step is one block on the certified path from the finalized tip to the cert target.
type step struct {
id ids.ID
height uint64
parentID ids.ID
id ids.ID
height uint64
parentID ids.ID
canonical ids.ID // canonical commitment of this step (recorded in byHeight)
}
// pathFromTip returns the contiguous ancestry finalizedTip → target in ASCENDING
// height order, by walking target's parent links through the DAG. Errors distinguish
// the three non-extending cases (conflict / behind / gap). Caller guarantees
// cert.Height > led.height and led.set.
// cert.Height > led.height and led.set. The top step's canonical comes from the
// cert; intermediate steps' canonical come from the tracked DAG.
func pathFromTip(led FinalityLedger, cert Cert, dag Ancestry) ([]step, error) {
steps := []step{{id: cert.Block, height: cert.Height, parentID: cert.Parent}}
topCanonical := cert.Canonical
if topCanonical == ids.Empty {
topCanonical = cert.Block
}
steps := []step{{id: cert.Block, height: cert.Height, parentID: cert.Parent, canonical: topCanonical}}
cur := cert.Parent
childHeight := cert.Height
for cur != led.tip {
parent, curHeight, ok := dag.Parent(cur)
parent, curHeight, curCanonical, ok := dag.Parent(cur)
if !ok {
// The path to the frontier is not fully tracked — the node is behind.
// Fail-closed DEFER: never finalize on a path we cannot prove.
@@ -261,7 +396,11 @@ func pathFromTip(led FinalityLedger, cert Cert, dag Ancestry) ([]step, error) {
return nil, fmt.Errorf("%w: %s ancestry reaches %s (height %d) not finalized tip %s",
ErrConflictsWithFinalizedBranch, cert.Block, cur, curHeight, led.tip)
}
steps = append(steps, step{id: cur, height: curHeight, parentID: parent})
stepCanonical := curCanonical
if stepCanonical == ids.Empty {
stepCanonical = cur
}
steps = append(steps, step{id: cur, height: curHeight, parentID: parent, canonical: stepCanonical})
cur = parent
childHeight = curHeight
}
+9 -6
View File
@@ -28,12 +28,13 @@ type dagNode struct {
height uint64
}
func (d mapDAG) Parent(id ids.ID) (ids.ID, uint64, bool) {
func (d mapDAG) Parent(id ids.ID) (ids.ID, uint64, ids.ID, bool) {
n, ok := d[id]
if !ok {
return ids.Empty, 0, false
return ids.Empty, 0, ids.Empty, false
}
return n.parent, n.height, true
// This pure-fold harness has no inner/outer split: canonical == the block's own id.
return n.parent, n.height, id, true
}
func (d mapDAG) Children(id ids.ID) []ids.ID {
@@ -240,7 +241,7 @@ func TestLedger_StaleHeightBelowFrontier(t *testing.T) {
a := ids.GenerateTestID()
x := ids.GenerateTestID()
// hand-built sparse ledger: head at height 2, height 1 NOT indexed.
led := FinalityLedger{tip: a, height: 2, set: true, byHeight: map[uint64]ids.ID{2: a}}
led := FinalityLedger{tip: a, canonical: a, height: 2, set: true, byHeight: map[uint64]finalizedEntry{2: {canonical: a, envelope: a}}}
if _, _, err := Finalize(led, Cert{Block: x, Parent: ids.Empty, Height: 1}, mapDAG{}); !errors.Is(err, ErrNonMonotonicFinalizedHeight) {
t.Fatalf("a stale below-frontier height must be refused, got %v", err)
}
@@ -355,13 +356,15 @@ func TestLedger_WindowPrune(t *testing.T) {
func BenchmarkFinalize_FlatCost(b *testing.B) {
for _, start := range []uint64{1 << 6, 1 << 12, 1 << 18, 1 << 20} {
b.Run(fmt.Sprintf("height_%d", start), func(b *testing.B) {
led := seedLedger(ids.GenerateTestID(), start)
seed := ids.GenerateTestID()
led := seedLedger(seed, seed, start)
base := uint64(0)
if start >= equivocationWindow {
base = start - equivocationWindow + 1
}
for h := base; h <= start; h++ { // fill a full window so clone copies window-many
led.byHeight[h] = ids.GenerateTestID()
id := ids.GenerateTestID()
led.byHeight[h] = finalizedEntry{canonical: id, envelope: id}
}
parent := led.tip
next := ids.GenerateTestID()
+45 -27
View File
@@ -49,30 +49,41 @@ func TestSyncState_RefusesBackwardRegression(t *testing.T) {
}
}
// TestSyncState_EqualHeightDifferentBlockRefused verifies that a re-import at the
// already-finalized height with a DIFFERENT block is refused as equivocation
// (two distinct blocks at one finalized height), while a re-import with the SAME
// block at the SAME height is an idempotent success.
func TestSyncState_EqualHeightDifferentBlockRefused(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.
func TestSyncState_DifferentBlockIsHintNotEquivocation(t *testing.T) {
c := NewChainConsensus(4, 3, 2)
h100 := ids.GenerateTestID()
if _, err := c.FinalizeBranch(h100, 100, ids.Empty); err != nil {
t.Fatalf("seed finalize: %v", err)
t.Fatalf("seed certified finalize: %v", err)
}
// Same block, same height → idempotent OK (a benign reconcile).
// Same block, same height → still fine (a benign reconcile hint).
if err := c.SyncState(h100, 100); err != nil {
t.Fatalf("idempotent SyncState(same block @100) should succeed, got: %v", err)
t.Fatalf("SyncState(same block @100) should succeed, got: %v", err)
}
// Different block at the same finalized height → equivocation, refused.
// 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.
other := ids.GenerateTestID()
if err := c.SyncState(other, 100); !errors.Is(err, ErrHeightAlreadyFinalized) {
t.Fatalf("SyncState(different block @100) should return ErrHeightAlreadyFinalized, got: %v", err)
if err := c.SyncState(other, 100); err != nil {
t.Fatalf("SyncState(different block @100) must be a clean hint, got: %v", err)
}
if got := c.GetFinalizedTip(); got != h100 {
t.Fatalf("finalized tip changed on a refused equal-height import: got %s want %s", got, h100)
// 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)
}
if got := c.GetCertifiedTip(); got != h100 {
t.Fatalf("certified tip changed on a hint import: got %s want %s", got, h100)
}
}
@@ -112,33 +123,40 @@ func TestSyncState_EmptyResetYieldsCleanGenesis(t *testing.T) {
}
}
// TestSyncState_ForwardImportAdvances verifies the guard does NOT impede the
// legitimate forward case: an import at a height above the current finalized
// head advances finalizedTip/Height (this is the normal admin_importChain /
// state-sync path and must remain a clean success).
func TestSyncState_ForwardImportAdvances(t *testing.T) {
// 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.
func TestSyncState_ForwardImportAdvancesBuildAnchorNotFinality(t *testing.T) {
c := NewChainConsensus(4, 3, 2)
h100 := ids.GenerateTestID()
if _, err := c.FinalizeBranch(h100, 100, ids.Empty); err != nil {
t.Fatalf("seed finalize: %v", err)
t.Fatalf("seed certified finalize: %v", err)
}
// Forward import to a mature height.
// Forward import (hint) to a mature height.
h5000 := ids.GenerateTestID()
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.
if got := c.GetFinalizedTip(); got != h5000 {
t.Fatalf("forward import did not advance tip: got %s want %s", got, h5000)
t.Fatalf("forward import did not advance the build anchor: got %s want %s", got, h5000)
}
if h, _ := c.GetFinalizedHeight(); h != 5000 {
t.Fatalf("forward import did not advance height: got %d want 5000", h)
// 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)
}
// The import re-seeds the per-height ledger to the imported head; the stale
// height-100 entry is no longer the source of truth.
if _, ok := c.FinalizedBlockAtHeight(5000); !ok {
t.Fatalf("forward import did not seed the ledger at the imported height 5000")
// 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")
}
}
+26 -9
View File
@@ -47,6 +47,17 @@ type Block struct {
timestamp int64
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".
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-
@@ -241,11 +252,12 @@ func (c *ChainConsensus) Preference() ids.ID {
c.mu.RLock()
defer c.mu.RUnlock()
// The committed finalized tip wins once finality has advanced.
if c.ledger.set {
return c.ledger.tip
// The committed finalized (certified) tip wins once finality has advanced; before
// any cert, the recovery hint (vm.LastAccepted) is the build anchor.
if anchor, ok := c.ledger.BuildAnchor(); ok {
return anchor
}
// Before the first finalize: the preliminary build preference, then any build tip.
// No certified tip and no hint: the preliminary build preference, then any tip.
if c.preference != ids.Empty {
return c.preference
}
@@ -284,9 +296,10 @@ func (c *ChainConsensus) PreferredBuildTip() ids.ID {
// Caller holds c.mu.
func (c *ChainConsensus) buildTipLocked() ids.ID {
var cur ids.ID
anchor, hasAnchor := c.ledger.BuildAnchor()
switch {
case c.ledger.set:
cur = c.ledger.tip
case hasAnchor:
cur = anchor
case c.preference != ids.Empty:
cur = c.preference
default:
@@ -381,12 +394,16 @@ func (c *ChainConsensus) ancestry() Ancestry { return blocksAncestry{blocks: c.b
// interface so the Finalize fold stays engine-free and unit-testable.
type blocksAncestry struct{ blocks map[ids.ID]*Block }
func (a blocksAncestry) Parent(id ids.ID) (ids.ID, uint64, bool) {
func (a blocksAncestry) Parent(id ids.ID) (ids.ID, uint64, ids.ID, bool) {
b, ok := a.blocks[id]
if !ok {
return ids.Empty, 0, false
return ids.Empty, 0, ids.Empty, false
}
return b.parentID, b.height, true
canonical := b.canonicalID
if canonical == ids.Empty {
canonical = b.id // bare block: canonical == outer
}
return b.parentID, b.height, canonical, true
}
func (a blocksAncestry) Children(id ids.ID) []ids.ID {
+93 -34
View File
@@ -189,12 +189,44 @@ 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 {
// If a DIFFERENT block is finalized at this exact height, the cert is an
// equivocation proof — surface it. (Same block = harmless stale replay.)
if fin, ok := t.consensus.FinalizedBlockAtHeight(cert.Position.Height); ok && fin != cert.Position.BlockID {
rt.reportCertEquivocation(cert, fin)
// 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.
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).
var epochHeight uint64
if exists && pending.ConsensusBlock != nil {
epochHeight = pending.ConsensusBlock.pChainHeight
}
if verr := t.verifyCert(cert, epochHeight); verr != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Warn("incoming cert: UNVERIFIED conflicting cert at a finalized height — dropping, NO evidence (forged/junk signatures cannot slash)",
log.Uint64("certHeight", cert.Position.Height), log.Err(verr))
}
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.
rt.reportCertEquivocation(cert, finCanonical)
} else if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("incoming cert: height at/below finalized; dropping",
rt.config.Logger.Debug("incoming cert: height at/below finalized; dropping (duplicate or stale, not a fork)",
log.Uint64("certHeight", cert.Position.Height),
log.Uint64("finalizedHeight", fh))
}
@@ -230,7 +262,6 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
// from a different validator-set epoch. nil source ⟹ Empty on both sides (no
// epoch bound), so this is a no-op for a fixed-set chain.
t.mu.RLock()
stake := t.stakeSource
setRootSrc := t.setRootSource
t.mu.RUnlock()
if setRootSrc != nil && exists {
@@ -245,17 +276,11 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
}
}
// On a stake-weighted chain the incoming cert must clear the ⅔-of-stake
// supermajority (HIGH-3), not just the count predicate — otherwise a
// low-stake coalition's count-α cert would finalize. Count-only chains use
// Verify (equal-stake invariant enforced at admission).
var verr error
if stake != nil {
verr = cert.VerifyWeighted(verifier, stake, epochHeight)
} else {
verr = cert.Verify(verifier, epochHeight)
}
if verr != nil {
// 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.
if verr := t.verifyCert(cert, epochHeight); verr != nil {
if !rt.config.Logger.IsZero() {
rt.config.Logger.Warn("incoming cert: verification failed",
log.Stringer("blockID", cert.Position.BlockID), log.Err(verr))
@@ -264,13 +289,18 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
}
// Cert is a valid α-of-K finality proof. Finalize the block IF we have
// verified it locally. If we have not yet seen/verified the block, we cannot
// safely Accept it — record nothing; the block gossip will arrive and the
// cert can be re-requested/re-applied. (A production node may also fetch the
// block by ID here; that fetch path is the node layer's responsibility.)
// 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.
if !exists {
rt.requestCatchup(cert.Position.BlockID, ids.EmptyNodeID)
if !rt.config.Logger.IsZero() {
rt.config.Logger.Debug("incoming cert: valid but block not locally verified yet; deferring",
rt.config.Logger.Debug("incoming cert: valid but block not locally tracked; fetching the certified block",
log.Stringer("blockID", cert.Position.BlockID))
}
return false
@@ -341,19 +371,48 @@ func (rt *Runtime) HandleIncomingCert(certBytes []byte) bool {
return 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.
func (t *Transitive) verifyCert(cert *QuorumCert, epochHeight uint64) error {
t.mu.RLock()
verifier := t.voteVerifier
stake := t.stakeSource
t.mu.RUnlock()
if verifier == nil {
return ErrQCVerifierNil
}
if stake != nil {
return cert.VerifyWeighted(verifier, stake, epochHeight)
}
return cert.Verify(verifier, epochHeight)
}
// reportCertEquivocation records that a SECOND, conflicting finality cert was
// presented for a height already finalized to a DIFFERENT block — a provable
// safety-equivocation. Each voter in the conflicting cert signed-final a block
// at a height already finalized to `finalized`, so each is recorded as a
// DoubleVote (it asserts a different block at the same height than the one that
// finalized). The event is also logged at CRIT — this is a Byzantine-fault
// signal, not a routine drop. Best effort: never blocks the safety reject.
func (rt *Runtime) reportCertEquivocation(cert *QuorumCert, finalized ids.ID) {
// 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.
func (rt *Runtime) reportCertEquivocation(cert *QuorumCert, finalizedCanonical ids.ID) {
conflicting := cert.Position.CanonicalID
if conflicting == ids.Empty {
conflicting = cert.Position.BlockID
}
if !rt.config.Logger.IsZero() {
rt.config.Logger.Crit("EQUIVOCATION: conflicting finality cert at finalized height",
rt.config.Logger.Crit("EQUIVOCATION: conflicting finality cert at finalized height (different canonical block)",
log.Uint64("height", cert.Position.Height),
log.Stringer("finalizedBlock", finalized),
log.Stringer("conflictingBlock", cert.Position.BlockID),
log.Stringer("finalizedCanonical", finalizedCanonical),
log.Stringer("conflictingCanonical", conflicting),
log.Stringer("conflictingEnvelope", cert.Position.BlockID),
log.Int("conflictingVoters", cert.VoterCount()))
}
t := rt.Transitive
@@ -364,8 +423,8 @@ func (rt *Runtime) reportCertEquivocation(cert *QuorumCert, finalized ids.ID) {
return
}
for i := range cert.Votes {
proof := fmt.Appendf(nil, "height=%d finalized=%s conflicting=%s",
cert.Position.Height, finalized, cert.Position.BlockID)
proof := fmt.Appendf(nil, "height=%d finalizedCanonical=%s conflictingCanonical=%s",
cert.Position.Height, finalizedCanonical, conflicting)
sdb.RecordEvidence(slashing.Evidence{
Type: slashing.DoubleVote,
ValidatorID: cert.Votes[i].NodeID,