Skip to content

t8n: rejected transactions produce inconsistent transaction and receipt roots #3362

Description

@skbaek

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 writes the transaction into block_output.transactions_trie before transaction admission checks.
  2. t8n 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:

  1. 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

Metadata

Assignees

No one assigned

    Labels

    C-bugCategory: this is a bug, deviation, or other problem

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions