engine/chain: canonicalize the intermediate-ancestor walk (close v1.36 load-livelock)

pathFromTip resolved every intermediate ancestor by its OUTER proposervm-wrapper id
with no canonical fallback — the one finality path the mainnet-644 canonicalization
missed (vote placement, cert's-own-block, and the equivocation key were all
canonicalized; this walk was not). Under sustained saturation the fleet tracks
distinct wrappers of the same inner block (aliases, not forks); a verified cert
threading a peer's wrapper could not be applied by a node holding the identical
inner execution under its own wrapper, so finality livelocked on an unservable
outer id (~1174 finality-guard refusals/min, tip frozen ~2h, safety held — no fork).

Carry the signature-covered ParentCanonical through the fold Cert (it was dropped at
the engine.go seam); on an outer-id miss, collapse to a locally-held sibling wrapper
of the same inner commitment (WrapperByCanonical) before the fail-closed defer,
rebasing the child's parent link so sibling-pruning keys on the held envelope. A
genuinely unheld inner block still returns ErrAncestorNotTracked (behind-node defer
preserved). Bare chains and the normal tracked-outer path are byte-for-byte
unchanged (empty ParentCanonical ⇒ outer-id-only walk; the miss branch is additive).

Alias-collapse is sound because canonicalRep = CanonicalID() is a collision-resistant
hash of the inner execution (binds inner parent + payload + state root) that no
unverified block can spoof — every tracked block is Verify()'d before AddBlock, and
the matched parent commitment is signature-covered in the cert. It can therefore
only ever stand in a byte-identical execution, never a divergent state: no fork risk
HEAD lacked, livelock closed.

RED-verified SHIP: 0 critical / 0 high / 0 medium (2 optional-cleanup LOWs), 12
adversarial tests + the 107-test finality suite green, go build + vet clean.
Regression guards: intermediate_sibling_livelock_test.go (repro FAIL->PASS),
intermediate_sibling_adversarial_test.go, wrapper_bycanonical_adversarial_test.go.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-10 15:28:01 -07:00
co-authored by Hanzo Dev
parent 885735eb2e
commit 83b495aced
8 changed files with 782 additions and 28 deletions
+15 -5
View File
@@ -30,14 +30,15 @@ import (
// mapAncestry is a tiny read-only Ancestry over an explicit parent/height/canonical map, so the
// pure fold can be driven with an arbitrary (partial) tree.
type mapAncestry map[ids.ID]struct {
parent ids.ID
height uint64
canonical ids.ID
parent ids.ID
height uint64
canonical ids.ID
parentCanonical ids.ID
}
func (m mapAncestry) Parent(id ids.ID) (ids.ID, uint64, ids.ID, bool) {
func (m mapAncestry) Parent(id ids.ID) (ids.ID, uint64, ids.ID, ids.ID, bool) {
e, ok := m[id]
return e.parent, e.height, e.canonical, ok
return e.parent, e.height, e.canonical, e.parentCanonical, ok
}
func (m mapAncestry) Children(id ids.ID) []ids.ID {
@@ -50,6 +51,15 @@ func (m mapAncestry) Children(id ids.ID) []ids.ID {
return out
}
func (m mapAncestry) WrapperByCanonical(canonical ids.ID, height uint64) (ids.ID, bool) {
for id, e := range m {
if e.height == height && e.canonical == canonical {
return id, true
}
}
return ids.Empty, false
}
// TestFinalize_MissingAncestor_TypedError pins the fold half of the fix: an untracked ancestor on
// the certified path yields a *AncestorNotTracked carrying the SPECIFIC missing id, while
// errors.Is(err, ErrAncestorNotTracked) still holds (so existing classifiers are unchanged).
+1 -1
View File
@@ -3237,7 +3237,7 @@ func (t *Transitive) acceptWithCertCore(ctx context.Context, blockID ids.ID, cer
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, Canonical: pos.CanonicalID})
plan, err := t.consensus.ApplyCert(Cert{Block: pos.BlockID, Parent: pos.ParentID, ParentCanonical: pos.ParentCanonicalID, Height: pos.Height, Canonical: pos.CanonicalID})
if err != nil {
return err
}
@@ -0,0 +1,371 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// intermediate_sibling_adversarial_test.go — RED-TEAM adversarial probes of the
// v1.36 "Nova" intermediate-ancestor alias-collapse fix (pathFromTip +
// WrapperByCanonical). These tests try to REFUTE safety: to make the walk stand in a
// divergent execution (FORK), miss an equivocation, or livelock on a topology the fix
// claims to resolve. Every test drives the pure fold (Finalize) directly.
//
// Reuses the wrappedAncestry harness from intermediate_sibling_livelock_test.go.
package chain
import (
"errors"
"testing"
"github.com/luxfi/ids"
)
// ---------------------------------------------------------------------------
// #6 COMPLETENESS — TWO consecutive intermediate aliases in a row.
// tip certified @5. Cert selects inner8 @8. BOTH the height-6 and height-7
// intermediate ancestors named by the cert are the PEER's wrappers (absent here);
// this node holds its OWN wrappers of inner6 and inner7, chaining to the tip. The
// walk must resolve BOTH levels (hint threaded each iteration), not just the first.
// ---------------------------------------------------------------------------
func TestAdv_TwoConsecutiveIntermediateAliases_Resolves(t *testing.T) {
tipOuter := ids.GenerateTestID()
tipCanon := ids.GenerateTestID()
led := seedLedger(tipOuter, tipCanon, 5)
inner6 := ids.GenerateTestID()
inner7 := ids.GenerateTestID()
inner8 := ids.GenerateTestID()
wrapperB6 := ids.GenerateTestID() // this node's wrapper of inner6, parent = tip
wrapperB7 := ids.GenerateTestID() // this node's wrapper of inner7, parent = wrapperB6
wrapperA6 := ids.GenerateTestID() // peer's wrapper of inner6 (ABSENT)
wrapperA7 := ids.GenerateTestID() // peer's wrapper of inner7 (ABSENT)
blk8 := ids.GenerateTestID() // certified top; its recorded parent is wrapperA7
dag := wrappedAncestry{
wrapperB6: {parent: tipOuter, height: 6, canonical: inner6, parentCanonical: tipCanon},
wrapperB7: {parent: wrapperB6, height: 7, canonical: inner7, parentCanonical: inner6},
// certified block: recorded outer parent is the peer's wrapperA7 (absent).
blk8: {parent: wrapperA7, height: 8, canonical: inner8, parentCanonical: inner7},
// wrapperA6, wrapperA7 intentionally ABSENT.
}
// Cert carries the attested ParentCanonical = inner7 (signature-covered, cert.go:295).
cert := Cert{Block: blk8, Parent: wrapperA7, ParentCanonical: inner7, Height: 8, Canonical: inner8}
_, plan, err := Finalize(led, cert, dag)
if err != nil {
t.Fatalf("two-level alias walk must resolve both intermediates, got err=%v", err)
}
if len(plan.Accept) != 3 {
t.Fatalf("expected 3-step accept {wrapperB6@6, wrapperB7@7, blk8@8}, got %v", plan.Accept)
}
// The accepted path must use the LOCAL wrappers, never the absent peer envelopes.
for _, id := range plan.Accept {
if id == wrapperA6 || id == wrapperA7 {
t.Fatalf("accepted an UNHELD peer envelope %s; must use local wrappers", id)
}
}
if plan.Accept[0] != wrapperB6 || plan.Accept[1] != wrapperB7 || plan.Accept[2] != blk8 {
t.Fatalf("accept order wrong: got %v want [wrapperB6 wrapperB7 blk8]", plan.Accept)
}
}
// ---------------------------------------------------------------------------
// #6 COMPLETENESS — a resolved intermediate wrapper whose OWN outer parent is ALSO
// an absent alias. After WrapperByCanonical stands in wrapperB7 (for absent wrapperA7),
// wrapperB7's recorded outer parent is wrapperA6 (ALSO absent). The next iteration
// must re-resolve via the threaded hint (wrapperB7.parentCanonical=inner6), not wedge.
// ---------------------------------------------------------------------------
func TestAdv_ResolvedWrapperParentIsAlsoAbsentAlias(t *testing.T) {
tipOuter := ids.GenerateTestID()
tipCanon := ids.GenerateTestID()
led := seedLedger(tipOuter, tipCanon, 5)
inner6 := ids.GenerateTestID()
inner7 := ids.GenerateTestID()
inner8 := ids.GenerateTestID()
wrapperB6 := ids.GenerateTestID() // local wrapper of inner6, parent = tip
wrapperB7 := ids.GenerateTestID() // local wrapper of inner7, parent = wrapperA6 (ABSENT)
wrapperA6 := ids.GenerateTestID() // peer wrapper of inner6 (ABSENT)
wrapperA7 := ids.GenerateTestID() // peer wrapper of inner7 (ABSENT)
blk8 := ids.GenerateTestID()
dag := wrappedAncestry{
wrapperB6: {parent: tipOuter, height: 6, canonical: inner6, parentCanonical: tipCanon},
// local wrapperB7 exists, but its recorded outer parent is the ABSENT wrapperA6.
wrapperB7: {parent: wrapperA6, height: 7, canonical: inner7, parentCanonical: inner6},
blk8: {parent: wrapperA7, height: 8, canonical: inner8, parentCanonical: inner7},
}
cert := Cert{Block: blk8, Parent: wrapperA7, ParentCanonical: inner7, Height: 8, Canonical: inner8}
_, plan, err := Finalize(led, cert, dag)
if err != nil {
t.Fatalf("must re-resolve the second absent alias via threaded hint, got err=%v", err)
}
if len(plan.Accept) != 3 {
t.Fatalf("expected 3-step accept, got %v", plan.Accept)
}
if plan.Accept[0] != wrapperB6 {
t.Fatalf("lowest accept step must be local wrapperB6, got %v", plan.Accept[0])
}
}
// ---------------------------------------------------------------------------
// #7 SEEDING — empty hint must degrade to the OLD fail-closed defer, never a wrong
// collapse. cert.ParentCanonical is Empty AND the certified block's recorded
// parentCanonical is Empty, so no hint exists. Even though a canonically-EQUIVALENT
// wrapper is present, the walk must NOT collapse it (no attested inner identity to
// key on) — it must DEFER.
// ---------------------------------------------------------------------------
func TestAdv_EmptyHint_DefersNeverWrongCollapse(t *testing.T) {
tipOuter := ids.GenerateTestID()
tipCanon := ids.GenerateTestID()
led := seedLedger(tipOuter, tipCanon, 5)
inner6 := ids.GenerateTestID()
inner7 := ids.GenerateTestID()
wrapperB6 := ids.GenerateTestID()
wrapperA6 := ids.GenerateTestID() // absent
blk7 := ids.GenerateTestID()
dag := wrappedAncestry{
// A local wrapper of inner6 IS present and chains to the tip...
wrapperB6: {parent: tipOuter, height: 6, canonical: inner6, parentCanonical: tipCanon},
// ...but the certified block records EMPTY parentCanonical (bare/degenerate),
// so there is no attested inner id to collapse wrapperA6 onto.
blk7: {parent: wrapperA6, height: 7, canonical: inner7, parentCanonical: ids.Empty},
}
// Cert also omits ParentCanonical (Empty).
cert := Cert{Block: blk7, Parent: wrapperA6, ParentCanonical: ids.Empty, Height: 7, Canonical: inner7}
_, _, err := Finalize(led, cert, dag)
if !errors.Is(err, ErrAncestorNotTracked) {
t.Fatalf("empty hint must DEFER (ErrAncestorNotTracked, old outer-id behavior), got err=%v", err)
}
}
// ---------------------------------------------------------------------------
// #1 BINDING BOUNDARY — a block at the right height with a DIFFERENT canonical than
// the hint must NEVER be collapsed. This proves the stand-in is keyed strictly on
// inner-execution identity: a divergent execution (different canonical) is invisible
// to WrapperByCanonical, so the walk defers rather than standing in a fork.
// ---------------------------------------------------------------------------
func TestAdv_DivergentCanonicalNeverCollapsed(t *testing.T) {
tipOuter := ids.GenerateTestID()
tipCanon := ids.GenerateTestID()
led := seedLedger(tipOuter, tipCanon, 5)
inner6 := ids.GenerateTestID() // the TRUE height-6 execution the cert attests
inner6evil := ids.GenerateTestID() // a DIFFERENT height-6 execution
inner7 := ids.GenerateTestID()
wrapperEvil6 := ids.GenerateTestID() // wrapper of the WRONG execution, chains to tip
wrapperA6 := ids.GenerateTestID() // absent wrapper of the TRUE inner6
blk7 := ids.GenerateTestID()
dag := wrappedAncestry{
// The node holds ONLY a wrapper of a DIFFERENT execution at height 6.
wrapperEvil6: {parent: tipOuter, height: 6, canonical: inner6evil, parentCanonical: tipCanon},
// Cert attests parentCanonical=inner6 (the TRUE execution), which the node lacks.
blk7: {parent: wrapperA6, height: 7, canonical: inner7, parentCanonical: inner6},
}
cert := Cert{Block: blk7, Parent: wrapperA6, ParentCanonical: inner6, Height: 7, Canonical: inner7}
_, plan, err := Finalize(led, cert, dag)
// MUST NOT collapse the divergent wrapperEvil6 (canonical inner6evil != attested inner6).
if err == nil {
for _, id := range plan.Accept {
if id == wrapperEvil6 {
t.Fatalf("FORK: collapsed a DIVERGENT-execution wrapper (inner6evil) in place of the "+
"attested inner6 — two nodes would finalize different state at height 6")
}
}
t.Fatalf("expected a DEFER (node lacks the attested inner6), but Finalize succeeded: Accept=%v", plan.Accept)
}
if !errors.Is(err, ErrAncestorNotTracked) {
t.Fatalf("must DEFER with ErrAncestorNotTracked (node holds no wrapper of the attested inner6), got %v", err)
}
}
// ---------------------------------------------------------------------------
// #3 EQUIVOCATION — a fork attempt at a height that was finalized as an INTERMEDIATE
// step (via alias collapse) must still trip the equivocation guard. Finalize a
// 2-step path [inner6@6, inner7@7] via alias collapse; then present a cert at height
// 6 with a DIFFERENT canonical. byHeight[6] was recorded during the intermediate
// walk, so the guard MUST fire ErrHeightAlreadyFinalized.
// ---------------------------------------------------------------------------
func TestAdv_EquivocationAtIntermediateHeight_StillCaught(t *testing.T) {
tipOuter := ids.GenerateTestID()
tipCanon := ids.GenerateTestID()
led := seedLedger(tipOuter, tipCanon, 5)
inner6 := ids.GenerateTestID()
inner7 := ids.GenerateTestID()
wrapperB6 := ids.GenerateTestID()
wrapperA6 := ids.GenerateTestID() // absent
blk7 := ids.GenerateTestID()
dag := wrappedAncestry{
wrapperB6: {parent: tipOuter, height: 6, canonical: inner6, parentCanonical: tipCanon},
blk7: {parent: wrapperA6, height: 7, canonical: inner7, parentCanonical: inner6},
}
cert := Cert{Block: blk7, Parent: wrapperA6, ParentCanonical: inner6, Height: 7, Canonical: inner7}
next, _, err := Finalize(led, cert, dag)
if err != nil {
t.Fatalf("setup finalize failed: %v", err)
}
// height 6 was finalized as an INTERMEDIATE step; byHeight[6] must equal inner6.
// Now a conflicting cert at height 6 with a DIFFERENT canonical must be refused.
inner6fork := ids.GenerateTestID()
forkBlk6 := ids.GenerateTestID()
forkCert := Cert{Block: forkBlk6, Parent: tipOuter, ParentCanonical: tipCanon, Height: 6, Canonical: inner6fork}
_, _, ferr := Finalize(next, forkCert, dag)
if !errors.Is(ferr, ErrHeightAlreadyFinalized) {
t.Fatalf("a conflicting cert at a previously-INTERMEDIATE height must trip the equivocation "+
"guard (ErrHeightAlreadyFinalized); byHeight[6] recording is what makes this safe. got err=%v", ferr)
}
// And an IDEMPOTENT re-cert of the SAME intermediate canonical must be a clean no-op.
sameCert := Cert{Block: wrapperB6, Parent: tipOuter, ParentCanonical: tipCanon, Height: 6, Canonical: inner6}
if _, _, serr := Finalize(next, sameCert, dag); serr != nil {
t.Fatalf("idempotent re-finalize of the same intermediate canonical must be a no-op, got %v", serr)
}
}
// ---------------------------------------------------------------------------
// #3 FORK ATTEMPT — a cert whose ancestry threads a DIFFERENT execution at an
// already-finalized height must NOT finalize (must hit ConflictsWithFinalizedBranch),
// even when a divergent wrapper is tracked. Tip finalized @6 = inner6. A cert for
// blk7@7 claims to extend inner6 BUT the only height-6 wrapper it can reach diverges.
// ---------------------------------------------------------------------------
func TestAdv_CertThreadingDivergentFinalizedHeight_Refused(t *testing.T) {
tipOuter := ids.GenerateTestID()
inner5 := ids.GenerateTestID()
// Finalize height 6 = inner6 first, so led.height=6, byHeight[6]=inner6.
led := seedLedger(tipOuter, inner5, 5)
inner6 := ids.GenerateTestID()
wrap6 := ids.GenerateTestID()
dag0 := wrappedAncestry{
wrap6: {parent: tipOuter, height: 6, canonical: inner6, parentCanonical: inner5},
}
led, _, err := Finalize(led, Cert{Block: wrap6, Parent: tipOuter, ParentCanonical: inner5, Height: 6, Canonical: inner6}, dag0)
if err != nil {
t.Fatalf("setup: %v", err)
}
// Now a cert for blk8@8 whose parentCanonical chain reaches a height-7 block whose
// parent is a DIFFERENT height-6 execution (inner6evil) — a branch that does NOT
// descend from the finalized inner6. Must be refused, never finalized.
inner6evil := ids.GenerateTestID()
inner7 := ids.GenerateTestID()
inner8 := ids.GenerateTestID()
evil6 := ids.GenerateTestID()
blk7 := ids.GenerateTestID()
blk8 := ids.GenerateTestID()
dag := wrappedAncestry{
wrap6: {parent: tipOuter, height: 6, canonical: inner6, parentCanonical: inner5},
// a divergent height-6 wrapper the attacker planted (does NOT descend from tip's finalized branch cleanly)
evil6: {parent: tipOuter, height: 6, canonical: inner6evil, parentCanonical: inner5},
blk7: {parent: evil6, height: 7, canonical: inner7, parentCanonical: inner6evil},
blk8: {parent: blk7, height: 8, canonical: inner8, parentCanonical: inner7},
}
cert := Cert{Block: blk8, Parent: blk7, ParentCanonical: inner7, Height: 8, Canonical: inner8}
_, plan, cerr := Finalize(led, cert, dag)
if cerr == nil {
// If it "succeeded", it must at least NOT have finalized through the divergent evil6.
for _, id := range plan.Accept {
if id == evil6 {
t.Fatalf("FORK: finalized through a divergent height-6 execution (inner6evil) at an "+
"already-finalized height (inner6). Accept=%v", plan.Accept)
}
}
t.Fatalf("a cert threading a divergent already-finalized height must be REFUSED, got Accept=%v", plan.Accept)
}
if !errors.Is(cerr, ErrHeightAlreadyFinalized) && !errors.Is(cerr, ErrConflictsWithFinalizedBranch) {
t.Fatalf("expected equivocation/conflict refusal, got %v", cerr)
}
}
// ---------------------------------------------------------------------------
// #2 PRUNING — the rebased parentID must never place a block in BOTH Accept and
// Reject, and the finalized canonical must be exact. A LOCAL sibling of the certified
// block (child of the local wrapper) is the legitimate reject target.
// ---------------------------------------------------------------------------
func TestAdv_PruningRebase_NoAcceptRejectOverlap(t *testing.T) {
tipOuter := ids.GenerateTestID()
tipCanon := ids.GenerateTestID()
led := seedLedger(tipOuter, tipCanon, 5)
inner6 := ids.GenerateTestID()
inner7 := ids.GenerateTestID()
wrapperB6 := ids.GenerateTestID()
wrapperA6 := ids.GenerateTestID() // absent
blk7 := ids.GenerateTestID()
localSibling7 := ids.GenerateTestID() // a DIFFERENT height-7 block extending the LOCAL wrapperB6
dag := wrappedAncestry{
wrapperB6: {parent: tipOuter, height: 6, canonical: inner6, parentCanonical: tipCanon},
blk7: {parent: wrapperA6, height: 7, canonical: inner7, parentCanonical: inner6},
localSibling7: {parent: wrapperB6, height: 7, canonical: ids.GenerateTestID(), parentCanonical: inner6},
}
cert := Cert{Block: blk7, Parent: wrapperA6, ParentCanonical: inner6, Height: 7, Canonical: inner7}
_, plan, err := Finalize(led, cert, dag)
if err != nil {
t.Fatalf("finalize failed: %v", err)
}
acc := map[ids.ID]bool{}
for _, id := range plan.Accept {
acc[id] = true
}
for _, id := range plan.Reject {
if acc[id] {
t.Fatalf("SAFETY: block %s is in BOTH Accept and Reject", id)
}
}
// The local sibling (child of wrapperB6, different canonical) is the legit reject.
rejected := map[ids.ID]bool{}
for _, id := range plan.Reject {
rejected[id] = true
}
if !rejected[localSibling7] {
t.Logf("note: localSibling7 not pruned (Reject=%v) — acceptable if it is not a child of the "+
"rebased local wrapper, but the losing branch then lingers as (harmless) undecided garbage", plan.Reject)
}
// The certified block must be accepted; the tip must advance to inner7.
if !acc[blk7] {
t.Fatalf("certified block blk7 must be accepted, Accept=%v", plan.Accept)
}
}
// ---------------------------------------------------------------------------
// #4 WRONG-BRANCH STAND-IN — a resolved wrapper whose outer parent does NOT reach the
// finalized tip (it reaches a DIFFERENT block at the finalized height) must be REFUSED
// with ErrConflictsWithFinalizedBranch, never finalized. This confirms a mis-branched
// stand-in fails closed (safety), it cannot fork.
func TestAdv_WrongBranchStandIn_RefusedNotForked(t *testing.T) {
tipOuter := ids.GenerateTestID()
tipCanon := ids.GenerateTestID()
led := seedLedger(tipOuter, tipCanon, 5)
inner6 := ids.GenerateTestID()
inner7 := ids.GenerateTestID()
wrongParent5 := ids.GenerateTestID() // a height-5 block that is NOT the finalized tip
wrapperB6 := ids.GenerateTestID() // resolves for inner6 but chains to wrongParent5
wrapperA6 := ids.GenerateTestID() // absent
blk7 := ids.GenerateTestID()
dag := wrappedAncestry{
// This wrapper of inner6 does NOT descend from the finalized tip.
wrapperB6: {parent: wrongParent5, height: 6, canonical: inner6, parentCanonical: ids.GenerateTestID()},
wrongParent5: {parent: ids.GenerateTestID(), height: 5, canonical: ids.GenerateTestID(), parentCanonical: ids.Empty},
blk7: {parent: wrapperA6, height: 7, canonical: inner7, parentCanonical: inner6},
}
cert := Cert{Block: blk7, Parent: wrapperA6, ParentCanonical: inner6, Height: 7, Canonical: inner7}
_, plan, err := Finalize(led, cert, dag)
if err == nil {
t.Fatalf("a stand-in that does not descend from the finalized tip must be REFUSED, got Accept=%v", plan.Accept)
}
if !errors.Is(err, ErrConflictsWithFinalizedBranch) {
t.Fatalf("expected ErrConflictsWithFinalizedBranch for a mis-branched stand-in, got %v", err)
}
}
@@ -0,0 +1,187 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// intermediate_sibling_livelock_test.go — REGRESSION GUARD for the v1.36 "Nova"
// finality LIVELOCK under saturation (testnet C-Chain 96368, tip frozen at 235).
//
// THE LIVE FAILURE. Under sustained load a verified α-of-K cert forms for a block a
// few heights above the finalized tip (e.g. 238 over tip 235). Every receiving node
// refuses it with ErrAncestorNotTracked on ONE specific intermediate OUTER envelope
// (`3vgfhXSS`) between the tip and the certified block, the self-heal fires an
// ancestor fetch, and the ancestor NEVER lands — a permanent livelock (~1174
// refusals/min, EVM tip frozen ~2h). Safety held (no fork); the guard chose halt.
//
// THE SUB-CAUSE (this test pins it). ledger.go `pathFromTip` walks the certified
// block's ancestry to the finalized tip by RESOLVING EACH INTERMEDIATE BY ITS OUTER
// ENVELOPE ID (`dag.Parent(cur)` where `cur` is an outer id). It has NO canonical
// fallback. Under load the network tracks DIFFERENT proposervm wrappers of the SAME
// inner execution block at an intermediate height (aliases, not a fork — heavy
// blocks + any proposer flap induce this). A cert whose ancestry threads through the
// PEER's wrapper (wrapperA6) cannot be applied by a node that holds the identical
// inner block under ITS OWN wrapper (wrapperB6): the walk demands the exact outer id
// wrapperA6, which the node lacks and which no reachable peer may retain. The
// self-heal fetches wrapperA6 forever.
//
// This is the SAME nondeterminism class as the mainnet-644 sibling storm. The 644
// fix canonicalized the VOTE-PLACEMENT path (convergedWinnerAtHeightLocked /
// canonicalRep, engine.go) and the CERT'S OWN block (finalizeLocalAliasFromVerified-
// Cert / pendingByCanonicalLocked, topology.go) — but it did NOT canonicalize the
// INTERMEDIATE-ANCESTOR walk in pathFromTip. v1.36's Tendermint-braid rip did not
// touch this seam (the Nova sampler is not the decider yet; the cert still drives
// accept), so the residual outer-id ancestry resolution survives into v1.36.0.
//
// The pure fold (ledger.go Finalize) is the exact and deterministic locus of the
// bug, so these tests drive it directly — no network, no goroutines, no timing.
package chain
import (
"errors"
"testing"
"github.com/luxfi/ids"
)
// wrapperNode is one tracked block in the read-only ancestry: its outer parent, its
// own height, its inner execution commitment (canonical), and — recorded but IGNORED
// by the current outer-id-only pathFromTip — the inner commitment of its parent. A
// canonical-aware walk consults parentCanonical to collapse sibling wrappers; the
// current fold does not, which is the bug under test.
type wrapperNode struct {
parent ids.ID
height uint64
canonical ids.ID
parentCanonical ids.ID
}
// wrappedAncestry is a read-only Ancestry over an explicit wrapper map, so the pure
// fold can be driven with a partial tree that models sibling-wrapper divergence.
type wrappedAncestry map[ids.ID]wrapperNode
func (w wrappedAncestry) Parent(id ids.ID) (ids.ID, uint64, ids.ID, ids.ID, bool) {
n, ok := w[id]
return n.parent, n.height, n.canonical, n.parentCanonical, ok
}
func (w wrappedAncestry) Children(id ids.ID) []ids.ID {
var out []ids.ID
for cid, n := range w {
if n.parent == id {
out = append(out, cid)
}
}
return out
}
func (w wrappedAncestry) WrapperByCanonical(canonical ids.ID, height uint64) (ids.ID, bool) {
for id, n := range w {
if n.height == height && n.canonical == canonical {
return id, true
}
}
return ids.Empty, false
}
// TestFinalize_IntermediateSiblingWrapper_Livelocks is the repro. This node is
// certified through height 5. A verified cert selects the inner execution block at
// height 7 whose recorded outer parent is wrapperA6 (a PEER's proposervm wrapper of
// the height-6 execution). This node holds the IDENTICAL height-6 execution under its
// OWN wrapper wrapperB6, which chains to the finalized tip. Finality MUST advance:
// the node possesses the whole certified inner chain, just under its own envelopes,
// and the cert is a verified α-of-K witness. It must NOT wedge waiting to fetch a
// redundant outer-envelope alias (wrapperA6) that no reachable peer may retain.
//
// On current HEAD Finalize returns ErrAncestorNotTracked{missing: wrapperA6} — the
// livelock. This test asserts the invariant (finality under load) and therefore
// FAILS on HEAD, pinning the regression until the intermediate walk is canonicalized.
func TestFinalize_IntermediateSiblingWrapper_Livelocks(t *testing.T) {
tipOuter := ids.GenerateTestID()
tipCanon := ids.GenerateTestID()
led := seedLedger(tipOuter, tipCanon, 5) // certified through height 5
// Height-6 execution, wrapped two ways — ALIASES of one inner block, not a fork.
inner6 := ids.GenerateTestID()
wrapperA6 := ids.GenerateTestID() // the cert's ancestry names THIS wrapper (a peer's)
wrapperB6 := ids.GenerateTestID() // THIS node tracks THIS wrapper of the same inner6
// Height-7 execution — the certified block.
inner7 := ids.GenerateTestID()
blk7 := ids.GenerateTestID()
dag := wrappedAncestry{
// The node's OWN height-6 wrapper, chaining to the finalized tip.
wrapperB6: {parent: tipOuter, height: 6, canonical: inner6, parentCanonical: tipCanon},
// The certified height-7 block; its recorded outer parent is the peer's
// wrapperA6 (identical inner6), a canonical-equivalent sibling of wrapperB6.
blk7: {parent: wrapperA6, height: 7, canonical: inner7, parentCanonical: inner6},
// wrapperA6 is intentionally ABSENT: this node never tracked the peer's envelope.
}
cert := Cert{Block: blk7, Parent: wrapperA6, Height: 7, Canonical: inner7}
_, plan, err := Finalize(led, cert, dag)
if errors.Is(err, ErrAncestorNotTracked) {
var ant *AncestorNotTracked
_ = errors.As(err, &ant)
t.Fatalf(`LIVELOCK REPRO (mainnet-644 sibling class in the v1.36 intermediate-ancestor walk):
Finalize refused a VERIFIED cert for height 7 with ErrAncestorNotTracked (missing=%v),
even though this node HOLDS the certified block's parent execution (inner6) under a
canonical-equivalent wrapper (wrapperB6=%s) that chains to the finalized tip.
pathFromTip (ledger.go) resolves the intermediate ancestor by OUTER id ONLY, so it
demands the exact envelope wrapperA6=%s the cert named — which this node lacks and
which no reachable peer may retain — instead of collapsing the sibling wrapper by
inner identity (the way convergedWinnerAtHeightLocked and finalizeLocalAliasFrom-
VerifiedCert already do everywhere else). The self-heal fetch then targets wrapperA6
forever; the ancestor never lands; the EVM tip freezes. err=%v`,
ant.Missing, wrapperB6, wrapperA6, err)
}
if err != nil {
t.Fatalf("unexpected error finalizing the sibling-wrapper cert: %v", err)
}
// Post-fix expectation: the 2-step canonical path is accepted using the node's
// LOCAL wrappers of the certified inner chain (wrapperB6 @6, then blk7 @7).
if len(plan.Accept) != 2 {
t.Fatalf("expected the canonical path {wrapperB6@6, blk7@7} (2 blocks) to be accepted, got Accept=%v", plan.Accept)
}
if plan.Accept[len(plan.Accept)-1] != blk7 {
t.Fatalf("the certified block %s must be the top of the accepted path, got %v", blk7, plan.Accept)
}
for _, id := range plan.Accept {
if id == wrapperA6 {
t.Fatalf("accepted the UNHELD peer envelope wrapperA6=%s; finality must accept the LOCAL wrapper the VM holds", wrapperA6)
}
}
}
// TestFinalize_IntermediateGenuinelyAbsent_StillDefers is the SAFETY BOUNDARY (the
// contrast that keeps the fix honest). Here the node holds NO wrapper of the height-6
// execution at all — not even a sibling. This is a genuine behind-node gap, and the
// fold MUST still fail-closed with ErrAncestorNotTracked (fetch-and-retry), never
// invent an execution it does not have. This test PASSES on HEAD and MUST keep
// passing after the fix: the fix may only collapse canonical-EQUIVALENT siblings, it
// may never accept a certified branch whose inner ancestry the node cannot prove it
// holds.
func TestFinalize_IntermediateGenuinelyAbsent_StillDefers(t *testing.T) {
tipOuter := ids.GenerateTestID()
tipCanon := ids.GenerateTestID()
led := seedLedger(tipOuter, tipCanon, 5)
inner7 := ids.GenerateTestID()
blk7 := ids.GenerateTestID()
absentParent := ids.GenerateTestID() // height-6 ancestor whose inner block we do NOT hold in ANY wrapper
// The node tracks ONLY the certified block; it holds no height-6 wrapper.
dag := wrappedAncestry{
blk7: {parent: absentParent, height: 7, canonical: inner7, parentCanonical: ids.GenerateTestID()},
}
cert := Cert{Block: blk7, Parent: absentParent, Height: 7, Canonical: inner7}
_, _, err := Finalize(led, cert, dag)
if !errors.Is(err, ErrAncestorNotTracked) {
t.Fatalf("a genuinely-absent intermediate ancestor (NO local wrapper of its inner block) must "+
"DEFER with ErrAncestorNotTracked (fail-closed, fetch-and-retry), never finalize; got err=%v", err)
}
}
+59 -12
View File
@@ -193,12 +193,21 @@ func (l FinalityLedger) BuildAnchor() (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 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)
// Parent returns id's OUTER parent, id's OWN height, id's CANONICAL execution
// commitment, and the CANONICAL commitment of id's PARENT; 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. parentCanonical is carried so the ancestry
// walk can collapse the NEXT sibling wrapper by inner identity (see WrapperByCanonical).
Parent(id ids.ID) (parent ids.ID, height uint64, canonical, parentCanonical ids.ID, ok bool)
// Children returns the ids of every tracked block whose parent is id.
Children(id ids.ID) []ids.ID
// WrapperByCanonical resolves an inner execution commitment at a height to ANY
// locally-tracked OUTER wrapper of it (any alias). The ancestry walk uses it to stand
// a local wrapper in for the exact outer envelope a cert named — collapsing sibling
// wrappers (aliases, not forks) the way every other finality path does. It NEVER
// invents an execution the node does not hold: a miss (ok=false) preserves the
// fail-closed behind-node defer (ErrAncestorNotTracked, fetch and retry).
WrapperByCanonical(canonical ids.ID, height uint64) (id ids.ID, ok bool)
}
// Cert is the minimal finality subject — the block a quorum certificate selects,
@@ -210,11 +219,20 @@ type Ancestry interface {
// 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.
//
// ParentCanonical is the inner commitment of Parent (from the signed
// VotePosition.ParentCanonicalID). It lets the ancestry walk collapse a
// canonical-equivalent SIBLING WRAPPER in place of the exact outer envelope Parent
// names — the fix for the intermediate-ancestor livelock (a cert whose ancestry
// threads a wrapper the node holds under a DIFFERENT envelope must still finalize).
// Empty on a bare (non-wrapped) chain or the genesis finalize; the walk then stays
// outer-id only, byte-for-byte as before.
type Cert struct {
Block ids.ID
Parent ids.ID
Height uint64
Canonical ids.ID
Block ids.ID
Parent ids.ID
ParentCanonical ids.ID
Height uint64
Canonical ids.ID
}
// Plan is what Finalize decides and the engine applies to the VM and DAG. It mirrors
@@ -397,12 +415,40 @@ func pathFromTip(led FinalityLedger, cert Cert, dag Ancestry) ([]step, error) {
steps := []step{{id: cert.Block, height: cert.Height, parentID: cert.Parent, canonical: topCanonical}}
cur := cert.Parent
childHeight := cert.Height
// parentCanonHint is the INNER commitment the NEXT ancestor must equal, carried down
// the walk so a canonical-equivalent SIBLING WRAPPER can stand in for the exact outer
// envelope the cert named (the intermediate-ancestor livelock fix; mainnet-644 sibling
// class). Seeded from the cert's ParentCanonicalID, else from the certified block's own
// recorded parent-canonical. Empty (a bare, non-wrapped chain) ⇒ the walk stays
// outer-id only, byte-for-byte as before.
parentCanonHint := cert.ParentCanonical
if parentCanonHint == ids.Empty {
if _, _, _, pc, ok := dag.Parent(cert.Block); ok {
parentCanonHint = pc
}
}
for cur != led.tip {
parent, curHeight, curCanonical, ok := dag.Parent(cur)
parent, curHeight, curCanonical, curParentCanon, 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.
return nil, &AncestorNotTracked{Missing: cur, Target: cert.Block}
// The exact outer envelope is untracked. Before the fail-closed behind-node
// DEFER, try to stand a LOCAL wrapper of the SAME inner block (a
// canonical-equivalent alias) in its place — collapsing sibling wrappers the
// way convergedWinnerAtHeightLocked and finalizeLocalAliasFromVerifiedCert
// already do everywhere else. A genuinely-unheld inner block (no wrapper at
// all) still falls through to the DEFER, so safety is unchanged.
if parentCanonHint != ids.Empty {
if localID, found := dag.WrapperByCanonical(parentCanonHint, childHeight-1); found {
// Rebase the child's parent link onto the LOCAL wrapper so BOTH the
// accept path and the reject-subtree walk (losingSubtrees) key on the
// wrapper the VM actually holds, never the unheld outer alias.
steps[len(steps)-1].parentID = localID
cur = localID
parent, curHeight, curCanonical, curParentCanon, ok = dag.Parent(cur)
}
}
if !ok {
return nil, &AncestorNotTracked{Missing: cur, Target: cert.Block}
}
}
// Heights must strictly decrease toward the tip; a parent at/above its child's
// height is malformed linkage.
@@ -423,6 +469,7 @@ func pathFromTip(led FinalityLedger, cert Cert, dag Ancestry) ([]step, error) {
steps = append(steps, step{id: cur, height: curHeight, parentID: parent, canonical: stepCanonical})
cur = parent
childHeight = curHeight
parentCanonHint = curParentCanon
}
// Reverse to ascending height and assert contiguity with the frontier: the lowest
+15 -4
View File
@@ -28,13 +28,24 @@ type dagNode struct {
height uint64
}
func (d mapDAG) Parent(id ids.ID) (ids.ID, uint64, ids.ID, bool) {
func (d mapDAG) Parent(id ids.ID) (ids.ID, uint64, ids.ID, ids.ID, bool) {
n, ok := d[id]
if !ok {
return ids.Empty, 0, ids.Empty, false
return ids.Empty, 0, ids.Empty, ids.Empty, false
}
// This pure-fold harness has no inner/outer split: canonical == the block's own id.
return n.parent, n.height, id, true
// This pure-fold harness has no inner/outer split: canonical == the block's own id,
// so a block's parent-canonical is simply its parent id.
return n.parent, n.height, id, n.parent, true
}
// WrapperByCanonical: canonical == id in this bare harness, so the only wrapper of an
// inner id IS that id — resolvable only when actually tracked (a genuinely-missing block
// still misses, preserving the behind-node defer).
func (d mapDAG) WrapperByCanonical(canonical ids.ID, height uint64) (ids.ID, bool) {
if n, ok := d[canonical]; ok && n.height == height {
return canonical, true
}
return ids.Empty, false
}
func (d mapDAG) Children(id ids.ID) []ids.ID {
+27 -6
View File
@@ -394,16 +394,37 @@ 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, ids.ID, bool) {
func (a blocksAncestry) Parent(id ids.ID) (ids.ID, uint64, ids.ID, ids.ID, bool) {
b, ok := a.blocks[id]
if !ok {
return ids.Empty, 0, ids.Empty, false
return ids.Empty, 0, ids.Empty, ids.Empty, false
}
canonical := b.canonicalID
if canonical == ids.Empty {
canonical = b.id // bare block: canonical == outer
// canonicalRep/parentCanonicalRep fall back to the outer id for a bare (non-wrapped)
// block, so a chain with no inner/outer split is unchanged.
return b.parentID, b.height, b.canonicalRep(), b.parentCanonicalRep(), true
}
// WrapperByCanonical resolves an inner execution commitment at a height to a
// locally-tracked OUTER wrapper of it — any alias — so the finality walk can collapse a
// sibling wrapper in place of the exact envelope a cert named (the intermediate ancestor
// the node holds under a DIFFERENT proposervm wrapper). It prefers an already-accepted
// wrapper when several tracked aliases exist, so the walk lands on the block the VM
// committed. A miss (ok=false) means the node holds NO wrapper of that inner block,
// preserving the fail-closed behind-node defer (ErrAncestorNotTracked). This is a rare
// defer-path O(n) scan over the live block set, matching pendingByCanonicalLocked.
func (a blocksAncestry) WrapperByCanonical(canonical ids.ID, height uint64) (ids.ID, bool) {
var hit ids.ID
found := false
for id, b := range a.blocks {
if b.height != height || b.canonicalRep() != canonical {
continue
}
if b.accepted {
return id, true // the wrapper the VM actually committed — prefer it
}
hit, found = id, true
}
return b.parentID, b.height, canonical, true
return hit, found
}
func (a blocksAncestry) Children(id ids.ID) []ids.ID {
@@ -0,0 +1,107 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// wrapper_bycanonical_adversarial_test.go — RED-TEAM probes of the PRODUCTION
// blocksAncestry.WrapperByCanonical scan (topological.go), which the pure-fold
// harness stubs out. Targets: the height+canonicalRep filter is exact (no cross-height
// or cross-execution collapse), and the "prefer accepted" tiebreak lands on the
// committed wrapper.
package chain
import "testing"
import "github.com/luxfi/ids"
func mkBlock(id, parent ids.ID, height uint64, canonical, parentCanonical ids.ID, accepted bool) *Block {
return &Block{
id: id,
parentID: parent,
height: height,
canonicalID: canonical,
parentCanonicalID: parentCanonical,
accepted: accepted,
}
}
// #1/#4 — the scan matches ONLY on (height, canonicalRep). A block with a DIFFERENT
// canonical at the same height, or the SAME canonical at a different height, is never
// returned. This is the binding boundary in the real scan.
func TestAdvProd_WrapperByCanonical_ExactHeightAndCanonical(t *testing.T) {
tip := ids.GenerateTestID()
inner6 := ids.GenerateTestID()
inner6b := ids.GenerateTestID() // different execution
inner7 := ids.GenerateTestID()
wrap6 := ids.GenerateTestID()
wrap6diff := ids.GenerateTestID()
wrap7same := ids.GenerateTestID() // same canonical value as target but WRONG height
blocks := map[ids.ID]*Block{
wrap6: mkBlock(wrap6, tip, 6, inner6, tip, false),
wrap6diff: mkBlock(wrap6diff, tip, 6, inner6b, tip, false),
// Deliberately reuse inner6 as a canonical at height 7 to test the height filter.
wrap7same: mkBlock(wrap7same, wrap6, 7, inner6, inner6, false),
}
a := blocksAncestry{blocks: blocks}
// Exact match at height 6 returns wrap6 (the only inner6 wrapper at h6).
if got, ok := a.WrapperByCanonical(inner6, 6); !ok || got != wrap6 {
t.Fatalf("expected wrap6 for (inner6,6), got %v ok=%v", got, ok)
}
// A different canonical at h6 (inner7, no wrapper) misses.
if got, ok := a.WrapperByCanonical(inner7, 6); ok {
t.Fatalf("no wrapper of inner7 at h6 exists; must miss, got %v", got)
}
// SAME canonical value (inner6) but at height 7: must NOT return wrap6 (height filter);
// it returns wrap7same only because wrap7same is literally at h7 with canonical inner6.
if got, ok := a.WrapperByCanonical(inner6, 7); !ok || got != wrap7same {
t.Fatalf("height filter: (inner6,7) must return wrap7same, not the h6 wrapper, got %v ok=%v", got, ok)
}
// Cross-height leak check: (inner6,6) must never leak wrap7same.
if got, _ := a.WrapperByCanonical(inner6, 6); got == wrap7same {
t.Fatalf("cross-height leak: returned a height-7 wrapper for a height-6 query")
}
}
// #4 — "prefer accepted" tiebreak: when several aliases of one inner block are tracked,
// the accepted one (the wrapper the VM committed) is returned deterministically.
func TestAdvProd_WrapperByCanonical_PrefersAccepted(t *testing.T) {
tip := ids.GenerateTestID()
inner6 := ids.GenerateTestID()
accepted6 := ids.GenerateTestID()
pending6a := ids.GenerateTestID()
pending6b := ids.GenerateTestID()
blocks := map[ids.ID]*Block{
pending6a: mkBlock(pending6a, tip, 6, inner6, tip, false),
accepted6: mkBlock(accepted6, tip, 6, inner6, tip, true),
pending6b: mkBlock(pending6b, tip, 6, inner6, tip, false),
}
a := blocksAncestry{blocks: blocks}
// Repeat to defeat map-iteration randomness: accepted must ALWAYS win.
for i := 0; i < 64; i++ {
got, ok := a.WrapperByCanonical(inner6, 6)
if !ok || got != accepted6 {
t.Fatalf("iter %d: accepted wrapper must always be preferred, got %v ok=%v", i, got, ok)
}
}
}
// #1 — bare-block degrade: canonicalID Empty ⇒ canonicalRep falls back to the outer id,
// so a bare chain's WrapperByCanonical is keyed on the outer id (no two envelopes share
// it ⇒ collapse is inert), exactly as claimed.
func TestAdvProd_WrapperByCanonical_BareDegrade(t *testing.T) {
tip := ids.GenerateTestID()
bare := ids.GenerateTestID()
blocks := map[ids.ID]*Block{
bare: mkBlock(bare, tip, 6, ids.Empty, ids.Empty, false), // canonicalID Empty ⇒ rep == id
}
a := blocksAncestry{blocks: blocks}
if got, ok := a.WrapperByCanonical(bare, 6); !ok || got != bare {
t.Fatalf("bare block must resolve by its own outer id, got %v ok=%v", got, ok)
}
// A random inner id never matches a bare block (no aliasing on a bare chain).
if _, ok := a.WrapperByCanonical(ids.GenerateTestID(), 6); ok {
t.Fatalf("bare chain must not alias-collapse on an arbitrary canonical")
}
}