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:
- legacy transaction, nonce 0: accepted;
- legacy transaction, nonce 9: rejected (
NonceMismatchError);
- 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:
process_transaction writes the transaction into block_output.transactions_trie before transaction admission checks.
t8n catches the admission exception and continues. State rollback does not restore the trie stored on BlockOutput.
- 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:
- 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.
- 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:
t8n
derives the post-execution index from len(self.txs), the input count. The
consensus counterpart in apply_body
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, 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
Metadata
forks/amsterdamat5b2b22c75f69bda02615204396b70a91e00529e0evm1.15.6-stable-19d2b4c8uv sync --no-default-groups --group testfrom the committeduv.lockc47fdc1374f8a41a26edc0ac34b7227071c197877fb6d64a6634ac62712cb9a7What 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'snon-consensus reject-and-continue adapter and the roots it emits.
The attached minimized Prague case has three signed transactions:
NonceMismatchError);Run the command in the attachment's
README.md. EELS correctly reports inputindex 1 as rejected and produces the same post-state as Geth, but the emitted
roots differ:
{0, 1}/ GethtxRoot0x33475b089f7e79c177de6104a67e662ad8959fe91e781cd2713a9f4de9be628a0x0e9e7a492709d9dc5ab0cf24b1af81e0ba9f46b03bc48646858d827e7a75c1b1receiptsRoot0x98207864c436426d40ece7e546fd1dedf75d6d8627308705b0b9e9235e7641a20x59fe57186b64100200328863236de3773f63cad6ebd47226f7c34d8765ba34a3Direct trie reconstruction explains both values:
{0, 1, 2}, includingthe rejected transaction.
{0, 2}, leaving a gap forthe rejection.
{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:
process_transactionwrites the transaction intoblock_output.transactions_triebefore transaction admission checks.t8ncatches the admission exception and continues. State rollback does not restore the trie stored onBlockOutput.enumerateindex 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:
succeed, and have
t8nmaintain a separate accepted-transaction counter.The original input index remains available for the
rejectedreport, whilethe accepted index feeds transaction/receipt keys and
block_access_index = accepted_index + 1. Post-transaction operationswould use
accepted_count + 1as well.t8nsnapshot andrestore the transaction and receipt tries (plus related
BlockOutputindexing 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 sametxRootas 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 theattached
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()arerequired. 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. BothtxRootandreceiptsRootdiffer from thataccepted-only run, and the emitted block access lists contain different
indices:
block_access_indexvalues{0, 1, 3}[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 singlewithdrawal to
env.jsonextends this to{0, 1, 3, 4}against{0, 1, 2, 3}: the post-transaction change is indexed 4 rather than 3. Thestate 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:
t8nderives the post-execution index from
len(self.txs), the input count. Theconsensus counterpart in
apply_bodyuses
ulen(transactions) + Uint(1). That is correct there because atransaction 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_indexand the post-transaction index, as sketched in option 1above.
Additional Context
Found while building a t8n frontend for Jaune, a Lean 4 executable EVM specification whose conformance gate is byte-exact against
ethereum-spec-evm.A duplicate search across
ethereum/execution-specsand the historicalethereum/execution-spec-testsissue and PR trackers found no matching report.t8n-rejected-index-repro.zip