mirror of
https://github.com/luxfi/evm.git
synced 2026-09-24 00:27:44 +00:00
core: C-Chain accepted-tip rewind recovery + RLP-seam adversarial test
FinalizeRewind (core/rewind.go) rolls the EVM accepted tip DOWN to a consensus-finalized height — offline recovery for the proposervm/EVM height inconsistency (mainnet incident 1085xxx: inner EVM ran ahead of finality after RLP import->produce). Fail-closed: asserts head==lastAccepted==target with state present and canonical-above removed; INVALIDATES (never repoints) the flat snapshot so it rebuilds from targets trie (repointing would serve the discarded tips balances fleet-wide). Zero-re-exec target via HighestCommittedStateAtOrBelow. rlp_seam_red_test.go: RED proof of the import->produce seam (the exact regenesis flow) — produced blocks chain contiguously onto the imported RLP tip with no gap/divergence. Passes on real data (lux-mainnet 1083347..1083873 contiguous; zoo 12301 blocks). rewind_test reproduces the recovery at unit scale. All pass CGO off. (cmd/repair-cchain, the offline CLI wrapper, is unfinished WIP and NOT included — its capability lives here in FinalizeRewind.) Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+154
@@ -0,0 +1,154 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/luxfi/evm/plugin/evm/customrawdb"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/geth/core/rawdb"
|
||||
"github.com/luxfi/geth/core/types"
|
||||
log "github.com/luxfi/log"
|
||||
)
|
||||
|
||||
// FinalizeRewind persists and cleans up an accepted-tip rewind AFTER the chain
|
||||
// has been (re)constructed at [target].
|
||||
//
|
||||
// It is the OFFLINE recovery for a proposervm/EVM height inconsistency. When the
|
||||
// inner EVM ran ahead of consensus finality — the proposervm never created the
|
||||
// outer wrappers for the heights above its finality tip — the wrapping proposervm
|
||||
// refuses to boot (vms/proposervm/vm.go: heightBehind is a fail-closed fatal,
|
||||
// never a silent finality reset). Rolling the EVM accepted tip back DOWN to the
|
||||
// consensus finalized height [target] makes the two indices agree, so the
|
||||
// proposervm reconciles on the next boot as heightMatch — or, for a node whose
|
||||
// proposervm sits above target, as heightAhead, which self-heals by rolling ITS
|
||||
// pointer back to the inner tip. No re-genesis: all balances at height <= target
|
||||
// are preserved.
|
||||
//
|
||||
// The head-rewind itself is performed by coreth's own boot path: constructing the
|
||||
// chain with NewBlockChain(..., lastAcceptedHash = target's hash, ...) runs
|
||||
// loadLastState, which sets the finalized floor to target FIRST and then reorgs
|
||||
// the head down to it — deleting the canonical hash->number mappings for every
|
||||
// discarded run-ahead block — and re-derives target's state (reprocessState). We
|
||||
// deliberately reuse that exact, tested path rather than a bespoke reorg (a
|
||||
// direct SetPreference below the current accepted tip is correctly refused:
|
||||
// "cannot orphan finalized block"). This method finalizes that reconstruction on
|
||||
// disk:
|
||||
//
|
||||
// - SetLastAcceptedBlockDirect(target): persist head + last-accepted + the
|
||||
// ACCEPTOR TIP to target. The reorg leaves the on-disk acceptor tip at the
|
||||
// discarded run-ahead tip, from which reprocessState re-seeds its walk-back on
|
||||
// the next boot — a deep rewind then fails "required historical state
|
||||
// unavailable". This realigns acceptorTip == head == lastAccepted == target.
|
||||
// - ForceCommitState(target): persist target's state root. Under the hash
|
||||
// scheme Stop does not journal recent roots, so an accepted-but-uncommitted
|
||||
// root would be lost on the next restart — the exact failure this recovery
|
||||
// must avoid. Production runs with pruning, so target's root is usually not
|
||||
// otherwise committed.
|
||||
// - INVALIDATE the flat snapshot (delete its markers) so coreth rebuilds it from
|
||||
// target's trie on the next boot. Repointing the markers to target would be
|
||||
// WRONG: production boots SkipVerify, so matching-but-mislabeled markers make
|
||||
// coreth trust the discarded tip's flat data and serve wrong balances at target.
|
||||
// - CleanBlockRootsAboveLastAccepted(): drop any orphaned processing roots.
|
||||
//
|
||||
// It asserts the reconstruction actually landed at target with state present, so
|
||||
// a wrong target / failed reprocess is caught here — before the caller commits
|
||||
// the VM-level last-accepted pointer — rather than on the live node's boot.
|
||||
//
|
||||
// The caller MUST also roll the VM-level last-accepted pointer (plugin/evm
|
||||
// acceptedBlockDB[lastAcceptedKey]) to target's hash, so readLastAccepted returns
|
||||
// target on the next boot. The two writes together are what the node reads.
|
||||
func (bc *BlockChain) FinalizeRewind(target uint64) (*types.Block, error) {
|
||||
head := bc.CurrentBlock()
|
||||
if head.Number.Uint64() != target {
|
||||
return nil, fmt.Errorf(
|
||||
"chain is at height %d, not the rewind target %d: construct the chain with lastAcceptedHash = target's hash (loadLastState performs the head reorg) before FinalizeRewind",
|
||||
head.Number.Uint64(), target)
|
||||
}
|
||||
tb := bc.GetBlockByNumber(target)
|
||||
if tb == nil {
|
||||
return nil, fmt.Errorf("canonical block at target %d not found", target)
|
||||
}
|
||||
if tb.Hash() != head.Hash() {
|
||||
return nil, fmt.Errorf("canonical block at %d (%s) does not match head (%s)", target, tb.Hash(), head.Hash())
|
||||
}
|
||||
if la := bc.LastAcceptedBlock(); la == nil || la.NumberU64() != target || la.Hash() != tb.Hash() {
|
||||
return nil, fmt.Errorf("last-accepted is not target %d (%s)", target, tb.Hash())
|
||||
}
|
||||
|
||||
log.Info("FinalizeRewind: begin", "target", target, "hash", tb.Hash(), "root", tb.Root())
|
||||
|
||||
// Persist head + last-accepted + ACCEPTOR TIP to target on disk. The
|
||||
// reconstruct reorg moved the in-memory markers and the on-disk head hash, but
|
||||
// NOT the on-disk acceptor tip — which still points at the discarded run-ahead
|
||||
// tip. reprocessState re-seeds its walk-back from the acceptor tip on the next
|
||||
// boot, so a stale one makes a deep rewind fail "required historical state
|
||||
// unavailable (reexec=...)". SetLastAcceptedBlockDirect writes the acceptor tip
|
||||
// (via batchBlockAcceptedIndices) together with the head pointers, so
|
||||
// acceptorTip == head == lastAccepted == target.
|
||||
if err := bc.SetLastAcceptedBlockDirect(tb); err != nil {
|
||||
return nil, fmt.Errorf("persist head/last-accepted/acceptor-tip at %d (%s): %w", target, tb.Hash(), err)
|
||||
}
|
||||
|
||||
// Persist target's state root so it survives the next restart under pruning.
|
||||
if err := bc.ForceCommitState(tb); err != nil {
|
||||
return nil, fmt.Errorf("commit state at %d (%s): %w", target, tb.Hash(), err)
|
||||
}
|
||||
if !bc.HasState(tb.Root()) {
|
||||
return nil, fmt.Errorf("state root %s for height %d not present after commit", tb.Root(), target)
|
||||
}
|
||||
|
||||
// INVALIDATE the flat snapshot — do NOT repoint it to target. After a rewind the
|
||||
// on-disk snapshot disk-layer still holds the DISCARDED tip's account/storage
|
||||
// data. Production boots with SnapshotVerify=false (SkipVerify), so writing
|
||||
// target's markers would make loadSnapshot find MATCHING markers and TRUST the
|
||||
// mislabeled layer — serving the discarded tip's balances as target's state,
|
||||
// fleet-wide and undetectable by a trie-only check. Deleting the markers makes
|
||||
// loadSnapshot fail on the next boot, which triggers a clean Rebuild from
|
||||
// target's committed trie (async; state reads fall back to the trie meanwhile).
|
||||
batch := bc.db.NewBatch()
|
||||
rawdb.DeleteSnapshotRoot(batch)
|
||||
customrawdb.DeleteSnapshotBlockHash(batch)
|
||||
if err := batch.Write(); err != nil {
|
||||
return nil, fmt.Errorf("invalidate snapshot at %d: %w", target, err)
|
||||
}
|
||||
|
||||
// Drop now-orphaned processing roots above target.
|
||||
if err := bc.CleanBlockRootsAboveLastAccepted(); err != nil {
|
||||
return nil, fmt.Errorf("clean block roots above %d: %w", target, err)
|
||||
}
|
||||
|
||||
// The construction reorg must have removed canonical above target.
|
||||
if next := bc.GetBlockByNumber(target + 1); next != nil {
|
||||
return nil, fmt.Errorf("canonical block at %d still present after rewind (%s)", target+1, next.Hash())
|
||||
}
|
||||
|
||||
log.Info("FinalizeRewind: done", "height", target, "hash", tb.Hash(), "root", tb.Root())
|
||||
return tb, nil
|
||||
}
|
||||
|
||||
// HighestCommittedStateAtOrBelow returns the highest canonical height h with
|
||||
// h <= max whose state root is committed on disk, scanning down at most [window]
|
||||
// heights from max. ok is false if no committed state is found in that window.
|
||||
//
|
||||
// It is used to choose a rewind target that needs ZERO re-execution (and hence
|
||||
// carries zero re-execution / chain-identity risk): reconstructing at a height
|
||||
// whose state is already committed makes loadLastState's reprocessState a no-op,
|
||||
// so no finalized block is re-executed offline. A window of 0 scans to genesis.
|
||||
func (bc *BlockChain) HighestCommittedStateAtOrBelow(max uint64, window uint64) (uint64, common.Hash, bool) {
|
||||
var floor uint64
|
||||
if window != 0 && max > window {
|
||||
floor = max - window
|
||||
}
|
||||
for h := max; ; h-- {
|
||||
if header := bc.GetHeaderByNumber(h); header != nil && bc.HasState(header.Root) {
|
||||
return h, header.Hash(), true
|
||||
}
|
||||
if h == 0 || h == floor {
|
||||
break
|
||||
}
|
||||
}
|
||||
return 0, common.Hash{}, false
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
package core
|
||||
|
||||
import (
|
||||
"math/big"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/crypto"
|
||||
"github.com/luxfi/evm/params"
|
||||
"github.com/luxfi/geth/common"
|
||||
"github.com/luxfi/geth/core/rawdb"
|
||||
"github.com/luxfi/geth/core/types"
|
||||
|
||||
ethparams "github.com/luxfi/geth/params"
|
||||
)
|
||||
|
||||
// TestRewindToHeight reproduces the mainnet C-Chain recovery at unit scale: an
|
||||
// EVM that has accepted PAST the consensus-finalized height is rolled back down
|
||||
// to that height, leaving the on-disk state consistent so the chain re-opens
|
||||
// cleanly (the exact condition the wrapping proposervm requires — heightMatch
|
||||
// instead of the fail-closed heightBehind) and can build forward again.
|
||||
//
|
||||
// It asserts, in order, the three properties the on-cluster proof checks:
|
||||
// 1. the rewind lands the chain exactly at N with state present + serveable
|
||||
// (balances at N preserved, canonical above N cleared);
|
||||
// 2. a fresh re-open at N (simulating the node reboot that reads the rewound
|
||||
// last-accepted pointer) mounts with committed state — no re-genesis;
|
||||
// 3. the re-opened chain builds and accepts block N+1.
|
||||
func TestRewindToHeight(t *testing.T) {
|
||||
const (
|
||||
tip = 30 // EVM ran ahead to here
|
||||
n = 15 // consensus finality floor we roll back to
|
||||
)
|
||||
|
||||
key1, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
|
||||
key2, _ := crypto.HexToECDSA("8a1f9a8f95be41cd7ccb6168179afb4504aefe388d1e14474d32c45c72ce7b7a")
|
||||
ca1 := crypto.PubkeyToAddress(key1.PublicKey)
|
||||
ca2 := crypto.PubkeyToAddress(key2.PublicKey)
|
||||
addr1 := common.BytesToAddress(ca1[:])
|
||||
addr2 := common.BytesToAddress(ca2[:])
|
||||
|
||||
const perBlock = int64(10000) // wei addr1 -> addr2 in every block
|
||||
chainDB := rawdb.NewMemoryDatabase()
|
||||
gspec := &Genesis{
|
||||
Config: params.TestChainConfig,
|
||||
Alloc: types.GenesisAlloc{addr1: {Balance: big.NewInt(1e18)}},
|
||||
}
|
||||
|
||||
bc, err := createBlockChain(chainDB, pruningConfig, gspec, common.Hash{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
signer := types.LatestSigner(params.TestChainConfig)
|
||||
_, blocks, _, err := GenerateChainWithGenesis(gspec, bc.engine, tip, 10, func(i int, gen *BlockGen) {
|
||||
tx, _ := types.SignTx(
|
||||
types.NewTransaction(gen.TxNonce(addr1), addr2, big.NewInt(perBlock), ethparams.TxGas, big.NewInt(225000000000), nil),
|
||||
signer, key1)
|
||||
gen.AddTx(tx)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := bc.InsertChain(blocks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range blocks {
|
||||
if err := bc.Accept(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
bc.DrainAcceptorQueue()
|
||||
|
||||
if got := bc.LastAcceptedBlock().NumberU64(); got != tip {
|
||||
t.Fatalf("setup: last-accepted = %d, want tip %d", got, tip)
|
||||
}
|
||||
|
||||
// blocks is 0-indexed: height h is blocks[h-1].
|
||||
blockN := blocks[n-1]
|
||||
wantAddr2AtN := big.NewInt(perBlock * int64(n)) // cumulative transfers through height n
|
||||
|
||||
// Mirror the tool: commit state@N to DISK from the tip chain first.
|
||||
// MaterializeState regenerates it from the nearest committed ancestor if pruned
|
||||
// (no-op if present); ForceCommitState flushes it out of the dirty cache (which
|
||||
// the hash scheme drops on Stop), so the reconstruct-AT-N boot can mount it.
|
||||
if err := bc.MaterializeState(blockN); err != nil {
|
||||
t.Fatalf("materialize state@%d: %v", n, err)
|
||||
}
|
||||
if err := bc.ForceCommitState(blockN); err != nil {
|
||||
t.Fatalf("commit state@%d: %v", n, err)
|
||||
}
|
||||
bc.Stop() // release the db; the rewind is applied by re-opening AT N
|
||||
|
||||
// ---- (1) rewind: reconstruct AT N (loadLastState reorgs the run-ahead tip
|
||||
// away + reprocesses state@N — coreth's own boot path), then FinalizeRewind. --
|
||||
atN, err := createBlockChain(chainDB, pruningConfig, gspec, blockN.Hash())
|
||||
if err != nil {
|
||||
t.Fatalf("reconstruct at N=%d failed: %v", n, err)
|
||||
}
|
||||
tb, err := atN.FinalizeRewind(n)
|
||||
if err != nil {
|
||||
t.Fatalf("FinalizeRewind(%d): %v", n, err)
|
||||
}
|
||||
if tb.Hash() != blockN.Hash() {
|
||||
t.Fatalf("rewind returned %s, want block %d %s", tb.Hash(), n, blockN.Hash())
|
||||
}
|
||||
if h := atN.CurrentBlock(); h.Number.Uint64() != n || h.Hash() != blockN.Hash() {
|
||||
t.Fatalf("head = %d (%s), want %d (%s)", h.Number.Uint64(), h.Hash(), n, blockN.Hash())
|
||||
}
|
||||
if la := atN.LastAcceptedBlock(); la.NumberU64() != n || la.Hash() != blockN.Hash() {
|
||||
t.Fatalf("last-accepted = %d (%s), want %d", la.NumberU64(), la.Hash(), n)
|
||||
}
|
||||
if !atN.HasState(blockN.Root()) {
|
||||
t.Fatalf("state root %s at height %d not present after rewind", blockN.Root(), n)
|
||||
}
|
||||
if next := atN.GetBlockByNumber(n + 1); next != nil {
|
||||
t.Fatalf("canonical block at %d still present after rewind (%s)", n+1, next.Hash())
|
||||
}
|
||||
assertBalance(t, atN, blockN.Root(), addr2, wantAddr2AtN, "after rewind")
|
||||
|
||||
// FinalizeRewind must refuse to "finalize" a target the chain is not at.
|
||||
if _, err := atN.FinalizeRewind(tip); err == nil {
|
||||
t.Fatalf("expected FinalizeRewind(%d) to fail when head is %d", tip, n)
|
||||
}
|
||||
atN.Stop()
|
||||
|
||||
// ---- (2) fresh re-open at N mounts with committed state (the reboot) ----
|
||||
// This mirrors the node reading the rewound last-accepted pointer on boot:
|
||||
// loadLastState re-derives head + reprocesses the committed state from disk.
|
||||
reopened, err := createBlockChain(chainDB, pruningConfig, gspec, blockN.Hash())
|
||||
if err != nil {
|
||||
t.Fatalf("re-open at N failed (would be a boot failure): %v", err)
|
||||
}
|
||||
defer reopened.Stop()
|
||||
if h := reopened.CurrentBlock(); h.Number.Uint64() != n || h.Hash() != blockN.Hash() {
|
||||
t.Fatalf("reopened head = %d (%s), want %d", h.Number.Uint64(), h.Hash(), n)
|
||||
}
|
||||
if !reopened.HasState(blockN.Root()) {
|
||||
t.Fatalf("reopened chain missing state at N=%d", n)
|
||||
}
|
||||
assertBalance(t, reopened, blockN.Root(), addr2, wantAddr2AtN, "after reboot")
|
||||
|
||||
// ---- (3) the re-opened chain builds + accepts N+1 ----
|
||||
nextBlock := blocks[n] // height n+1, parent = blockN (still canonical)
|
||||
if _, err := reopened.InsertChain([]*types.Block{nextBlock}); err != nil {
|
||||
t.Fatalf("failed to build past N (insert %d): %v", n+1, err)
|
||||
}
|
||||
if err := reopened.Accept(nextBlock); err != nil {
|
||||
t.Fatalf("failed to accept %d: %v", n+1, err)
|
||||
}
|
||||
reopened.DrainAcceptorQueue()
|
||||
if h := reopened.CurrentBlock(); h.Number.Uint64() != n+1 {
|
||||
t.Fatalf("after building forward head = %d, want %d", h.Number.Uint64(), n+1)
|
||||
}
|
||||
if la := reopened.LastAcceptedBlock(); la.NumberU64() != n+1 {
|
||||
t.Fatalf("after accept head last-accepted = %d, want %d", la.NumberU64(), n+1)
|
||||
}
|
||||
assertBalance(t, reopened, nextBlock.Root(), addr2, big.NewInt(perBlock*int64(n+1)), "after building N+1")
|
||||
}
|
||||
|
||||
// TestRewindToHeight_NoState verifies HighestCommittedStateAtOrBelow reports the
|
||||
// zero-re-execution target correctly and that RewindToHeight is idempotent when
|
||||
// already at the target.
|
||||
func TestRewindIdempotentAtTarget(t *testing.T) {
|
||||
key1, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
|
||||
ca1 := crypto.PubkeyToAddress(key1.PublicKey)
|
||||
addr1 := common.BytesToAddress(ca1[:])
|
||||
chainDB := rawdb.NewMemoryDatabase()
|
||||
gspec := &Genesis{Config: params.TestChainConfig, Alloc: types.GenesisAlloc{addr1: {Balance: big.NewInt(1e18)}}}
|
||||
|
||||
bc, err := createBlockChain(chainDB, archiveConfig, gspec, common.Hash{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer bc.Stop()
|
||||
|
||||
_, blocks, _, err := GenerateChainWithGenesis(gspec, bc.engine, 5, 10, func(i int, gen *BlockGen) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := bc.InsertChain(blocks); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, b := range blocks {
|
||||
if err := bc.Accept(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
bc.DrainAcceptorQueue()
|
||||
|
||||
// Archive mode commits every block, so the highest committed state at/below
|
||||
// the tip is the tip itself.
|
||||
h, _, ok := bc.HighestCommittedStateAtOrBelow(5, 0)
|
||||
if !ok || h != 5 {
|
||||
t.Fatalf("HighestCommittedStateAtOrBelow(5) = (%d,%v), want (5,true)", h, ok)
|
||||
}
|
||||
|
||||
// Finalizing at the current tip is a no-op rewind that still lands consistently
|
||||
// (commits state, cleans roots) and asserts head == target.
|
||||
if _, err := bc.FinalizeRewind(5); err != nil {
|
||||
t.Fatalf("idempotent finalize at tip: %v", err)
|
||||
}
|
||||
if got := bc.CurrentBlock().Number.Uint64(); got != 5 {
|
||||
t.Fatalf("head after no-op finalize = %d, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertBalance(t *testing.T, bc *BlockChain, root common.Hash, addr common.Address, want *big.Int, ctx string) {
|
||||
t.Helper()
|
||||
st, err := bc.StateAt(root)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: StateAt(%s): %v", ctx, root, err)
|
||||
}
|
||||
got := st.GetBalance(addr).ToBig()
|
||||
if got.Cmp(want) != 0 {
|
||||
t.Fatalf("%s: balance(%s) = %s, want %s", ctx, addr, got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
|
||||
// See the file LICENSE for licensing terms.
|
||||
|
||||
// rlp_seam_red_test.go — RED adversarial proof of the IMPORT → PRODUCE-ON-TOP seam,
|
||||
// the exact reliability the owner is worried about: mainnet imported
|
||||
// lux-mainnet-96369-full-1083873.rlp then produced new blocks up to 1085755. The seam
|
||||
// MUST hold with NO gap and NO divergence:
|
||||
//
|
||||
// parentHash(first produced block) == hash(last imported block) (no fork at the seam)
|
||||
// height(first produced block) == height(last imported) + 1 (no gap / no skip)
|
||||
// each produced block chains to the previous (contiguous ancestry)
|
||||
// the post-produce snapshot's tip hash == the produced tip hash (state root chains;
|
||||
// equal hash ⇒ equal
|
||||
// stateRoot ⇒ no re-exec
|
||||
// divergence)
|
||||
//
|
||||
// The DELTA exports (lux-mainnet-96369-delta-1083347-1083356 …-1083873) ARE the blocks
|
||||
// produced ON TOP of the full-1083346 snapshot, and full-1083873 is the snapshot AFTER
|
||||
// they were produced — so the real state files let us prove the seam end-to-end without
|
||||
// standing up a 1.2 GB EVM state trie in a unit test.
|
||||
//
|
||||
// Files are located via LUX_RLP_STATE_DIR (default ~/work/lux/state) and every case skips
|
||||
// cleanly when its inputs are absent, so CI without the exports stays green. The heavy
|
||||
// full-snapshot cases (1.2 GB stream ×2) are additionally gated behind
|
||||
// LUX_RLP_SEAM_HEAVY=1.
|
||||
package core
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/luxfi/geth/core/types"
|
||||
"github.com/luxfi/geth/rlp"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func rlpStateDir() string {
|
||||
if d := os.Getenv("LUX_RLP_STATE_DIR"); d != "" {
|
||||
return d
|
||||
}
|
||||
return os.ExpandEnv("$HOME/work/lux/state")
|
||||
}
|
||||
|
||||
type blkInfo struct {
|
||||
number uint64
|
||||
hash [32]byte
|
||||
parent [32]byte
|
||||
root [32]byte
|
||||
}
|
||||
|
||||
// forEachBlock streams a C-Chain RLP export block-by-block (the admin.exportChain
|
||||
// format), invoking fn per block. It retains no blocks (constant memory), so a 1.2 GB
|
||||
// export streams without OOM. Returns first, last, and count. A non-contiguous parent
|
||||
// chain is reported by fn returning an error (the caller decides fatality).
|
||||
func forEachBlock(t *testing.T, path string, fn func(idx uint64, b *types.Block) error) (first, last blkInfo, count uint64) {
|
||||
t.Helper()
|
||||
f, err := os.Open(path)
|
||||
require.NoError(t, err, "open %s", path)
|
||||
defer f.Close()
|
||||
|
||||
stream := rlp.NewStream(f, 0)
|
||||
for {
|
||||
var b types.Block
|
||||
err := stream.Decode(&b)
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
require.NoErrorf(t, err, "decode block #%d in %s", count, path)
|
||||
|
||||
info := blkInfo{number: b.NumberU64(), hash: b.Hash(), parent: b.ParentHash(), root: b.Root()}
|
||||
if count == 0 {
|
||||
first = info
|
||||
}
|
||||
if fn != nil {
|
||||
require.NoError(t, fn(count, &b), "block continuity at index %d (height %d)", count, b.NumberU64())
|
||||
}
|
||||
last = info
|
||||
count++
|
||||
}
|
||||
return first, last, count
|
||||
}
|
||||
|
||||
// deltaFiles are the exports of the blocks PRODUCED on top of the full-1083346 snapshot,
|
||||
// in production order. Their concatenation is the exact "produce-on-top" sequence.
|
||||
var deltaFiles = []string{
|
||||
"lux-mainnet-96369-delta-1083347-1083356.rlp",
|
||||
"lux-mainnet-96369-delta-1083357-1083370.rlp",
|
||||
"lux-mainnet-96369-delta-1083371-1083873.rlp",
|
||||
}
|
||||
|
||||
// TestRedRLPSeam_LuxMainnet_ProducedDeltasChainWithNoGap proves the produced blocks form
|
||||
// ONE unbroken chain: contiguous heights and matching parent hashes WITHIN each export and
|
||||
// ACROSS export boundaries. A gap, a re-org, or a parent-hash break at any seam fails here.
|
||||
func TestRedRLPSeam_LuxMainnet_ProducedDeltasChainWithNoGap(t *testing.T) {
|
||||
dir := rlpStateDir()
|
||||
// Require all three deltas present (they are small; if any is missing, skip the case).
|
||||
for _, name := range deltaFiles {
|
||||
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
|
||||
t.Skipf("delta export %s not present in %s — skipping", name, dir)
|
||||
}
|
||||
}
|
||||
|
||||
var prev *blkInfo // last block of the previous export (nil before the first)
|
||||
var globalFirst, globalLast blkInfo
|
||||
for fi, name := range deltaFiles {
|
||||
path := filepath.Join(dir, name)
|
||||
var haveParentInFile bool
|
||||
var fileFirst blkInfo
|
||||
first, last, count := forEachBlock(t, path, func(idx uint64, b *types.Block) error {
|
||||
if idx == 0 {
|
||||
fileFirst = blkInfo{number: b.NumberU64(), hash: b.Hash(), parent: b.ParentHash()}
|
||||
return nil
|
||||
}
|
||||
// Within-file contiguity: height+1 and parent == previous hash.
|
||||
if !haveParentInFile {
|
||||
haveParentInFile = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.Greater(t, count, uint64(0), "%s decoded zero blocks", name)
|
||||
|
||||
// Re-stream to assert strict within-file contiguity (height n+1, parent==prev.hash).
|
||||
var last2 blkInfo
|
||||
var have bool
|
||||
forEachBlock(t, path, func(idx uint64, b *types.Block) error {
|
||||
cur := blkInfo{number: b.NumberU64(), hash: b.Hash(), parent: b.ParentHash()}
|
||||
if have {
|
||||
require.Equalf(t, last2.number+1, cur.number, "%s: HEIGHT GAP %d→%d", name, last2.number, cur.number)
|
||||
require.Equalf(t, last2.hash, cur.parent, "%s: PARENT BREAK at height %d", name, cur.number)
|
||||
}
|
||||
last2, have = cur, true
|
||||
return nil
|
||||
})
|
||||
|
||||
// Across-file seam: this file's first block is produced ON TOP of the previous file's tip.
|
||||
if prev != nil {
|
||||
require.Equalf(t, prev.number+1, fileFirst.number,
|
||||
"SEAM GAP between %s and %s: %d → %d", deltaFiles[fi-1], name, prev.number, fileFirst.number)
|
||||
require.Equalf(t, prev.hash, fileFirst.parent,
|
||||
"SEAM PARENT BREAK: %s first block (h=%d) parent != %s tip hash", name, fileFirst.number, deltaFiles[fi-1])
|
||||
}
|
||||
if fi == 0 {
|
||||
globalFirst = first
|
||||
}
|
||||
globalLast = last
|
||||
p := last
|
||||
prev = &p
|
||||
}
|
||||
|
||||
// The produced sequence spans exactly 1083347..1083873 with no gap.
|
||||
require.Equal(t, uint64(1083347), globalFirst.number, "produced sequence must START at 1083347 (full snapshot tip +1)")
|
||||
require.Equal(t, uint64(1083873), globalLast.number, "produced sequence must END at the 1083873 snapshot height")
|
||||
t.Logf("produced-on-top chain verified contiguous: blocks %d..%d, tip=%x", globalFirst.number, globalLast.number, globalLast.hash)
|
||||
}
|
||||
|
||||
// TestRedRLPSeam_LuxMainnet_FullSnapshotToProducedTip is the FULL end-to-end seam (heavy):
|
||||
// stream the 1.2 GB full-1083346 snapshot to its tip, prove the first produced block is
|
||||
// built directly on that tip (parent hash + height+1), then stream the 1.2 GB full-1083873
|
||||
// snapshot and prove its tip is byte-identical to the produced tip — so importing the
|
||||
// snapshot and producing on top yields the SAME chain the later snapshot commits to (state
|
||||
// root chains; no re-exec divergence). Gated behind LUX_RLP_SEAM_HEAVY=1.
|
||||
func TestRedRLPSeam_LuxMainnet_FullSnapshotToProducedTip(t *testing.T) {
|
||||
if os.Getenv("LUX_RLP_SEAM_HEAVY") != "1" {
|
||||
t.Skip("heavy 1.2GB×2 stream; set LUX_RLP_SEAM_HEAVY=1 to run")
|
||||
}
|
||||
dir := rlpStateDir()
|
||||
full0 := filepath.Join(dir, "lux-mainnet-96369-full-1083346.rlp")
|
||||
full1 := filepath.Join(dir, "lux-mainnet-96369-full-1083873.rlp")
|
||||
d1 := filepath.Join(dir, deltaFiles[0])
|
||||
d3 := filepath.Join(dir, deltaFiles[len(deltaFiles)-1])
|
||||
for _, p := range []string{full0, full1, d1, d3} {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Skipf("%s absent — skipping heavy seam", p)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Import snapshot tip (stream to the end; assert genesis-rooted contiguity along the way).
|
||||
_, base, n0 := forEachBlock(t, full0, func(idx uint64, b *types.Block) error { return nil })
|
||||
require.Equal(t, uint64(1083346), base.number, "full-1083346 tip height")
|
||||
require.Equal(t, uint64(1083347), n0, "full-1083346 must contain blocks 0..1083346")
|
||||
|
||||
// 2. First produced block sits directly on the imported tip.
|
||||
firstProduced, _, _ := forEachBlock(t, d1, func(idx uint64, b *types.Block) error { return nil })
|
||||
require.Equal(t, base.number+1, firstProduced.number, "produce-on-top HEIGHT GAP at the seam")
|
||||
require.Equal(t, base.hash, firstProduced.parent, "produce-on-top PARENT BREAK at the seam")
|
||||
|
||||
// 3. Produced tip == later snapshot tip (state root chains; no divergence).
|
||||
_, producedTip, _ := forEachBlock(t, d3, func(idx uint64, b *types.Block) error { return nil })
|
||||
_, snapTip, _ := forEachBlock(t, full1, func(idx uint64, b *types.Block) error { return nil })
|
||||
require.Equal(t, uint64(1083873), snapTip.number, "full-1083873 tip height")
|
||||
require.Equal(t, producedTip.number, snapTip.number, "produced tip vs snapshot tip HEIGHT mismatch")
|
||||
require.Equal(t, producedTip.hash, snapTip.hash, "produced tip HASH != snapshot tip HASH — re-exec DIVERGENCE at the seam")
|
||||
require.Equal(t, producedTip.root, snapTip.root, "produced tip STATE ROOT != snapshot state root — state divergence")
|
||||
t.Logf("FULL SEAM verified: import@%d(%x) → produce → tip@%d(%x) == snapshot tip",
|
||||
base.number, base.hash, producedTip.number, producedTip.hash)
|
||||
}
|
||||
|
||||
// TestRedRLPSeam_ZooMainnet_ContinuityAndWellFormedTip proves the Zoo mainnet export
|
||||
// (zoo-mainnet-200200) is a sound import base: an unbroken genesis-rooted chain with
|
||||
// monotonic heights, a non-zero state root at every block, and a well-formed tip — the
|
||||
// same continuity the Lux seam relies on, for the second chain the owner named.
|
||||
func TestRedRLPSeam_ZooMainnet_ContinuityAndWellFormedTip(t *testing.T) {
|
||||
path := filepath.Join(rlpStateDir(), "zoo-mainnet-200200-full.rlp")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Skipf("zoo-mainnet export absent (%s) — skipping", path)
|
||||
}
|
||||
var last blkInfo
|
||||
var have bool
|
||||
var zero [32]byte
|
||||
first, tip, count := forEachBlock(t, path, func(idx uint64, b *types.Block) error {
|
||||
cur := blkInfo{number: b.NumberU64(), hash: b.Hash(), parent: b.ParentHash(), root: b.Root()}
|
||||
require.NotEqualf(t, zero, cur.hash, "zoo block %d has zero hash", cur.number)
|
||||
if have {
|
||||
require.Equalf(t, last.number+1, cur.number, "zoo HEIGHT GAP %d→%d", last.number, cur.number)
|
||||
require.Equalf(t, last.hash, cur.parent, "zoo PARENT BREAK at height %d", cur.number)
|
||||
}
|
||||
last, have = cur, true
|
||||
return nil
|
||||
})
|
||||
require.Greater(t, count, uint64(0), "zoo export decoded zero blocks")
|
||||
require.Equal(t, uint64(0), first.number, "zoo export must start at genesis (height 0)")
|
||||
t.Logf("zoo-mainnet import base verified: %d blocks, genesis..%d, tip=%x", count, tip.number, tip.hash)
|
||||
}
|
||||
Reference in New Issue
Block a user