core+miner: rebuild pruned parent state on demand at BuildBlock (post-accept state-loss recovery)

The fresh-multi-node block-production halt is NOT an accept-time commit gap (the
accept-path guard added earlier never fires — state IS present at accept). The
loss is POST-accept: the reorg/reject churn equivocation produces on a fresh
chain dereferences an accepted block's trie AFTER it becomes the head, before the
next BuildBlock reads it, wedging the chain with 'missing trie node' / 'failed to
create new current environment'. Verified live: fresh v1.101.1 devnet re-genesis
halted at block 8 with the head-root missing on all 5 nodes and the accept guard
at 0 firings.

Fix: BlockChain.MaterializeState rebuilds the pruned state range on demand from
the nearest ancestor whose state is present; the miner's createCurrentEnvironment
calls it on a StateAt miss so block production continues instead of halting. No-op
on a healthy chain. reprocessBlock's ValidateState re-checks each rebuilt root
against its header. Keeps the accept-path guard as defence-in-depth.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-07-01 10:25:32 -07:00
co-authored by Hanzo Dev
parent b6d17435ca
commit d61d71e624
2 changed files with 77 additions and 1 deletions
+63
View File
@@ -1154,6 +1154,69 @@ func (bc *BlockChain) LastAcceptedBlock() *types.Block {
return bc.acceptorTip
}
// MaterializeState guarantees the state trie for [target] is present in the trie
// database, rebuilding it on demand from the nearest ancestor whose state is
// available if it was pruned. It is a cheap HasState no-op when the state is
// already present.
//
// This is the recovery half of the fresh-multi-node block-production halt: on a
// fresh chain the reorg/reject churn that equivocation produces can dereference
// an accepted block's trie AFTER it becomes the head (the state is present at
// accept — so the accept-path guard is a no-op — but pruned before the next
// BuildBlock reads it), wedging the chain with "missing trie node". Called from
// the miner's createCurrentEnvironment on a StateAt miss, this re-executes the
// pruned range so BuildBlock can always load its parent state. reprocessBlock's
// ValidateState re-checks each rebuilt root against its header, so a
// non-deterministic transition surfaces loudly rather than silently.
func (bc *BlockChain) MaterializeState(target *types.Block) error {
if bc.HasState(target.Root()) {
return nil
}
bc.chainmu.Lock()
defer bc.chainmu.Unlock()
// Re-check under the lock — a concurrent insert may have materialized it.
if bc.HasState(target.Root()) {
return nil
}
// Walk back to the nearest ancestor whose state is available, collecting the
// missing blocks (target first, oldest-missing last).
var missing []*types.Block
cur := target
for !bc.HasState(cur.Root()) {
if cur.NumberU64() == 0 {
return fmt.Errorf("cannot materialize state for %s:%d: genesis state missing", target.Hash().Hex(), target.NumberU64())
}
missing = append(missing, cur)
parent := bc.GetBlock(cur.ParentHash(), cur.NumberU64()-1)
if parent == nil {
return fmt.Errorf("cannot materialize state for %s:%d: missing ancestor %s:%d", target.Hash().Hex(), target.NumberU64(), cur.ParentHash().Hex(), cur.NumberU64()-1)
}
cur = parent
}
log.Warn("[STATE-MATERIALIZE] rebuilding pruned state on demand",
"target", target.Hash(), "targetHeight", target.NumberU64(), "targetRoot", target.Root(),
"fromHeight", cur.NumberU64(), "blocks", len(missing),
)
// Re-execute forward from [cur] (which has state) through the missing range.
parent := cur
for i := len(missing) - 1; i >= 0; i-- {
child := missing[i]
root, err := bc.reprocessBlock(parent, child)
if err != nil {
return fmt.Errorf("materialize: failed to re-execute %s:%d: %w", child.Hash().Hex(), child.NumberU64(), err)
}
if root != child.Root() {
return fmt.Errorf("materialize: re-executed root %s != header root %s for %s:%d (non-deterministic state transition)",
root.Hex(), child.Root().Hex(), child.Hash().Hex(), child.NumberU64())
}
parent = child
}
return nil
}
// SetLastAcceptedBlockDirect sets the last accepted block directly without
// validation. This is used during block import to update the canonical chain
// head after InsertChain has verified and inserted the blocks.
+14 -1
View File
@@ -281,7 +281,20 @@ func (w *worker) commitNewWork(predicateContext *precompileconfig.PredicateConte
func (w *worker) createCurrentEnvironment(predicateContext *precompileconfig.PredicateContext, parent *types.Header, header *types.Header, feeConfig commontype.FeeConfig, tstart time.Time) (*environment, error) {
currentState, err := w.chain.StateAt(parent.Root)
if err != nil {
return nil, err
// The parent's state trie may have been pruned by the reorg/reject churn a
// fresh multi-node chain produces under equivocation (present at accept,
// dereferenced before this build reads it), which would wedge the chain with
// "missing trie node". Rebuild it on demand so block production continues
// instead of halting. No-op on a healthy chain (StateAt succeeds).
if parentBlock := w.chain.GetBlock(parent.Hash(), parent.Number.Uint64()); parentBlock != nil {
if merr := w.chain.MaterializeState(parentBlock); merr != nil {
return nil, fmt.Errorf("failed to materialize parent state for build: %w (StateAt: %v)", merr, err)
}
currentState, err = w.chain.StateAt(parent.Root)
}
if err != nil {
return nil, err
}
}
chainConfig := params.GetExtra(w.chainConfig)
capacity, err := customheader.GasCapacity(chainConfig, feeConfig, parent, header.Time)