mirror of
https://github.com/luxfi/evm.git
synced 2026-09-24 00:27:44 +00:00
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:
@@ -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
@@ -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{}
|
||||
|
||||
Reference in New Issue
Block a user