fix(evm): startup RLP-import consensus-wedge + idempotency (flag-day hotfix off v1.104.9)

Two boot-path fixes to the --import-chain-data startup import, both execution-orthogonal
(no go.mod/import changes; module graph identical to the deployed v1.104.9 plugin):

1. CONSENSUS REFRESH (the wedge): importBlocksFromFile advanced core.BlockChain +
   acceptedBlockDB to the imported tip but not the in-memory consensus wrapper vm.State,
   which initChainState built at the pre-import genesis tip. On the FIRST import boot the
   consensus engine then reads genesis while the EVM head is the imported tip and the
   proposervm wedges ("BuildBlock: preferred block not fetchable", preferred=empty id)
   until a restart re-runs initChainState off acceptedBlockDB. Now syncs vm.State via the
   boot-safe chain.State.SetLastAcceptedBlock, fail-closed.

2. IDEMPOTENCY (crash-loop): the production pod spec keeps --import-chain-data SET, so every
   restart AND every G9 kill-rejoin re-enters this path on an already-imported chain, where
   importBlocksFromFile imports 0 blocks and returns "no blocks imported" / "no blocks found
   in file" as an ERROR (admin_api.go) -> the node crash-loops. isNothingToImportError now
   classifies that as an idempotent skip (log + continue) when the chain already holds
   accepted content (curHead > 0), staying fatal on a fresh chain with an empty/bad file.

+ startup_import_idempotency_test.go locks both error-string contracts and the fresh-chain
  safety case. Empirically: proven on lux-testnet 96368 / node v1.36.17 (this exact v1.104.9
  plugin) — first import boot wedged; with fix (1) first boot finalizes past the import tip
  no-restart; fix (2) keeps G9 rejoin from crash-looping with the flag set. Same geth
  v1.17.12 as deployed -> G4 execution unchanged.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-16 11:04:54 -07:00
co-authored by Hanzo Dev
parent a472d035b5
commit 15e75f4362
2 changed files with 135 additions and 9 deletions
@@ -0,0 +1,68 @@
// Copyright (C) 2019-2026, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
package evm
import (
"os"
"path/filepath"
"testing"
"github.com/luxfi/evm/core"
"github.com/luxfi/geth/rlp"
"github.com/stretchr/testify/require"
)
// writeChainRLP RLP-encodes canonical blocks [from,to] of chain into a temp file, in the
// sequential per-block format importBlocksFromFile reads, and returns its path.
func writeChainRLP(t *testing.T, chain *core.BlockChain, from, to uint64) string {
t.Helper()
p := filepath.Join(t.TempDir(), "chain.rlp")
f, err := os.Create(p)
require.NoError(t, err)
for i := from; i <= to; i++ {
b := chain.GetBlockByNumber(i)
require.NotNilf(t, b, "canonical block %d missing", i)
require.NoError(t, rlp.Encode(f, b))
}
require.NoError(t, f.Close())
return p
}
// TestStartupImportIdempotency_AlreadyAtTip locks the contract the startup --import-chain-data
// idempotency guard (VM.Initialize -> isNothingToImportError) depends on: on an already-imported
// chain, re-importing the SAME RLP must return a "nothing to import" error the guard classifies as
// idempotent (log + skip) — NOT a fatal error. If this drifts, a production restart or a G9
// kill-rejoin (both keep --import-chain-data SET in the pod spec) CRASH-LOOPS the node — the
// re-audit's blocking HIGH (admin_api.go:607).
//
// Both RLP shapes are covered because admin_exportChain can include or omit genesis, and the two
// hit DIFFERENT importBlocksFromFile returns ("no blocks imported" vs "no blocks found in file").
func TestStartupImportIdempotency_AlreadyAtTip(t *testing.T) {
const tip = 5
chain, _ := buildAcceptedTestChain(t, tip) // real canonical chain, accept tip == 5
curHead := chain.CurrentBlock().Number.Uint64()
require.EqualValues(t, tip, curHead)
// RLP WITH genesis (blocks 0..tip): genesis is skipped, all others already present ->
// importBlocksFromFile falls through to the totalImported==0 "no blocks imported" return.
withGenesis := writeChainRLP(t, chain, 0, tip)
imported, _, _, err := importBlocksFromFile(chain, withGenesis, nil)
require.Equal(t, 0, imported)
require.Error(t, err)
require.Truef(t, isNothingToImportError(err, curHead),
"already-at-tip re-import (genesis-inclusive RLP) must classify idempotent; got: %v", err)
// RLP WITHOUT genesis (blocks 1..tip): every block is skipped before it is counted ->
// the inner loop returns "no blocks found in file".
withoutGenesis := writeChainRLP(t, chain, 1, tip)
imported, _, _, err = importBlocksFromFile(chain, withoutGenesis, nil)
require.Equal(t, 0, imported)
require.Error(t, err)
require.Truef(t, isNothingToImportError(err, curHead),
"already-at-tip re-import (no-genesis RLP) must classify idempotent; got: %v", err)
// SAFETY: the SAME "nothing to import" error on a FRESH chain (curHead == 0) must stay FATAL —
// a genuinely empty/corrupt RLP on first boot must never be silently skipped.
require.False(t, isNothingToImportError(err, 0))
}
+67 -9
View File
@@ -678,16 +678,52 @@ func (vm *VM) Initialize(ctx context.Context, init block.Init) error {
}
imported, lastHash, lastHeight, err := importBlocksFromFile(chain, vm.config.ImportChainData, persistAccepted)
if err != nil {
return fmt.Errorf("startup import failed: %w", err)
// IDEMPOTENCY GUARD: the flag-day / production pod spec keeps --import-chain-data
// SET, so every restart AND every kill-rejoin (G9) re-enters this path. Once the
// chain is already at/above the file's tip, importBlocksFromFile imports 0 blocks and
// returns a "nothing to import" error (see isNothingToImportError) — that is SUCCESS
// on a re-run, NOT a fatal boot error. Without this guard the node CRASH-LOOPS on
// every restart-with-flag (killing G9 rejoin and every watchdog restart). On this
// skip path chain.State was already restored to the correct tip by initChainState (it
// read the persisted acceptedBlockDB on this boot), so nothing more is needed; and we
// must NOT run reconcileQuasarWithAccept(lastHeight==0) here (it would wrongly reset
// the export frontier).
if !isNothingToImportError(err, chain.CurrentBlock().Number.Uint64()) {
return fmt.Errorf("startup import failed: %w", err)
}
log.Info("Startup import: chain already at/above import tip; nothing to import (idempotent skip)",
"path", vm.config.ImportChainData, "currentHeight", chain.CurrentBlock().Number.Uint64())
} else {
// A startup import can leave the accept tip below a persisted EXPORT-FINAL (Quasar) height.
// reconcile AFTER it — the boot reconcile in initializeChain ran BEFORE this import (against
// the pre-import tip), and importBlocksFromFile (unlike admin_importChain) does not reconcile
// itself — so without this a stale export height would survive an --import-chain-data rewind
// and `finalized`/warp would report a rebuilt (never-⅔-certified) block export-final. Single
// threaded at boot (no observer yet), so no lock needed here.
vm.reconcileQuasarWithAccept(lastHeight)
// CONSENSUS-REFRESH: sync the IN-MEMORY consensus last-accepted to the imported tip.
// initializeChain -> initChainState (run BEFORE this import) built vm.State (the
// components/chain consensus wrapper) with LastAcceptedBlock = the pre-import genesis
// tip. importBlocksFromFile advanced core.BlockChain + acceptedBlockDB but NOT vm.State,
// so on this FIRST import boot the consensus engine reads genesis while the EVM head is
// the imported tip -> the proposervm wedges ("BuildBlock: preferred block not fetchable",
// preferred=empty id) until a restart re-runs initChainState off acceptedBlockDB. The
// admin_importChain RPC path runs on a LIVE node whose vm.State is already current, so
// only this startup path needs the sync. FAIL-CLOSED: a boot that cannot establish the
// imported tip stops loudly rather than serving consensus at a stale genesis.
if imported > 0 {
tip := chain.GetBlockByHash(lastHash)
if tip == nil {
return fmt.Errorf("startup import: imported tip %s (height %d) not found in blockchain", lastHash.Hex(), lastHeight)
}
if err := vm.State.SetLastAcceptedBlock(vm.newBlock(tip)); err != nil {
return fmt.Errorf("startup import: failed to sync consensus last-accepted to imported tip (height %d): %w", lastHeight, err)
}
log.Info("Startup import: consensus chain.State refreshed to imported tip", "height", lastHeight, "hash", lastHash.Hex())
}
log.Info("Startup import complete", "blocks", imported, "lastHash", lastHash.Hex(), "height", lastHeight)
}
// A startup import can leave the accept tip below a persisted EXPORT-FINAL (Quasar) height.
// reconcile AFTER it — the boot reconcile in initializeChain ran BEFORE this import (against
// the pre-import tip), and importBlocksFromFile (unlike admin_importChain) does not reconcile
// itself — so without this a stale export height would survive an --import-chain-data rewind
// and `finalized`/warp would report a rebuilt (never-⅔-certified) block export-final. Single
// threaded at boot (no observer yet), so no lock needed here.
vm.reconcileQuasarWithAccept(lastHeight)
log.Info("Startup import complete", "blocks", imported, "lastHash", lastHash.Hex(), "height", lastHeight)
}
// Start continuous profiler in a goroutine with recovery
@@ -715,6 +751,28 @@ func (vm *VM) Initialize(ctx context.Context, init block.Init) error {
return vm.initializeStateSyncClient(lastAcceptedHeight)
}
// isNothingToImportError reports whether err from importBlocksFromFile means the chain was already
// at/above the file's tip on a re-run (idempotent), as opposed to a genuine import failure. The
// flag-day / production pod spec keeps --import-chain-data SET, so every restart and every G9
// kill-rejoin re-enters the startup import path on an already-imported chain; treating that
// "nothing to import" result as fatal would CRASH-LOOP the node.
//
// importBlocksFromFile yields one of two messages when every block in the file is already present:
// - "no blocks imported" — the RLP includes genesis (block 0), which is skipped, so the
// outer loop reaches the totalImported==0 return.
// - "no blocks found in file" — the RLP has no genesis; every block is skipped before it is
// counted, so the inner loop returns early.
//
// Both are idempotent ONLY when the chain already holds accepted content (curHead > 0). On a fresh
// chain (curHead == 0) the SAME messages mean a genuinely empty/corrupt file and MUST stay fatal.
func isNothingToImportError(err error, curHead uint64) bool {
if err == nil || curHead == 0 {
return false
}
m := err.Error()
return strings.Contains(m, "no blocks imported") || strings.Contains(m, "no blocks found in file")
}
func parseGenesis(ctx context.Context, genesisBytes []byte, upgradeBytes []byte, airdropFile string, genesisAllocFile string) (*core.Genesis, error) {
// First check if this is a database replay genesis
var genesisMap map[string]interface{}