mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()}}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user