erigontech / erigontech/erigon
execution: too-deep FCU passes the unwind gate, partially unwinds, then wedges block production (SeekCommitment "state out of date")
- Dominant language
- Go
- Stars
- 3.6k
- Forks
- 1.5k
- Avg merge
- 1d 16h
- Merged PRs (30d)
- 455
Description
Found on `main` while extending the reorg/unwind integration tests for #21860 (PR #22300).
#### Setup
A node with snapshot production on and default changeset behavior (no `AlwaysGenerateChangesets`), at toy scale to make files/pruning bite: `step_size = 32`, ~300 one-txn blocks, domain files built and the filed range pruned from MDBX. Changesets are still retained for roughly the whole chain (chain-tip retention), but the commitment values/history for old blocks are only in files.
#### What happens
A forkchoice update to block 20 (depth ~290 — within `MaxReorgDepth` and within the retained changesets, but far below the files boundary):
1. is **not** rejected upfront by the reorg-depth / min-unwindable gate,
2. proceeds into a real disk unwind,
3. and fails midway:
```
updateForkChoice: [4/6 Execution] unwind: SeekCommitment after disk unwind: "commitment" state out of date: step 1, expected step 18
```
After this failed FCU the node is half-wedged: plain FCUs back to the tip succeed and state reads are correct, but **block production is permanently broken** — every payload-building forkchoice repeats the same failure:
```
update fork choice failed: updateForkChoice: [4/6 Execution] unwind: SeekCommitment after disk unwind: "commitment" state out of date: step 1, expected step 18
```
A bad or buggy CL request must not be able to wedge the node.
#### Reproduction
`TestEngineApiUnwindBeyondRetainedChangesetsRejectedCleanly` (`execution/engineapi/engine_api_state_churn_prune_test.go`, PR #22300) pins the currently-working part of the contract (loud rejection, head restorable, reads correct) and deliberately stops before block production. Appending one line at its end reproduces the wedge:
```go
churnAndAssert(ctx, t, eat, churn, 3, func(k int) int64 { return int64(6_000 + k) })
```
#### Analysis
The unwindability gate consults the retained changesets (`CanUnwindToBlockNum` / min-unwindable), which at the chain tip can cover far more blocks than the commitment domain can actually rebuild — commitment values for the target block are already files-resident and pruned from MDBX, and `SeekCommitment` cannot reconstruct the state at the unwind target. So the gate and the actual unwind requirements disagree: the unwind starts, mutates state, and then aborts, leaving a persistent inconsistency that every subsequent block-building forkchoice trips over.
Two possible fix directions (not mutually exclusive):
- make the gate account for commitment rebuildability at the target (min-unwindable = max of the changeset-based and commitment-history-based limits), so the FCU is rejected upfront like other too-deep reorgs;
- make the unwind atomic with respect to the `SeekCommitment` failure, so a failed attempt rolls back to a fully consistent pre-unwind state instead of wedging payload building.
Contributor guide
Assessment
This issue has not been assessed yet.