erigontech / erigontech/erigon

execution: too-deep FCU passes the unwind gate, partially unwinds, then wedges block production (SeekCommitment "state out of date")

Open
#22,301 1 comment 0 reactions 2 assignees Claimed by @taratorio View on GitHub
ErigonDB
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.