Files
node/vms/proposervm/missing_outer_anchor_test.go
zeekay 7cf87b230b node-unified is the node
The module already called itself github.com/luxfi/node while containing no node
in Go: cmd/luxd was twenty-one lines that printed the size of a few types, and
the Go column of the matrix measured a VM plugin. A repo that fetches the thing
it claims to be has not replaced it, so the Go sources move in — 1515 files,
the daemon and everything under it — and the build reaches outside this module
for nothing.

Rust and C++ sit beside it unchanged and are still measured against it by the
differential. That is the point of one repo: the reference implementation and
the two ports are the same checkout, so parity is a thing you can run rather
than a thing you assert.

Verified: builds, and the closure carries no lux-private — checked at build time
rather than believed. The daemon starts twelve chains on a local network
(P X C Q Z A B G K M F, plus D through its plugin) and reports healthy.
2026-09-07 16:15:25 -07:00

154 lines
6.6 KiB
Go

// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
// missing_outer_anchor_test.go — THE STRANDED-VALIDATOR DEFECT.
//
// height_backfill_test.go covers the case where the outer index is BEHIND the
// inner tip: an anchor exists, the heights disagree, and repair runs. This file
// covers the case one step worse, where the anchor is ABSENT entirely.
//
// How a live fleet reaches it: `admin_importChain` writes inner EVM blocks
// straight into the chain store and never produces a single proposervm envelope.
// Do that into a wiped node and the inner VM reports a healthy tip while
// State.GetLastAccepted() returns database.ErrNotFound. The pre-fix code read
// that ErrNotFound as "the underlying chain is the only chain and there is
// nothing to repair" and returned nil — so no backfill was recorded, no request
// was made, nothing was logged, and the node sat at its import height forever
// while its peers advanced. Nothing in its own state and nothing arriving on the
// wire could move it, and nothing it emitted said so.
//
// The distinction that must be preserved: a GENUINELY pre-fork chain also has no
// anchor, and it must still boot normally. The two are told apart by the fork
// height, which is persisted outside the last-accepted pointer.
package proposervm
import (
"context"
"testing"
)
// TestMissingOuterAnchor_PostFork_EntersBackfill is the regression for the
// stranded validator. Inner state exists, the fork height proves the chain is
// post-fork, and the outer anchor is gone. Boot must record a pending backfill —
// which also gates BuildBlock off, so a node that cannot name its own outer tip
// never proposes.
//
// FAILS on the pre-fix code (ErrNotFound -> return nil, pending == false).
func TestMissingOuterAnchor_PostFork_EntersBackfill(t *testing.T) {
ctx := context.Background()
ic := newInnerChain(t, 8)
vm := testVM(t, ic)
// Build a normal post-fork chain so the fork height is recorded the way a
// real validator records it.
acceptThroughProposervm(t, vm, ic, 8)
if _, err := vm.State.GetForkHeight(); err != nil {
t.Fatalf("precondition: a post-fork chain must have a recorded fork height: %v", err)
}
// DAMAGE: delete the outer anchor and the whole height index, leaving the
// inner VM at 8. This is the on-disk shape of an RLP import into a wiped
// node — inner history present, proposervm pointer absent.
if err := vm.State.DeleteLastAccepted(); err != nil {
t.Fatalf("DeleteLastAccepted: %v", err)
}
for h := uint64(1); h <= 8; h++ {
if err := vm.State.DeleteBlockIDAtHeight(h); err != nil {
t.Fatalf("DeleteBlockIDAtHeight(%d): %v", h, err)
}
}
if err := vm.db.Commit(); err != nil {
t.Fatalf("commit damage: %v", err)
}
// BOOT. Must not error — a node that cannot repair must still start, loudly,
// rather than killing the chain.
if err := vm.repairAcceptedChainByHeight(ctx); err != nil {
t.Fatalf("boot with a missing outer anchor must not fail: %v", err)
}
from, tip, pending := vm.NeedsOuterBackfill()
if !pending {
t.Fatal("MISSING-OUTER-ANCHOR BLIND SPOT: inner state exists at height 8 and the outer " +
"last-accepted pointer is absent, but boot recorded NO pending backfill. That is the " +
"silent 'nothing to repair' path that strands an imported validator at its import " +
"height forever — no backfill state, no request, no log.")
}
if tip != 8 {
t.Fatalf("backfill target must be the inner tip 8, got %d", tip)
}
if from == 0 || from > tip {
t.Fatalf("first missing height must be within (0, tip]; got from=%d tip=%d", from, tip)
}
// The build gate is the safety half: a node with a hole must never propose.
if !vm.outerBackfillPending() {
t.Fatal("a pending backfill must gate BuildBlock off")
}
}
// TestMissingOuterAnchor_PreFork_BootsClean is the other side of the branch: a
// chain that has never crossed the proposervm activation boundary has no anchor
// and no fork height, and must still boot with nothing pending. Without this,
// the fix above would turn every legitimately pre-fork chain into a permanently
// build-gated one.
func TestMissingOuterAnchor_PreFork_BootsClean(t *testing.T) {
ctx := context.Background()
ic := newInnerChain(t, 5)
vm := testVM(t, ic)
// No acceptThroughProposervm: nothing post-fork ever happened, so neither the
// anchor nor the fork height exists — exactly a pre-fork chain at boot.
if err := vm.repairAcceptedChainByHeight(ctx); err != nil {
t.Fatalf("a pre-fork chain must boot cleanly: %v", err)
}
if _, _, pending := vm.NeedsOuterBackfill(); pending {
t.Fatal("a pre-fork chain has no outer envelopes to backfill — it must not be build-gated")
}
}
// TestDanglingOuterAnchor_StartsInBackfill pins how a truncated index boots.
//
// A snapshot clone (or any truncated copy) can carry the proposervm's
// last-accepted POINTER while the envelope it names is absent. Failing VM init
// on that state — "failed to get last accepted block" — makes every restart
// fatal on a chain that is otherwise intact, and the only way off such a node is
// a restart. The damage is per-node and invisible to every other node running
// the same binary, so it reads as a bad build rather than as local damage, and a
// fleet holding it cannot take a new binary for any other reason either.
//
// Starting degraded is strictly better: enterOuterBackfill makes the damage
// visible AND gates BuildBlock off, so the node cannot propose while its index
// is rebuilt.
func TestDanglingOuterAnchor_StartsInBackfill(t *testing.T) {
ctx := context.Background()
ic := newInnerChain(t, 6)
vm := testVM(t, ic)
acceptThroughProposervm(t, vm, ic, 6)
// DAMAGE: keep the last-accepted POINTER, destroy the block it names.
anchor, err := vm.State.GetLastAccepted()
if err != nil {
t.Fatalf("precondition: a post-fork chain must have an anchor: %v", err)
}
if err := vm.State.DeleteBlock(anchor); err != nil {
t.Fatalf("DeleteBlock(%s): %v", anchor, err)
}
if err := vm.db.Commit(); err != nil {
t.Fatalf("commit damage: %v", err)
}
// Boot MUST NOT fail. This is the whole point: a dangling anchor must not be
// able to kill chain init.
if err := vm.repairAcceptedChainByHeight(ctx); err != nil {
t.Fatalf("DANGLING ANCHOR KILLED INIT: %v — this is the failure that made every restart fatal "+
"and stranded mainnet on an old version; it must start degraded instead", err)
}
// And it must start DEGRADED, not pretend to be healthy.
if !vm.outerBackfillPending() {
t.Fatal("a dangling anchor must enter backfill so BuildBlock is gated off — a node that cannot " +
"retrieve its own outer tip must never propose")
}
}