mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
539 lines
24 KiB
Go
539 lines
24 KiB
Go
// 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, Quasar, 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.GetNovaTip(); 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.GetNovaTip(); 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, Quasar, 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, Quasar, 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)
|
||
}
|
||
}
|