mirror of
https://github.com/luxfi/consensus.git
synced 2026-09-24 00:26:58 +00:00
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]>
188 lines
8.9 KiB
Go
188 lines
8.9 KiB
Go
// 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)
|
||
}
|
||
}
|