ethereum / ethereum/execution-specs
t8n: rejected transactions produce inconsistent transaction and receipt roots
- Dominant language
- Python
- Stars
- 1.2k
- Forks
- 505
- Avg merge
- 2d 14h
- Merged PRs (30d)
- 116
Description
### Metadata
- Python: CPython 3.12.13
- Operating System: macOS 26.5.1 (arm64)
- EELS: `forks/amsterdam` at `5b2b22c75f69bda02615204396b70a91e00529e0`
- Independent comparison: Geth `evm` 1.15.6-stable-19d2b4c8
- Environment: `uv sync --no-default-groups --group test` from the committed `uv.lock`
- Repro attachment SHA-256: `c47fdc1374f8a41a26edc0ac34b7227071c197877fb6d64a6634ac62712cb9a7`
### What was wrong?
The consensus execution path is correct: if a block contains an invalid
transaction, transaction processing raises and the block is invalid; it does
not continue with later transactions. This report is limited to `t8n`'s
non-consensus reject-and-continue adapter and the roots it emits.
The attached minimized Prague case has three signed transactions:
1. legacy transaction, nonce 0: accepted;
2. legacy transaction, nonce 9: rejected (`NonceMismatchError`);
3. type-2 transaction, nonce 1: accepted.
Run the command in the attachment's `README.md`. EELS correctly reports input
index 1 as rejected and produces the same post-state as Geth, but the emitted
roots differ:
| root | EELS | accepted transactions re-indexed `{0, 1}` / Geth |
| --- | --- | --- |
| `txRoot` | `0x33475b089f7e79c177de6104a67e662ad8959fe91e781cd2713a9f4de9be628a` | `0x0e9e7a492709d9dc5ab0cf24b1af81e0ba9f46b03bc48646858d827e7a75c1b1` |
| `receiptsRoot` | `0x98207864c436426d40ece7e546fd1dedf75d6d8627308705b0b9e9235e7641a2` | `0x59fe57186b64100200328863236de3773f63cad6ebd47226f7c34d8765ba34a3` |
Direct trie reconstruction explains both values:
- EELS's transaction trie contains original input keys `{0, 1, 2}`, including
the rejected transaction.
- EELS's receipt trie contains original input keys `{0, 2}`, leaving a gap for
the rejection.
- Geth emits two accepted transactions and two receipts at contiguous keys
`{0, 1}`; its receipts also report transaction indices 0 and 1.
No contiguously indexed accepted transaction subset reproduces both EELS
roots, so the pair cannot describe a valid block.
The interaction appears to be:
1. [`process_transaction`](https://github.com/ethereum/execution-specs/blob/5b2b22c75f69bda02615204396b70a91e00529e0/src/ethereum/forks/amsterdam/fork.py#L1010-L1091) writes the transaction into `block_output.transactions_trie` before transaction admission checks.
2. [`t8n`](https://github.com/ethereum/execution-specs/blob/5b2b22c75f69bda02615204396b70a91e00529e0/src/ethereum_spec_tools/evm_tools/t8n/__init__.py#L324-L337) catches the admission exception and continues. State rollback does not restore the trie stored on `BlockOutput`.
3. The loop passes the original `enumerate` index into later calls, so a later accepted receipt retains the gap.
This is a transition-tool emission issue only: it has zero consensus impact.
The existing fixture-generation path also structurally avoids the reproducer
by placing an exception-producing transaction last and recomputing the
transaction root rather than trusting this emitted trie, so no fixture-corpus
impact is known.
### How can it be fixed?
I see two reasonable designs and would prefer maintainer direction before
opening a PR:
1. Refactor transaction-trie insertion to occur only after admission checks
succeed, and have `t8n` maintain a separate accepted-transaction counter.
The original input index remains available for the `rejected` report, while
the accepted index feeds transaction/receipt keys and
`block_access_index = accepted_index + 1`. Post-transaction operations
would use `accepted_count + 1` as well.
2. Keep consensus processing order unchanged, but have `t8n` snapshot and
restore the transaction and receipt tries (plus related `BlockOutput`
indexing state) around each attempted transaction, together with the same
accepted-transaction counter.
I am happy to implement the direction maintainers prefer, including a
mid-list-rejection regression covering both roots and the block access index.
### Amsterdam confirmation
The attached repro uses Prague to keep the prestate minimal; the defect is not
Prague-specific. Rerunning the attachment's inputs unchanged, changing only
`--state.fork=Amsterdam`, emits the same `txRoot` as the Prague run:
`0x33475b089f7e79c177de6104a67e662ad8959fe91e781cd2713a9f4de9be628a`.
That run also reports a block exception because the Prague-oriented alloc
lacks the Amsterdam predeploys below. Adding them makes the run complete
without a block exception and does not change the `txRoot`.
Amsterdam additionally exposes the block access index. To reach it, add the
two EIP-8282 builder predeploy accounts—with nonce 1 and their canonical
runtime bytecode from Amsterdam's `pre_allocation_blockchain()`—to the
attached `alloc.json`:
- `0x0000BFF46984E3725691FA540A8C7589300D8282` (builder deposit)
- `0x000064D678505AD48F8CCB093BC65613800E8282` (builder exit)
Those are the only Amsterdam-specific additions needed for this diagnostic
run; no other entries from Amsterdam's `pre_allocation_blockchain()` are
required. With them present, the same three transactions complete without a
block exception and produce the same state root as an accepted-only
`[tx0, tx2]` run. Both `txRoot` and `receiptsRoot` differ from that
accepted-only run, and the emitted block access lists contain different
indices:
| run | distinct emitted `block_access_index` values |
| --- | --- |
| 3 inputs, input 1 rejected | `{0, 1, 3}` |
| accepted-only `[tx0, tx2]` | `{0, 1, 2}` |
Index 0 records the pre-transaction system operations in both runs, so the
transaction changes land at `{1, 3}` rather than `{1, 2}`. Adding a single
withdrawal to `env.json` extends this to `{0, 1, 3, 4}` against
`{0, 1, 2, 3}`: the post-transaction change is indexed 4 rather than 3. The
state root is compared between the two runs rather than quoted because its
absolute value depends on which predeploys are included in the starting
allocation.
Alongside the three interactions listed above, forks with a block access list
add a fourth:
4. [`t8n`](https://github.com/ethereum/execution-specs/blob/5b2b22c75f69bda02615204396b70a91e00529e0/src/ethereum_spec_tools/evm_tools/t8n/__init__.py#L387-L391)
derives the post-execution index from `len(self.txs)`, the input count. The
consensus counterpart in [`apply_body`](https://github.com/ethereum/execution-specs/blob/5b2b22c75f69bda02615204396b70a91e00529e0/src/ethereum/forks/amsterdam/fork.py#L836-L839)
uses `ulen(transactions) + Uint(1)`. That is correct there because a
transaction failure aborts block processing; any block that reaches the
post-execution operations contains every transaction supplied to
`apply_body`.
This confirms that the accepted-transaction counter must also drive
`block_access_index` and the post-transaction index, as sketched in option 1
above.
### Additional Context
Found while building a t8n frontend for [Jaune](https://github.com/skbaek/jaune), a Lean 4 executable EVM specification whose conformance gate is byte-exact against `ethereum-spec-evm`.
A duplicate search across `ethereum/execution-specs` and the historical
`ethereum/execution-spec-tests` issue and PR trackers found no matching report.
[t8n-rejected-index-repro.zip](https://github.com/user-attachments/files/30959950/t8n-rejected-index-repro.zip)
Contributor guide
Research direction
Run the attached reproduction using the command in its README, then inspect ethereum_spec_tools/evm_tools/t8n/__init__.py and the linked process_transaction and apply_body entry points. Trace how rejected transactions affect trie keys, receipt indices, block_access_index, and post-execution indexing. Done means a mid-list rejection produces roots and indices matching the accepted-only run, with a regression covering both roots and the block access index.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- cli, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100