Files
consensus/engine/chain/sync_test.go
T
zeekayandHanzo Dev 8793eb2d71 fix(engine/chain): QCv2 canonical-commitment finality + seed-hint semantics (incident 1082814)
Durable fix for the finality-equivocation bug class. Root cause: finality was
defined over the OUTER proposervm envelope id, and the in-memory finality ledger
was SEEDED from vm.LastAccepted on boot. Two outer envelopes wrapping the SAME
inner execution block looked like a fork -> fatal EQUIVOCATION (reportCertEquivocation
-> Logger.Crit -> Fatal -> os.Exit), halting mainnet 43h. They were duplicates.

Two layers, shipped together (atomic roll, all validators upgrade at once):

PART A - seed semantics. ChainConsensus.SyncState (the vm.LastAccepted reconcile)
now writes a NON-AUTHORITATIVE recovery HINT, never certified finality.
FinalityLedger separates a certified frontier (advanced ONLY by a verified QC or
bootstrap frontier-trust) from a hint anchor. GetFinalizedHeight is certified-only;
GetFinalizedTip/BuildAnchor return the higher of {certified tip, hint} for the VM
to build on. A locally-seeded sibling can never manufacture a finalized entry, so
it can never equivocate. The equal-height "different block => equivocation" check
in SyncState was the bug and is deleted.

PART B - QCv2 canonical cert. Finality is keyed on the inner EXECUTION commitment,
not the envelope. VotePosition carries both outer transport ids {BlockID, ParentID}
and canonical {CanonicalID, ParentCanonicalID, ExecutionStateRoot, PayloadRoot}.
byHeight indexes the CANONICAL id. Two certs at one height conflict IFF canonical
differs; same canonical / different envelope = duplicate alias (idempotent no-op).
CanonicalVoteMessage (tag LUX/chain/vote/v2, QuorumCertVersion=3) binds the
canonical identity and EXCLUDES the envelope, so validators that executed the same
inner block sign byte-identical messages even under different envelopes -> their
votes interoperate. The non-wrapped degrade (Empty canonical => bind BlockID) is
resolved in ONE place (canonicalVoteMessageFor). Canonical id is plumbed from
proposervm via the optional canonicalCommitter block iface + setCanonicalFromVM /
canonicalIDOf (falls back to b.ID for a bare block).

HARD GATE: the only writers of certified finalizedTip/byHeight are the cert fold
(Finalize, via ApplyCert/FinalizeBranch) and bootstrap frontier-trust
(AcceptBootstrapBlock). SyncState writes only the hint; preferredTip/build anchor
are build-only, never finality.

Proof suite (all green): 6-row regression matrix + invariant (incident_1082814_test.go),
canonical inversions (cert_canonical_inversion_test.go), two fuzz targets
(cert_fuzz_test.go: codec round-trip/malformed + finalize-decision invariant),
-race clean, go vet clean, staticcheck adds zero findings. 335 engine/chain tests
pass. Pre-fix repro captured (seed A + cert B at one height returned
ErrHeightAlreadyFinalized; now finalizes the canonical block, no equivocation).

No deploy/push/merge/tag — review-pending.

Co-authored-by: Hanzo Dev <[email protected]>
2026-06-30 13:45:42 -07:00

231 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package chain
import (
"context"
"errors"
"testing"
"github.com/luxfi/ids"
)
// TestSyncState_RefusesBackwardRegression is the regression test for the
// SyncState monotonic-height guard. SyncState bypasses markFinalizedLocked by
// design (an import is an out-of-band reconcile, not an α-of-K finalize), so the
// monotonic invariant must be re-asserted there explicitly: a backward import
// (height below the already-finalized height) MUST be refused and MUST leave the
// finalized head untouched. Without the guard a shorter/older imported chain
// would silently regress finalizedHeight and un-finalize already-finalized
// blocks — re-opening the fork window the per-height guard closes.
func TestSyncState_RefusesBackwardRegression(t *testing.T) {
c := NewChainConsensus(4, 3, 2)
// Seed a finalized head at height 100 through the real finalize path so the
// per-height ledger is in the exact shape SyncState must respect.
h100 := ids.GenerateTestID()
if _, err := c.FinalizeBranch(h100, 100, ids.Empty); err != nil {
t.Fatalf("seed finalize at height 100: %v", err)
}
// A backward import at height 99 must be REFUSED with ErrSyncStateRegression…
older := ids.GenerateTestID()
if err := c.SyncState(older, 99); !errors.Is(err, ErrSyncStateRegression) {
t.Fatalf("backward SyncState(h=99) should return ErrSyncStateRegression, got: %v", err)
}
// …and must NOT have regressed any finalized state (fail-closed no-op).
if got := c.GetFinalizedTip(); got != h100 {
t.Fatalf("finalized tip regressed: got %s want %s (height-100 head must be untouched)", got, h100)
}
if h, set := c.GetFinalizedHeight(); h != 100 || !set {
t.Fatalf("finalizedHeight regressed: got %d set=%v want 100/true", h, set)
}
if existing, ok := c.FinalizedBlockAtHeight(100); !ok || existing != h100 {
t.Fatalf("per-height ledger at 100 corrupted: ok=%v existing=%s", ok, existing)
}
if _, ok := c.FinalizedBlockAtHeight(99); ok {
t.Fatalf("backward import leaked a ledger entry at the rejected height 99")
}
}
// 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 certified finalize: %v", err)
}
// Same block, same height → still fine (a benign reconcile hint).
if err := c.SyncState(h100, 100); err != nil {
t.Fatalf("SyncState(same block @100) should succeed, got: %v", err)
}
// DIFFERENT block at the same certified height → a HINT, NOT equivocation. The
// pre-fix code returned ErrHeightAlreadyFinalized here (the bug). The fix accepts
// it as a non-authoritative hint and leaves certified finality untouched.
other := ids.GenerateTestID()
if err := c.SyncState(other, 100); err != nil {
t.Fatalf("SyncState(different block @100) must be a clean hint, got: %v", err)
}
// The CERTIFIED frontier is untouched: height stays 100, the canonical tip is
// 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)
}
}
// TestSyncState_EmptyResetYieldsCleanGenesis is the LOW-2 regression: an empty import
// head (ids.Empty, height 0) on a ledger ALREADY finalized above genesis resets finality
// to a CLEAN genesis VALUE — tip Empty, height 0, unset — never the prior half-reset that
// nulled the tip while leaving height/byHeight stale (a tip-vs-height desync that wedged
// every future cert in pathFromTip). The proof the desync is gone: a fresh cert finalizes
// cleanly afterward instead of wedging.
func TestSyncState_EmptyResetYieldsCleanGenesis(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)
}
// Empty reset of a live ledger → clean genesis value, no error.
if err := c.SyncState(ids.Empty, 0); err != nil {
t.Fatalf("empty reset SyncState(Empty,0) should succeed, got: %v", err)
}
if got := c.GetFinalizedTip(); got != ids.Empty {
t.Fatalf("empty reset should clear the tip, got %s", got)
}
// No desync: height/set are reset too (not left stale at 100/true).
if h, set := c.GetFinalizedHeight(); set || h != 0 {
t.Fatalf("empty reset left a tip-vs-height desync: got (%d,%v) want (0,false)", h, set)
}
// No wedge: a fresh cert finalizes cleanly (first-finalize seeds), proving the
// post-reset ledger is usable rather than permanently stuck seeking an Empty tip.
next := ids.GenerateTestID()
if _, err := c.FinalizeBranch(next, 7, ids.Empty); err != nil {
t.Fatalf("post-reset finalize wedged (the LOW-2 desync): %v", err)
}
if got := c.GetFinalizedTip(); got != next {
t.Fatalf("post-reset finalize did not advance: got %s want %s", got, next)
}
}
// TestSyncState_ForwardImportAdvancesBuildAnchorNotFinality verifies the corrected
// (incident-1082814 PART-A) semantics of a forward import: it advances the BUILD
// ANCHOR (where the VM builds — GetFinalizedTip) so the node builds on the imported
// head, but it does NOT advance CERTIFIED finality. vm.LastAccepted / import is a
// recovery HINT, never a finality source: the certified height stays at the last
// QC-backed height and byHeight gains NO entry at the imported height (only a cert
// can write there). Finality at the imported height is established later via the
// bootstrap/cert path, not by the import claiming it.
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 certified finalize: %v", err)
}
// 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 the build anchor: got %s want %s", got, h5000)
}
// 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)
}
// 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")
}
}
// TestSyncState_EmptyResetAllowed verifies that an explicit genesis/empty reset
// (lastAcceptedID == ids.Empty) is NOT treated as a regression — it is a
// deliberate teardown, distinct from a backward import of a concrete head.
func TestSyncState_EmptyResetAllowed(t *testing.T) {
c := NewChainConsensus(4, 3, 2)
h100 := ids.GenerateTestID()
if _, err := c.FinalizeBranch(h100, 100, ids.Empty); err != nil {
t.Fatalf("seed finalize: %v", err)
}
if err := c.SyncState(ids.Empty, 0); err != nil {
t.Fatalf("empty reset SyncState(Empty,0) should succeed, got: %v", err)
}
if got := c.GetFinalizedTip(); got != ids.Empty {
t.Fatalf("empty reset should clear the finalized tip, got %s", got)
}
}
// TestSyncState_EmptyHeadWithPositiveHeightRefused is the INFO-6 regression. An
// EMPTY import head paired with a POSITIVE height is contradictory (an empty head
// is the genesis/teardown reset, valid only at height 0). If allowed it would set
// finalizedTip=Empty while the seed branch is skipped (finalizedHeight stays
// stale) AND prune blocks below the positive height — the exact
// finalizedTip-vs-finalizedHeight desync ForcePreference was hardened against.
// SyncState must REFUSE it fail-closed, leaving finalized state AND the block pool
// untouched (no pruning).
func TestSyncState_EmptyHeadWithPositiveHeightRefused(t *testing.T) {
c := NewChainConsensus(4, 3, 2)
// Finalized head at height 100.
h100 := ids.GenerateTestID()
if _, err := c.FinalizeBranch(h100, 100, ids.Empty); err != nil {
t.Fatalf("seed finalize at height 100: %v", err)
}
// Seed a low-height block in the pool so we can prove the refused import does
// NOT prune it (the desync path would delete every block below the height).
low := &Block{id: ids.GenerateTestID(), height: 50}
if err := c.AddBlock(context.Background(), low); err != nil {
t.Fatalf("seed low block @50: %v", err)
}
// Empty head at a positive height → refused with ErrSyncStateEmptyWithHeight.
if err := c.SyncState(ids.Empty, 5000); !errors.Is(err, ErrSyncStateEmptyWithHeight) {
t.Fatalf("SyncState(Empty, 5000) should return ErrSyncStateEmptyWithHeight, got: %v", err)
}
// Finalized state is untouched (the desync is prevented).
if got := c.GetFinalizedTip(); got != h100 {
t.Fatalf("refused empty-at-height import desynced the tip: got %s want %s", got, h100)
}
if h, set := c.GetFinalizedHeight(); h != 100 || !set {
t.Fatalf("refused import corrupted finalizedHeight: got %d set=%v want 100/true", h, set)
}
if existing, ok := c.FinalizedBlockAtHeight(100); !ok || existing != h100 {
t.Fatalf("refused import corrupted the per-height ledger at 100: ok=%v existing=%s", ok, existing)
}
// The low-height block MUST still be present — the refused import pruned nothing.
if _, ok := c.GetBlock(low.id); !ok {
t.Fatal("refused empty-at-height import pruned a live block below the height (the desync the guard prevents)")
}
// Sanity: the legitimate empty reset (Empty, 0) is still allowed (the guard is
// precise — it gates only height>0, not all empty heads).
if err := c.SyncState(ids.Empty, 0); err != nil {
t.Fatalf("empty reset SyncState(Empty,0) must still succeed after the guard, got: %v", err)
}
}