Skip to content

refactor(amsterdam): Re-factor Amsterdam + Preparation for EIP-8141#3192

Draft
gurukamath wants to merge 8 commits into
ethereum:forks/amsterdamfrom
gurukamath:refactor/remove-message-with-8141
Draft

refactor(amsterdam): Re-factor Amsterdam + Preparation for EIP-8141#3192
gurukamath wants to merge 8 commits into
ethereum:forks/amsterdamfrom
gurukamath:refactor/remove-message-with-8141

Conversation

@gurukamath

@gurukamath gurukamath commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Description

Note: This PR does not contain any EIP-8141 (Frame Transaction) implementation code but includes the minimal refactor aimed at accomplishing the following

  1. Clean up the Amsterdam specs more broadly than what refactor(spec-specs): organize gas logic in amsterdam EIP-8037/8038/2780 #3159 did.
  2. Make it easier to implement EIP-8141 when needed

A series of behavior-preserving refactors that disentangle top-frame ("the
transaction") machinery from generic call-frame execution, as groundwork for
EIP-8141 frame transactions. Each commit fills
byte-identically on the full Amsterdam fixture set; ruff and mypy clean.

What changes (8 commits, ~688/−624):

  • Merge Message into Evm — the two were mutable halves of one frame
    with aliased warm sets and double-written code. Message's call parameters
    fold onto Evm;
  • check_transaction returns a TransactionEnvironment instead of an
    ad-hoc tuple.
  • Hoist top-frame preparation (EIP-7702 delegation + dispatch resolution)
    out of process_message and up into process_message_call, so
    process_message only executes the code of whichever frame it is given.
  • Split top-level dispatch into ready_top_level_evm / run_top_level_evm,
    keeping everything transaction-scoped (sender charging, settlement, receipts)
    outside the pair.

Behavior: none. Pure refactor — full Amsterdam suite (52,812 cases) fills
byte-identically.

Related Issues or PRs

#3159

Checklist

  • Ran fast static checks to avoid CI fails, see Code Standards & Verifying Changes: just static
  • PR title has the form <type>(<area>): <title>, where <type> and <area> come from an appropriate C-<type>, respectively A-<area>, label. The title should match the target squash commit message.

Cute Animal Picture

Cute Animals - 2 of 12

Rename `prepare_dispatch` to `resolve_dispatch`: it resolves the top
frame's code and charges the state-dependent dispatch costs, and the
name frees "prepare" for a later `prepare_message_call` split. Replace
the `set_delegation` match/case on an optional with a plain
`if authority is None: continue`.

Groundwork for merging Message into the Evm call frame; no behavior
change (Amsterdam fixtures fill byte-identically).
…ransaction

`check_transaction` now assembles and returns the
`TransactionEnvironment` instead of an ad-hoc tuple, consuming the
`IntrinsicGasCost` from `validate_transaction` and calling
`allocate_execution_gas` for the regular/state gas split. The
access-list prewarming and authorizations move in with it, and the
transactions-trie write moves after validation -- a transaction that
fails validation aborts the block, so recording it first was an
unobservable mutation.

The chain-id check stays in `process_transaction` before
`recover_sender`: for a legacy transaction `chain_id` also validates the
`v` value that `recover_sender` then relies on, so it cannot move into
`check_transaction` (which runs after `recover_sender`).

No behavior change (Amsterdam fixtures fill byte-identically).
Message and Evm were two mutable halves of one frame: the warm sets were
aliased, resolve_dispatch double-wrote the code, and the top frame
mutated the supposedly immutable message. Fold Message's call parameters
onto the Evm dataclass so callers construct child frames directly and the
Message->Evm hand-off disappears.

- The two entry grants (entry_gas, state_gas_grant) live on the
  GasMeter, which becomes self-describing: tx_state_gas_used and
  restore_state_gas_to_entry no longer take the grant as a parameter.
- MessageCallOutput is retired; process_message_call returns the
  finished frame and normalizes error results on it.
- prepare_message becomes create_top_level_evm in fork.py and
  utils/message.py is deleted; system transactions build the frame
  inline.
- The unreachable stack-depth guard moves inside process_message's try,
  so a fire is settled as the exceptional halt it is rather than
  escaping the frame.
- The EIP-3155 and opcode-count tracers resolve the message-scoped
  fields via getattr(evm, "message", evm), keeping older forks traced
  through the same protocols; the Evm trace protocol drops its stale
  `message` member.

No behavior change: the full Amsterdam suite (52,812 cases) fills
byte-identically; ruff and mypy clean.
…usion

Drop the dead TransactionEnvironment.recipient/.value fields (no
readers), and hoist the transaction's resolved blob fee onto the
environment as blob_gas_fee, computed once in check_transaction at the
current blob gas price. Extract the upfront sender debit into a
charge_sender helper that reads that fee, and derive the top frame's
create address from tx.nonce directly rather than reading the
already-incremented sender nonce back.

Byte-identical on Amsterdam fixtures.
Move the depth-0 preparation block -- EIP-7702 delegation application
and dispatch resolution -- out of process_message and up into
process_message_call, where it runs once before the create-vs-call
dispatch. process_message now executes only the code of whichever
frame it is given, top-level or child.

Byte-identical on the full Amsterdam fixture set.
Split process_message_call into ready_top_level_evm (apply
authorizations and resolve dispatch) and run_top_level_evm (dispatch
to create or call, then close out). Both mutate the frame in place and
are called as a pair by process_transaction and
process_unchecked_system_transaction. Everything transaction-scoped --
sender charging, settlement, receipt assembly -- stays outside the
pair, so a future frame loop can call it once per top-level frame.

Byte-identical on Amsterdam fixtures.
Pass the top frame's finished GasMeter to settle_transaction_gas and
derive execution gas used, refund counter, and net state gas from it
inside the function, rather than threading each figure through the
call. The pre-refund usage is now the intrinsic regular cost plus the
execution gas the frame consumed across both dimensions.

Byte-identical on the full Amsterdam fixture set.
Move the two post-settlement fee transfers -- the unspent-gas refund
and the coinbase priority fee -- into disburse_gas_fees, parameterized
on the payer that receives the refund (the sender on the standard
path). Pairs with charge_sender: one fronts the maximum fee at
inclusion, the other returns the remainder and pays the miner.

Byte-identical on Amsterdam fixtures.
@codecov

codecov Bot commented Jul 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.89916% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.39%. Comparing base (2119b38) to head (98fe743).

Files with missing lines Patch % Lines
src/ethereum/forks/amsterdam/fork.py 97.36% 1 Missing and 1 partial ⚠️
src/ethereum/forks/amsterdam/vm/interpreter.py 96.42% 1 Missing and 1 partial ⚠️
...rc/ethereum/forks/amsterdam/vm/instructions/log.py 0.00% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@                 Coverage Diff                 @@
##           forks/amsterdam    #3192      +/-   ##
===================================================
- Coverage            93.39%   93.39%   -0.01%     
===================================================
  Files                  624      623       -1     
  Lines                37014    37000      -14     
  Branches              3386     3385       -1     
===================================================
- Hits                 34570    34555      -15     
- Misses                1671     1672       +1     
  Partials               773      773              
Flag Coverage Δ
unittests 93.39% <97.89%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

accessed_storage_keys=evm.accessed_storage_keys.copy(),
)
child_evm = process_create_message(child_message)
child_evm = process_create_message(child_evm)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should process_create_message be renamed, or is the "message" concept still applicable?


"""
data = evm.message.data
data = evm.data

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

data on its own was decent when it was part of Message, but perhaps it should be changed to call_data or something more specific now that it's bound to the Evm itself?

Execution is organized around a single call frame, the [`Evm`][evm]: one
object carrying a call's parameters, its machine state (program counter,
stack, memory), the gas it has left, and the effects it accrues (logs,
refunds, accounts to delete). A call spawns a child frame, and each

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
refunds, accounts to delete). A call spawns a child frame, and each
refunds, accounts to delete). A call (or create) spawns a child frame, and each

stack, memory), the gas it has left, and the effects it accrues (logs,
refunds, accounts to delete). A call spawns a child frame, and each
top-level call is a frame at depth zero; a transaction executes one or
more such frames, so `.interpreter` drives every frame the same way.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
more such frames, so `.interpreter` drives every frame the same way.
more such frames, so [`interpreter`](ref:ethereum.forks.amsterdam.vm.interpreter) drives every frame the same way.

@@ -137,45 +157,50 @@ class TransactionEnvironment:

@final
@dataclass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, can you make Evm frozen?

Apply any EIP-7702 authorizations and resolve the code the frame
runs. These state-dependent gas charges go through the frame's gas
pools; if they exhaust it, roll the preparation back, settle the
meter, and record the error on the frame so ``run_top_level_evm``

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be a link.

6. `return_data`: The output of the execution.
7. `state_gas_left`: remaining state gas after execution.
8. `state_gas_used`: State gas used during execution.
Ready the transaction's top-level frame for dispatch.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably just me misunderstanding, but if this function is "readying" a frame for dispatch, should it also be "resolving dispatch"? Seems odd that the function that does the readying also does the thing.

Dispatch a readied top-level frame and close it out.

If readying already failed there is nothing to dispatch. Otherwise,
if `evm.target` is empty the dispatch creates a smart contract, else

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Link would be nice.

evm = process_create_message(message)
tx_state = evm.tx_env.state

if not evm.error:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a weird flow, IMO. You're entering the run function with the post-run error already set, then checking the post-run error again after running.

Comment on lines +120 to +122
# Recent forks merge the message fields into the frame itself;
# older forks keep them on `evm.message`.
message = getattr(evm, "message", evm)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This'll need to get fixed when you backport the refactor, yes? Assuming backporting is still a thing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants