core: pin head state root in triedb dirty cache (Bug B — EVM head-state Reference fix)

hashdb.Update references storage-trie roots but never the STATE root, so every processed
root has parents==0; the StateHistory-deep tipBuffer Dereferences roots as they age out and
on an idle chain that Dereference lands on the live head and evicts it — at a non-CommitInterval
height the head was never flushed, yielding 'missing trie node' at tip → block production
stalls + the accepted tip is lost on restart (compounds the consensus catch-up split).

Fix: one Reference(block.Root(), common.Hash{}) in writeBlockWithState BEFORE InsertTrie
(hash-scheme only), refcount-balanced against the matching AcceptTrie/RejectTrie eviction.
Touches ONLY in-memory GC pinning — never a state root, block hash, gas value, or on-disk byte.

PROVEN (blue abeb6333): execution-IDENTICAL (golden roots identical RED-unpatched vs
GREEN-patched — cannot change mainnet history); RED reproduces at StateHistory=32 idle blocks,
GREEN passes after 96 idle; no-leak bounded over 4100 roots + across the 4096 boundary; refcount
balanced. head_pin test green (StateAvailableAfterIdle, NoLeakOverLongIdle, ExecutionIdentical,
Across4096Boundary_NoLeak, RestartAtNonBoundary).

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-07 10:57:13 -07:00
co-authored by Hanzo Dev
parent da06a9e206
commit 9ff7ebcff9
2 changed files with 497 additions and 0 deletions
+15
View File
@@ -1593,6 +1593,21 @@ func (bc *BlockChain) writeBlockWithState(block *types.Block, parentRoot common.
return nil
}
// Hash scheme: pin this block's state root in the triedb dirty cache.
// hashdb.Update references storage-trie roots but never the state root, so
// every processed root has parents==0. The StateHistory-deep tipBuffer
// Dereferences roots as they age out (and RejectTrie on reject); with
// duplicate roots on an idle chain that Dereference lands on the live head
// and evicts it — and at a non-CommitInterval height the head was never
// flushed to disk, yielding "missing trie node" at tip. This Reference is
// refcount-balanced (+1 here per processed block, -1 on the matching
// AcceptTrie eviction or RejectTrie) and restores the reference invariant
// documented at writeBlockAndSetHead's call site. It touches only in-memory
// GC pinning — never a state root, block hash, gas value, or on-disk byte.
if err := bc.triedb.Reference(block.Root(), common.Hash{}); err != nil {
return fmt.Errorf("failed to reference state root for block %s: %w", block.Hash().Hex(), err)
}
// Note: if InsertTrie must be the last step in verification that can return an error.
// This allows [stateManager] to assume that if it inserts a trie without returning an
// error then the block has passed verification and either AcceptTrie/RejectTrie will
+482
View File
@@ -0,0 +1,482 @@
// Copyright (C) 2019-2025, 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/consensus/dummy"
"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"
"github.com/luxfi/geth/trie"
"github.com/stretchr/testify/require"
)
// headPinConfigWith is a mainnet-faithful pruning cache config: hash scheme,
// snapshots disabled (no snapshot fallback — a missing trie node at the head is
// fatal, exactly as it is for block building on mainnet), parameterized by the
// tipBuffer depth (StateHistory) and the on-disk commit cadence (CommitInterval).
func headPinConfigWith(commitInterval, stateHistory uint64) *CacheConfig {
return &CacheConfig{
TrieCleanLimit: 256,
TrieDirtyLimit: 256,
TrieDirtyCommitTarget: 20,
TriePrefetcherParallelism: 4,
Pruning: true,
CommitInterval: commitInterval,
StateHistory: stateHistory,
SnapshotLimit: 0,
AcceptorQueueLimit: 64,
}
}
// headPinConfig uses the exact mainnet defaults: StateHistory=32 (tipBuffer
// depth), CommitInterval=4096 (so a short chain never writes state to disk — the
// head lives only in the hashdb dirty cache).
func headPinConfig() *CacheConfig { return headPinConfigWith(4096, 32) }
// senderHexKey is the funded account's private key; recipientHexKey funds the
// destination address. Each proof re-derives the key locally for signing.
const (
senderHexKey = "b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291"
recipientHexKey = "8a1f9a8f95be41cd7ccb6168179afb4504aefe388d1e14474d32c45c72ce7b7a"
)
// headPinFixtures returns the sender (funded) and recipient addresses. luxfi/crypto
// uses its own common.Address type, so convert to geth's via the raw bytes.
func headPinFixtures() (sender, recipient common.Address) {
k, _ := crypto.HexToECDSA(senderHexKey)
k2, _ := crypto.HexToECDSA(recipientHexKey)
ks, kr := crypto.PubkeyToAddress(k.PublicKey), crypto.PubkeyToAddress(k2.PublicKey)
return common.BytesToAddress(ks[:]), common.BytesToAddress(kr[:])
}
// insertAccept inserts, accepts, and synchronously drains one block so that
// AcceptTrie (tipBuffer.Insert -> hashdb.Dereference) has fully executed before
// returning. This is the exact path that evicts the head without the fix.
func insertAccept(t *testing.T, bc *BlockChain, block *types.Block) error {
t.Helper()
if err := bc.InsertBlock(block); err != nil {
return err
}
if err := bc.Accept(block); err != nil {
return err
}
bc.DrainAcceptorQueue()
return nil
}
// TestHeadPin_StateAvailableAfterIdle is the core red->green proof for Bug B.
//
// It reproduces the accepted-head GC eviction: a pruning chain reaches a tip at
// a non-CommitInterval height (state only in the hashdb dirty cache), then idles
// producing empty blocks whose state root is identical to the tip. The
// StateHistory-deep tipBuffer cycles and Dereferences that shared root against
// itself. Because hashdb.Update never references the state root (parents==0),
// WITHOUT the fix the live head's trie is deleted from the dirty cache and,
// never having been flushed to disk, becomes unserveable ("missing trie node").
//
// The invariant asserted here — for accepted head H, StateAt(root(H)) +
// GetBalance(latest) + build-on-H all succeed after idle + GC — FAILS on the
// unpatched code and PASSES with bc.triedb.Reference pinning the root.
func TestHeadPin_StateAvailableAfterIdle(t *testing.T) {
sender, recipient := headPinFixtures()
key, _ := crypto.HexToECDSA(senderHexKey)
genesisBalance := new(big.Int).Mul(big.NewInt(1), big.NewInt(params.Ether))
gspec := &Genesis{
Config: params.TestChainConfig,
Alloc: types.GenesisAlloc{sender: {Balance: genesisBalance}},
}
signer := types.LatestSigner(params.TestChainConfig)
const (
activeBlocks = 5 // real transfers; tip lands at height 5 (non-4096)
idleBlocks = 96 // 3x StateHistory of empty blocks -> buffer fully cycles
)
transfer := big.NewInt(1_000)
// Generate active + idle + one extra (build-on-H) block against a THROWAWAY
// db. The pruning chain under test runs on its own fresh db and rebuilds all
// state itself via re-execution, so nothing is pre-committed to disk for it.
genDB, chain, _, err := GenerateChainWithGenesis(gspec, dummy.NewCoinbaseFaker(), activeBlocks+idleBlocks+1, 10, func(i int, gen *BlockGen) {
// Active transfer blocks and the final build-on-H block carry a tx; the
// idle window in between is empty (identical state root -> duplicate roots).
if i < activeBlocks || i == activeBlocks+idleBlocks {
tx, _ := types.SignTx(types.NewTransaction(gen.TxNonce(sender), recipient, transfer, ethparams.TxGas, big.NewInt(225_000_000_000), nil), signer, key)
gen.AddTx(tx)
}
})
require.NoError(t, err)
_ = genDB
bc, err := createBlockChain(rawdb.NewMemoryDatabase(), headPinConfig(), gspec, common.Hash{})
require.NoError(t, err)
defer bc.Stop()
// Phase 1: build real state to the tip.
for i := 0; i < activeBlocks; i++ {
require.NoError(t, insertAccept(t, bc, chain[i]), "active block %d", i+1)
}
tipRoot := bc.CurrentHeader().Root
require.NotEqual(t, common.Hash{}, tipRoot)
require.NotEqual(t, types.EmptyRootHash, tipRoot, "tip must carry non-trivial state")
// Record the head balance while it is unambiguously serveable.
sdb, err := bc.StateAt(tipRoot)
require.NoError(t, err, "head state must be serveable at the tip before idling")
balBefore := sdb.GetBalance(sender)
require.True(t, balBefore.Sign() > 0)
// Phase 2: idle. Empty blocks share tipRoot; the tipBuffer Dereferences it.
// Pre-fix, at ~StateHistory idle blocks the tipBuffer cycles and Dereferences
// the shared head root against itself (parents==0) — the head trie is deleted
// and, never flushed to disk, becomes unserveable. Checking StateAt(head)
// after every idle accept catches the eviction at the exact block it happens.
for i := activeBlocks; i < activeBlocks+idleBlocks; i++ {
require.NoError(t, insertAccept(t, bc, chain[i]), "idle block %d (pre-fix: ancestor state evicted -> chain freezes)", i+1)
require.Equal(t, tipRoot, bc.CurrentHeader().Root, "idle block %d must preserve the head state root", i+1)
if _, err := bc.StateAt(bc.CurrentHeader().Root); err != nil {
t.Fatalf("BUG B reproduced: accepted-head state evicted by GC after %d idle blocks (StateHistory=32): %v", i+1-activeBlocks, err)
}
}
// ---- INVARIANT (fails without the fix, holds with it) ----
headRoot := bc.CurrentHeader().Root
require.Equal(t, tipRoot, headRoot)
// (a) OpenTrie(stateRoot(H)) succeeds after idle + GC.
sdbAfter, err := bc.StateAt(headRoot)
require.NoError(t, err, "BUG B: accepted-head state root evicted by GC during idle (missing trie node at tip)")
// (b) eth_getBalance(latest) is stable and correct after idle + GC.
balAfter := sdbAfter.GetBalance(sender)
require.Zero(t, balAfter.Cmp(balBefore), "head balance changed / unreadable after idle: before=%s after=%s", balBefore, balAfter)
// (c) build-on-H (T+1) succeeds: re-executes against the head state.
require.NoError(t, insertAccept(t, bc, chain[activeBlocks+idleBlocks]), "BUG B: cannot build T+1 on evicted head state")
require.Equal(t, chain[activeBlocks+idleBlocks].Hash(), bc.CurrentHeader().Hash())
// Head advanced and its new state is serveable too.
_, err = bc.StateAt(bc.CurrentHeader().Root)
require.NoError(t, err, "post-build head state must be serveable")
}
// TestHeadPin_NoLeakOverLongIdle proves the fix is refcount-balanced: +1 Reference
// per processed block is matched by the tipBuffer's -1 Dereference as roots age
// out. Over a long idle (duplicate roots add no new trie nodes), the hashdb dirty
// cache must stay bounded — no unbounded growth from accumulating references.
func TestHeadPin_NoLeakOverLongIdle(t *testing.T) {
sender, recipient := headPinFixtures()
key, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
genesisBalance := new(big.Int).Mul(big.NewInt(1), big.NewInt(params.Ether))
gspec := &Genesis{
Config: params.TestChainConfig,
Alloc: types.GenesisAlloc{sender: {Balance: genesisBalance}},
}
signer := types.LatestSigner(params.TestChainConfig)
const (
activeBlocks = 5
idleBlocks = 20 * 32 // 20x StateHistory of empty blocks
)
transfer := big.NewInt(1_000)
genDB, chain, _, err := GenerateChainWithGenesis(gspec, dummy.NewCoinbaseFaker(), activeBlocks+idleBlocks, 10, func(i int, gen *BlockGen) {
if i < activeBlocks {
tx, _ := types.SignTx(types.NewTransaction(gen.TxNonce(sender), recipient, transfer, ethparams.TxGas, big.NewInt(225_000_000_000), nil), signer, key)
gen.AddTx(tx)
}
})
require.NoError(t, err)
_ = genDB
bc, err := createBlockChain(rawdb.NewMemoryDatabase(), headPinConfig(), gspec, common.Hash{})
require.NoError(t, err)
defer bc.Stop()
for i := 0; i < activeBlocks; i++ {
require.NoError(t, insertAccept(t, bc, chain[i]))
}
dirtyBytes := func() common.StorageSize {
_, nodes, _ := bc.triedb.Size()
return nodes
}
// Idle through 2x StateHistory so the tipBuffer has fully cycled and reached
// steady state, then take the baseline dirty-cache size.
warmup := activeBlocks + 2*32
for i := activeBlocks; i < warmup; i++ {
require.NoError(t, insertAccept(t, bc, chain[i]))
}
baseline := dirtyBytes()
// Idle the rest. Empty blocks add no trie nodes and the reference count is
// balanced, so the dirty cache must not grow beyond the steady-state baseline.
for i := warmup; i < activeBlocks+idleBlocks; i++ {
require.NoError(t, insertAccept(t, bc, chain[i]))
}
final := dirtyBytes()
t.Logf("hashdb dirty cache: baseline=%v final=%v after %d idle blocks", baseline, final, idleBlocks)
require.LessOrEqual(t, uint64(final), uint64(baseline), "dirty cache grew during idle: reference count is leaking (baseline=%v final=%v)", baseline, final)
// Head is still serveable at the end of the long idle.
_, err = bc.StateAt(bc.CurrentHeader().Root)
require.NoError(t, err, "head state must remain serveable after long idle")
}
// TestHeadPin_ExecutionIdentical is the mainnet-history safety gate. The fix must
// change ONLY in-memory GC timing — never a state root, block hash, gas value, or
// receipt. This drives a deterministic mixed workload (transfers interleaved with
// empty blocks) through a pruning chain (which runs the patched writeBlockWithState
// Reference) and a parallel archive chain, and asserts every block's canonical
// hash — which commits to state root, receipts root, and gasUsed — is byte-identical
// between them and equal to the fix-independent values produced by GenerateChain.
//
// The golden head hash/root below are additionally verified byte-identical between
// the pre-fix and post-fix builds (see the report's stash A/B run), closing the
// gate: applying the fix reproduces mainnet history exactly.
func TestHeadPin_ExecutionIdentical(t *testing.T) {
sender, recipient := headPinFixtures()
key, _ := crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
genesisBalance := new(big.Int).Mul(big.NewInt(1), big.NewInt(params.Ether))
gspec := &Genesis{
Config: params.TestChainConfig,
Alloc: types.GenesisAlloc{sender: {Balance: genesisBalance}},
}
signer := types.LatestSigner(params.TestChainConfig)
// A deterministic workload that is well under StateHistory of consecutive
// empties so it does NOT trigger the bug — both pre-fix and post-fix builds
// process it cleanly, isolating the "does the fix change any hash?" question.
const total = 40
genDB, chain, receipts, err := GenerateChainWithGenesis(gspec, dummy.NewCoinbaseFaker(), total, 10, func(i int, gen *BlockGen) {
// Transfer on even blocks, empty on odd blocks (interleaved duplicate roots).
if i%2 == 0 {
tx, _ := types.SignTx(types.NewTransaction(gen.TxNonce(sender), recipient, big.NewInt(int64(1_000+i)), ethparams.TxGas, big.NewInt(225_000_000_000), nil), signer, key)
gen.AddTx(tx)
}
})
require.NoError(t, err)
_ = genDB
// Patched pruning chain (exercises writeBlockWithState's Reference).
pruneBC, err := createBlockChain(rawdb.NewMemoryDatabase(), headPinConfig(), gspec, common.Hash{})
require.NoError(t, err)
defer pruneBC.Stop()
// Reference archive chain (no tipBuffer GC path at all).
archiveBC, err := createBlockChain(rawdb.NewMemoryDatabase(), archiveConfig, gspec, common.Hash{})
require.NoError(t, err)
defer archiveBC.Stop()
for i, block := range chain {
require.NoError(t, insertAccept(t, pruneBC, block), "prune insert block %d", i+1)
require.NoError(t, insertAccept(t, archiveBC, block), "archive insert block %d", i+1)
ph := pruneBC.GetHeaderByNumber(uint64(i + 1))
ah := archiveBC.GetHeaderByNumber(uint64(i + 1))
// Gate rows 1-5, per block: every consensus-committed field must be
// byte-identical between the patched pruning chain and the archive chain,
// and equal to the fix-independent value produced by GenerateChain.
require.Equal(t, block.Hash(), ph.Hash(), "block %d BLOCK HASH diverged (prune)", i+1)
require.Equal(t, block.Hash(), ah.Hash(), "block %d BLOCK HASH diverged (archive)", i+1)
require.Equal(t, block.Root(), ph.Root, "block %d STATE ROOT diverged (prune)", i+1)
require.Equal(t, ah.Root, ph.Root, "block %d STATE ROOT diverged (prune vs archive)", i+1)
require.Equal(t, block.ReceiptHash(), ph.ReceiptHash, "block %d RECEIPT ROOT diverged (prune)", i+1)
require.Equal(t, ah.ReceiptHash, ph.ReceiptHash, "block %d RECEIPT ROOT diverged (prune vs archive)", i+1)
require.Equal(t, block.GasUsed(), ph.GasUsed, "block %d GAS USED diverged (prune)", i+1)
require.Equal(t, ah.GasUsed, ph.GasUsed, "block %d GAS USED diverged (prune vs archive)", i+1)
require.Equal(t, block.Bloom(), ph.Bloom, "block %d LOGS BLOOM diverged (prune)", i+1)
require.Equal(t, ah.Bloom, ph.Bloom, "block %d LOGS BLOOM diverged (prune vs archive)", i+1)
// Independently re-derive the receipts root from the canonical receipts.
require.Equal(t, block.ReceiptHash(), types.DeriveSha(receipts[i], trie.NewStackTrie(nil)), "block %d receipts do not derive to header root", i+1)
}
// Whole-chain agreement between patched-pruning and archive execution.
require.Equal(t, archiveBC.CurrentHeader().Hash(), pruneBC.CurrentHeader().Hash(), "patched pruning head hash != archive head hash")
require.Equal(t, archiveBC.CurrentHeader().Root, pruneBC.CurrentHeader().Root, "patched pruning head root != archive head root")
t.Logf("EXECUTION-IDENTICAL head: number=%d hash=%s root=%s",
pruneBC.CurrentHeader().Number, pruneBC.CurrentHeader().Hash().Hex(), pruneBC.CurrentHeader().Root.Hex())
// Golden values pinned from the deterministic workload. These are verified
// byte-identical on the PRE-fix build (stash A/B in the Blue report): the
// unpatched mainnet plugin produces exactly these. If the fix ever perturbs a
// state root or block hash, these assertions break before the plugin can ship.
const (
goldenHeadHash = "0xdef5961779e04fa8d7e93a166f8e271e0c28363875a128bdb717a82d539ce14d"
goldenHeadRoot = "0x8d8cc41e11177f8532bb1fe727a4a3b36c56abbe60f4fafa8747b6d842494ae6"
)
require.Equal(t, goldenHeadHash, pruneBC.CurrentHeader().Hash().Hex(), "head block hash changed — NOT execution-identical to mainnet history")
require.Equal(t, goldenHeadRoot, pruneBC.CurrentHeader().Root.Hex(), "head state root changed — NOT execution-identical to mainnet history")
}
// TestHeadPin_Across4096Boundary_NoLeak is the make-or-break interaction test the
// owner flagged: 32-slot tipBuffer × 4096 commit boundary × distinct-root progression
// × non-boundary idle × GC. It proves (a) execution stays healthy across a real 4096
// commit boundary, (b) PREVIOUS head roots are dereferenced as they leave the
// StateHistory window so the dirty cache stays BOUNDED over thousands of distinct
// roots (no retention leak), (c) the root committed at the 4096 boundary is durable
// on disk, and (d) the head remains serveable after crossing the boundary and idling
// at a non-boundary height, with build-H+1 succeeding.
func TestHeadPin_Across4096Boundary_NoLeak(t *testing.T) {
if testing.Short() {
t.Skip("crosses a full 4096 commit boundary; skipped under -short")
}
sender, recipient := headPinFixtures()
key, _ := crypto.HexToECDSA(senderHexKey)
// Fund heavily so thousands of transfers never run out of gas/balance.
genesisBalance := new(big.Int).Mul(big.NewInt(1_000_000), big.NewInt(params.Ether))
gspec := &Genesis{
Config: params.TestChainConfig,
Alloc: types.GenesisAlloc{sender: {Balance: genesisBalance}},
}
signer := types.LatestSigner(params.TestChainConfig)
const (
commitInterval = uint64(4096)
stateHistory = uint64(32)
activeBlocks = 4100 // distinct-root transfers; crosses the 4096 boundary
idleBlocks = 40 // empty blocks -> idle at a non-boundary tip (4101..4140)
)
genDB, chain, _, err := GenerateChainWithGenesis(gspec, dummy.NewCoinbaseFaker(), activeBlocks+idleBlocks, 10, func(i int, gen *BlockGen) {
if i < activeBlocks {
tx, _ := types.SignTx(types.NewTransaction(gen.TxNonce(sender), recipient, big.NewInt(1), ethparams.TxGas, big.NewInt(225_000_000_000), nil), signer, key)
gen.AddTx(tx)
}
})
require.NoError(t, err)
_ = genDB
bc, err := createBlockChain(rawdb.NewMemoryDatabase(), headPinConfigWith(commitInterval, stateHistory), gspec, common.Hash{})
require.NoError(t, err)
defer bc.Stop()
dirtyBytes := func() uint64 { _, n, _ := bc.triedb.Size(); return uint64(n) }
var (
rootAt4096 common.Hash
dirtyAt500 uint64
dirtyAtBoundary uint64
)
for i := 0; i < activeBlocks; i++ {
require.NoError(t, insertAccept(t, bc, chain[i]), "active block %d", i+1)
switch i + 1 {
case 500:
dirtyAt500 = dirtyBytes()
case int(commitInterval): // block 4096 — the commit boundary
rootAt4096 = bc.CurrentHeader().Root
dirtyAtBoundary = dirtyBytes()
}
// Head is serveable at every step (spot-checked cheaply every 512 blocks).
if (i+1)%512 == 0 {
_, err := bc.StateAt(bc.CurrentHeader().Root)
require.NoError(t, err, "head state unserveable at block %d", i+1)
}
}
require.NotEqual(t, common.Hash{}, rootAt4096)
// Idle at a non-boundary tip (heights 4101..4140), past StateHistory.
for i := activeBlocks; i < activeBlocks+idleBlocks; i++ {
require.NoError(t, insertAccept(t, bc, chain[i]))
}
// (b) Distinct-root retention: over 4100 distinct roots the dirty cache must
// stay bounded to the working set, NOT grow ~linearly. A retention leak would
// make the final cache dwarf the mid-run sample.
dirtyFinal := dirtyBytes()
t.Logf("dirty cache: @500=%d @4096-boundary=%d final(@4140)=%d bytes", dirtyAt500, dirtyAtBoundary, dirtyFinal)
require.Less(t, dirtyFinal, 4*dirtyAt500, "dirty cache grew ~linearly with chain length: retention leak (@500=%d final=%d)", dirtyAt500, dirtyFinal)
// (c) The root committed at the 4096 boundary is durable on disk: it is far
// outside the 32-slot dirty window now, so a successful open must come from disk.
_, err = bc.StateAt(rootAt4096)
require.NoError(t, err, "root committed at the 4096 boundary is not durable on disk")
// (d) Head serveable after boundary + idle, and build-H+1 succeeds.
_, err = bc.StateAt(bc.CurrentHeader().Root)
require.NoError(t, err, "head state unserveable after crossing 4096 boundary + idle")
extra, _, err := GenerateChain(gspec.Config, chain[activeBlocks+idleBlocks-1], dummy.NewCoinbaseFaker(), genDB, 1, 10, func(i int, gen *BlockGen) {
tx, _ := types.SignTx(types.NewTransaction(gen.TxNonce(sender), recipient, big.NewInt(7), ethparams.TxGas, big.NewInt(225_000_000_000), nil), signer, key)
gen.AddTx(tx)
})
require.NoError(t, err)
require.NoError(t, insertAccept(t, bc, extra[0]), "cannot build H+1 after 4096 boundary + idle")
}
// TestHeadPin_RestartAtNonBoundary proves the patched plugin recovers cleanly when
// restarted with an accepted head at a non-CommitInterval height (the exact mainnet
// freeze condition): Shutdown commits the last accepted root, and a fresh chain
// reopened on the same db serves that head and builds on it.
func TestHeadPin_RestartAtNonBoundary(t *testing.T) {
sender, recipient := headPinFixtures()
key, _ := crypto.HexToECDSA(senderHexKey)
genesisBalance := new(big.Int).Mul(big.NewInt(1), big.NewInt(params.Ether))
gspec := &Genesis{
Config: params.TestChainConfig,
Alloc: types.GenesisAlloc{sender: {Balance: genesisBalance}},
}
signer := types.LatestSigner(params.TestChainConfig)
// Active transfers then idle past StateHistory, ending at a non-boundary height.
const activeBlocks, idleBlocks = 5, 40
genDB, chain, _, err := GenerateChainWithGenesis(gspec, dummy.NewCoinbaseFaker(), activeBlocks+idleBlocks, 10, func(i int, gen *BlockGen) {
if i < activeBlocks {
tx, _ := types.SignTx(types.NewTransaction(gen.TxNonce(sender), recipient, big.NewInt(1_000), ethparams.TxGas, big.NewInt(225_000_000_000), nil), signer, key)
gen.AddTx(tx)
}
})
require.NoError(t, err)
_ = genDB
db := rawdb.NewMemoryDatabase()
cfg := headPinConfig()
bc, err := createBlockChain(db, cfg, gspec, common.Hash{})
require.NoError(t, err)
for _, b := range chain {
require.NoError(t, insertAccept(t, bc, b))
}
headHash := bc.CurrentHeader().Hash()
headNum := bc.CurrentHeader().Number.Uint64()
require.NotZero(t, headNum%cfg.CommitInterval, "head must be at a NON-boundary height")
sdb, err := bc.StateAt(bc.CurrentHeader().Root)
require.NoError(t, err)
balBefore := sdb.GetBalance(sender)
// Clean shutdown: Shutdown() commits the last accepted (non-boundary) root.
bc.Stop()
// Restart on the same db, pinned to the accepted head.
restarted, err := createBlockChain(db, cfg, gspec, headHash)
require.NoError(t, err)
defer restarted.Stop()
require.Equal(t, headHash, restarted.CurrentHeader().Hash(), "restart lost the accepted head")
rsdb, err := restarted.StateAt(restarted.CurrentHeader().Root)
require.NoError(t, err, "restarted chain cannot serve the non-boundary head state")
require.Zero(t, rsdb.GetBalance(sender).Cmp(balBefore), "restarted head balance mismatch")
// Build H+1 on the restarted, non-boundary head.
extra, _, err := GenerateChain(gspec.Config, chain[len(chain)-1], dummy.NewCoinbaseFaker(), genDB, 1, 10, func(i int, gen *BlockGen) {
tx, _ := types.SignTx(types.NewTransaction(gen.TxNonce(sender), recipient, big.NewInt(500), ethparams.TxGas, big.NewInt(225_000_000_000), nil), signer, key)
gen.AddTx(tx)
})
require.NoError(t, err)
require.NoError(t, insertAccept(t, restarted, extra[0]), "cannot build H+1 after restart at non-boundary head")
}