Skip to content

feat(amsterdam): glamsterdam devnet-7 (EIP-2780 runtime gas phase) - #315

Merged
rakita merged 7 commits into
mainfrom
glam-devnet-7
Jul 30, 2026
Merged

rakita merged 7 commits into
mainfrom
glam-devnet-7

Conversation

@rakita

@rakita rakita commented Jul 27, 2026 •

Copy link
Copy Markdown
Member

Aligns Amsterdam to the glamsterdam devnet-7 spec and the tests-glamsterdam-devnet@v7.2.0 fixtures. All Amsterdam state_tests and blockchain_tests pass (interpreter + JIT + AOT); no regressions in earlier-fork state suites or the unit/ee-test suite.

Ports bluealloy/revm#3795 to evm2's architecture.

EIP-2780 runtime gas phase (ethereum/EIPs#11844)

State-dependent transaction charges move from the intrinsic phase to a runtime phase applied as the first frame is entered. Running out of gas there is an included out-of-gas halt consuming all regular gas, not a transaction rejection.

  • Create transactions: account-creation state gas is charged at the create frame's entry (execute_create_message), conditional on the destination not already existing. The intrinsic create_state_gas and its refund path are removed.
  • EIP-7702: the intrinsic per-auth charge drops to the state-independent REGULAR_PER_AUTH_BASE_COST (7,816); ACCOUNT_WRITE, new-account, and delegation-bytes state gas are charged per authority at runtime on a transaction-level GasTracker (the RuntimeAuthCharges accounting), stopping at the first unaffordable charge. A runtime out-of-gas reverts the applied delegations and includes the tx as an OOG halt.
  • Delegated recipient (depth 0): resolution is deferred into the frame (apply_eip2780_call_charges) and the target load is gated on gas (skip_cold_load, as nested calls do), so a cold, unafforded target stays out of the EIP-7928 block access list. MessageResult::runtime_gas_oog signals a recipient-charge OOG back to the 7702 handler so it drops the delegations too.

The CALL/CREATE runtime charges live in the shared execute_call_message / execute_create_message (gated on depth == 0 + feature, not tx type), so all transaction types get them; only the authorization-specific charges are in the 7702 handler.

Devnet-7 spec alignment

Cleanups

  • Remove the superseded devnet-6 create accounting (create_initial_state_gas, refund_create_state_gas, MessageResult::created_target_was_alive, settle_gas's is_create path).
  • Remove rollback_failed_execution: failed execution is already rolled back to the message's own checkpoint (and halt gas zeroed) inside execute_message, so it was redundant.

Follow-up refactors

  • Message construction: Message now carries its destination — the CREATE/CREATE2 address is derived when the message is constructed (Message::derive_destination) instead of being re-derived at frame entry.
  • EIP-7702 authorization unification: both gas regimes share one apply_auth_list loop (validate → account → apply) driven by an AuthAccounting strategy — RuntimeAuthCharges (EIP-2780 runtime metering, abortable mid-list) and AuthRefunds (pre-Amsterdam pessimistic-intrinsic refunds, owning the EIP-8037 gating). The handler tail funnels every exit through a single settle closure (settle_oog for the runtime OOG halts), and initial_gas_and_reservoir loses its always-zero state_refund parameter — the pre-Amsterdam state refund is credited straight to the reservoir at the call site, matching execution-specs set_delegation. Crate constants are now pub.

The Message-carries-bytecode refactor (with the EIP-8037 create depth check) and the regular→execution gas rename are split out into the stacked PR #329.

Fixtures / harness

  • Bump devnet fixtures to v7.2.0 (setup script, CI, AGENTS.md).
  • eest: honor an explicit per-transaction chainId (v7 fixtures test type-0 INVALID_CHAINID).

Testing

  • Amsterdam state_tests: 406/406; blockchain_tests: 657/657.
  • JIT + AOT Amsterdam state suites pass on v7.2.0.
  • Unit + ee-tests: 2829/2829; Prague/Osaka/Cancun/Berlin/London/Shanghai state suites unchanged.

Two revm changes were no-ops in evm2: EIP-7708 (already implemented) and JournalEntry::CodeChange recording prior code (evm2's AccountChange already snapshots the full prior AccountInfo).

Aligns Amsterdam to the glamsterdam devnet-7 spec and the
tests-glamsterdam-devnet@v7.2.0 fixtures. All Amsterdam state_tests and
blockchain_tests pass.

EIP-2780 runtime gas phase (ethereum/EIPs#11844)

State-dependent transaction charges move from the intrinsic phase to a
runtime phase; running out of gas there is an included out-of-gas halt,
not a transaction rejection.

- Create transactions: account-creation state gas is charged at the
  create frame's entry (execute_create_message), conditional on the
  destination not already existing; the intrinsic create_state_gas and
  its refund path are removed.
- EIP-7702: the intrinsic per-auth charge drops to the state-independent
  REGULAR_PER_AUTH_BASE_COST (7,816); ACCOUNT_WRITE, new-account, and
  delegation-bytes state gas are charged per authority at runtime on a
  transaction-level GasTracker (apply_auth_list_runtime), stopping at the
  first unaffordable charge. A runtime out-of-gas reverts the applied
  delegations and includes the tx as an out-of-gas halt.
- Depth-0 delegated-recipient resolution is deferred into the frame
  (apply_eip2780_call_charges) and its target load is gated on gas
  (skip_cold_load), so a cold, unafforded target stays out of the
  EIP-7928 block access list; MessageResult::runtime_gas_oog signals a
  recipient-charge OOG back to the 7702 handler.

Devnet-7 spec alignment

- ethereum/EIPs#11836: calldata floor anchored on the decomposed
  intrinsic base instead of flat TX_BASE.
- ethereum/EIPs#11891: 7702 ACCOUNT_WRITE charged once per authority,
  skipped when already paid (sender / value-recipient / prior auth).
- ethereum/EIPs#11858: CREATE/CREATE2 account-creation state gas charged
  conditionally at access (endowment/nonce pre-check + single
  destination read), refilled on failure; mirrored in the JIT builtin.
- ethereum/EIPs#11854: SSTORE covers the slot access cost before the
  implicit read; an unaffordable cold access skips the read (Evm::sstore
  now honors skip_cold_load).
- EIP-8282: builder deposit/exit system-contract addresses updated.
- EIP-7623 floor binds the block regular-gas component
  (TxResult::regular_gas_spent = max(total - state, floor)).

Cleanups

- Remove the superseded devnet-6 create accounting
  (create_initial_state_gas, refund_create_state_gas,
  MessageResult::created_target_was_alive, settle_gas's is_create path).
- Remove rollback_failed_execution: failed execution is already rolled
  back to the message's own checkpoint and halt gas zeroed inside
  execute_message, so it was redundant.

Fixtures / harness

- Bump devnet fixtures to v7.2.0 (setup script, CI, AGENTS.md).
- eest: honor an explicit per-transaction chainId (v7 fixtures test
  type-0 INVALID_CHAINID).
let effective_gas_cost = U256::from(tx.gas_limit) * gas_price;
charge_upfront(req.host, caller, effective_gas_cost)?;
req.host.state.account(&caller, false).map_err(error_handler!(req.host))?.bump_nonce();
let execution_checkpoint = req.host.state.checkpoint();

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Checkpointing was not needed here as changes are not reversible, and if it is an invalid tx for any case, the state is going to be discarded.

Comment thread crates/evm2/src/evm/tx.rs
pub const fn regular_gas_spent(&self) -> u64 {
self.total_gas_spent.saturating_sub(self.state_gas_spent)
let regular = self.total_gas_spent.saturating_sub(self.state_gas_spent);
if regular > self.floor_gas { regular } else { self.floor_gas }

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Compute the CREATE/CREATE2 contract address once, when the message is
constructed, and store it directly in `Message.destination` instead of
caching it on the message and re-deriving it at frame entry.

- `create_inner` and the JIT `__revmc_builtin_create` now derive the
  address unconditionally (removing the EIP-8037 guard around the
  computation) and write it into `destination`. The caller is loaded
  once for the nonce and/or the EIP-8037 balance/nonce checks; only the
  `target_is_empty_for_new_account_gas` destination read stays gated
  behind the balance/nonce pre-checks (EIP-7928 BAL-leak safety).
- Drop the `cached_created_address` / `cached_init_code_hash` fields;
  `created_address`/`init_code_hash` are now pure helpers.
- Remove the frame-entry re-derivation sites and `derive_create_address`;
  `destination` is authoritative everywhere.
- Document that `Message.destination` holds the execution target for
  calls and the yet-to-be-created contract address for CREATE/CREATE2.

Verified against glamsterdam devnet v7.2.0 fixtures: 10757 state_tests
and 11426 blockchain_tests pass. Pre-Amsterdam creates confirm the
earlier caller load is observably neutral.
// `destination` already holds the create's contract address (set when the message was
// constructed), so the create hook observes it directly.
let is_create = matches!(message.kind, MessageKind::Create | MessageKind::Create2);
if is_create {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Simplfication allows us to do this. destination now always contains the created address, so we don't need to recreate it here (hash caller/nonce/init_code etc)

);
}
if let Err(stop) = self.prepare_create_message(&bytecode, message) {
if let Err(stop) = self.prepare_create_message(message) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

As we have already created address, we dont need to send bytecode

if self.inspector.is_none() {
message.destination =
Self::derive_create_address(bytecode, message, info.map_or(0, |info| info.nonce));
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is simplified, as the destination will always contain the created account address and we dont need to recalculate it. And we can remove bytecode from inputs.

Inspector has removed this too.

}

/// Derives the destination address for a create message.
fn derive_create_address(bytecode: &Bytecode, message: &Message<T>, nonce: u64) -> Address {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No need for confusion of depth == 0 return destination

let mut frame_gas =
GasTracker::new_with_regular_gas_and_reservoir(message.gas_limit, message.reservoir);
let mut bytecode = bytecode;
match self.apply_eip2780_call_charges(message, &mut frame_gas) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Note for myself, it is slightly strange that we load and do runtime checks inside execution. See how this would look if we did it in handler.

rakita added 2 commits July 28, 2026 12:15
Rename `init_code_hash` to `init_code_hash_slow` to signal it hashes the
initcode, and turn `created_address(&self, nonce) -> Address` into
`derive_destination(&mut self, nonce)`, which derives the CREATE/CREATE2
address and writes it into `Message.destination` directly. Call sites in
`create_inner` and the JIT create builtin simplify accordingly.
# Conflicts:
#	crates/evm2/src/ethereum/eip1559.rs
#	crates/evm2/src/ethereum/eip2930.rs
#	crates/evm2/src/ethereum/eip4844.rs
#	crates/evm2/src/ethereum/eip7702.rs
#	crates/evm2/src/ethereum/legacy.rs
#	crates/evm2/src/ethereum/mod.rs
#	crates/evm2/src/evm/mod.rs
#	crates/evm2/src/interpreter/message.rs
let mut frame_gas =
GasTracker::new_with_regular_gas_and_reservoir(message.gas_limit, message.reservoir);
let (eip2780_regular, eip2780_state) = eip2780_charges;
if frame_gas.spend(eip2780_regular).is_err()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is all moved to pre execution, so now we dont have special depth == 0 case in the loop

///
/// Returns `(regular, state)`, both zero unless EIP-2780 is active at depth 0.
fn eip2780_call_charges(&mut self, message: &Message<T>) -> (u64, u64) {
if message.depth != 0 || !self.feature(EvmFeatures::EIP2780) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

depth == 0 case is moved to the handler in helper function.

let _guard = self.enter_execution();
let interp_ref = interp.as_mut();
interp_ref.init(bytecode, tx_env, message);
// Adopt the caller's frame tracker, which carries the EIP-2780 depth-0 charges

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

By moving the loading of eip2780 stuff to pre execution, we can remove depth == 0 custom stuff from the loop

@rakita
rakita marked this pull request as ready for review July 28, 2026 18:46
@rakita
rakita requested review from DaniPopes and onbjerg as code owners July 28, 2026 18:46
@rakita

rakita commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

cyclops audit

@github-actions

github-actions Bot commented Jul 28, 2026 •

Copy link
Copy Markdown

cc @rakita

Cyclops audit event published. View workflow run

Config: config: default, iterations: default, hours: default

@tempoxyz-bot tempoxyz-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

👁️ Cyclops Review

PR #315 implements the Glamsterdam devnet-7 runtime-gas and state-gas accounting changes. I rechecked the surviving findings against the current head after drift from the audited commit; two low-severity accounting issues still apply and are commented inline.

Reviewer Callouts
  • ⚡ EIP-7702 runtime-OOG BAL comments: crates/evm2/src/ethereum/eip7702.rs:102-105 says rollback keeps the recipient/authorities out of the EIP-7928 block access list, but authorities already loaded during validate_one_auth can remain as BAL reads. Add a fixture or adjust the comments to describe only never-loaded later authorities.
  • ⚡ Type-4 hook ordering: TxHandlerHooks::before_execution now runs before the EIP-7702 authorization phase. Downstream hooks that expect “standard pre-execution state changes” to include applied delegations should get explicit trait documentation or a migration note.
  • ⚡ Fatal host error coupling: prepare_initial_frame currently treats any Host::load_account error as Ok(None) and relies on finalize_gas to surface host.error_code. Matching Err(InstrStop::OutOfGas) explicitly would avoid coupling custom settlement hooks to the default finalizer.
  • ⚡ Low-level Message construction contract: In-tree producers now populate Message.code and create destination, but custom Host::execute_message callers can still build inconsistent messages. Constructors or debug assertions would reduce this API footgun.
  • ⚡ Custom gas-parameter consistency: RuntimeAuthCharges::new hardcodes EIP8038_ACCOUNT_WRITE while other authorization prices come from Version::gas_params; custom forks that reprice this cost should not have to patch the handler manually.

let mut auth_refunds = AuthRefunds::new(req.host.version());
apply_auth_list(req.host, chain_id, &tx.authorization_list, &mut auth_refunds)?;
let AuthRefunds { state_refund, execution_refund, .. } = auth_refunds;
tx_gas.set_reservoir(tx_gas.reservoir() + state_refund);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛡️ [DEFENSE-IN-DEPTH] Pre-EIP-2780 authorization refunds can credit unbacked state gas under custom feature sets

In the !EIP2780 branch, AuthRefunds can still accumulate EIP-8037 state_refund values and this line credits them directly into the transaction reservoir. PR #315 removed the matching intrinsic per-authorization state-gas charge by starting EIP-7702 initial_state_gas at zero, so a custom Version with EIP8037 enabled and EIP2780 disabled can mint spendable reservoir gas and reduce total_gas_spent. The shipped version table currently enables those features together, but the public feature set can be customized.

Recommended Fix:
Make authorization state refunds follow the same predicate as the charge: remove the EIP-8037 state-refund arms from AuthRefunds in this pre-EIP-2780 branch, or restore the matching intrinsic state-gas charge whenever those refunds can be produced. Consider guarding unsupported EIP8037 && !EIP2780 combinations.

@rakita rakita Jul 30, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is fine, assumption is that EIP-2780 is not enabled without EIP-8037

Comment thread crates/evm2/src/ethereum/eip7702.rs Outdated
gas_price,
gas_limit: tx.gas_limit,
floor_gas,
initial_state_gas: 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ [ISSUE] EIP-7702 settlement drops hook-provided intrinsic state gas

handle_with_hooks lets TxHandlerHooks::adjust_intrinsic_gas add to initial_state_gas and then feeds that amount into initial_gas_and_reservoir, so the transaction gas tracker is charged for it. This settlement field is hardcoded to 0, so finalize_gas never reports the upfront state gas for EIP-7702 transactions; downstream EIP-8037/EIP-7778 accounting then shifts that amount into the execution-gas dimension for type-4 transactions with custom hooks.

Recommended Fix:
Pass the captured variable here (initial_state_gas,) like the other transaction handlers, or stop feeding hook-provided state gas into initial_gas_and_reservoir for this handler if type-4 transactions intentionally should not support it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

True, initial_state_gas should be taken into account in settle_gas. Commit 6830874

rakita added 2 commits July 29, 2026 14:41
Merge the two auth-list flows into a single apply_auth_list loop
(validate -> account -> set_delegation) driven by an AuthAccounting
strategy: RuntimeAuthCharges meters the EIP-2780 runtime charges on the
transaction-level tracker and can abort mid-list (unloaded authorities
stay out of the EIP-7928 block access list), while AuthRefunds
accumulates the pre-Amsterdam pessimistic-intrinsic refunds and owns the
EIP-8037 gating.

The handler tail is shared too: one tracker/runtime_checkpoint setup,
the pre-Amsterdam state refund credited straight to the reservoir at the
call site (execution-specs set_delegation semantics), and every exit
funnels through a single settle closure, with settle_oog covering the
runtime out-of-gas halts. The now always-zero state_refund parameter of
initial_gas_and_reservoir is removed.

Also: make the crate constants pub.
The EEST main develop suite renamed gas_limit_below_minimum,
invalid_header, and withdrawals_root to flat test_*.json names, so the
existing consensus-level skip entries no longer matched and the
fixtures ran (and failed) as harness tests.
@rakita
rakita merged commit 78bca97 into main Jul 30, 2026
34 checks passed
@rakita
rakita deleted the glam-devnet-7 branch July 30, 2026 09:36
rakita added a commit that referenced this pull request Sep 4, 2026
…d frames (#366)

## Summary

- reject Amsterdam `CREATE`/`CREATE2` when the caller cannot fund the
endowment or its nonce is exhausted
- return zero and clear returndata before deriving/accessing the
destination or constructing a child message
- mirror the pre-access path in the interpreter and JIT builtins, with
inspector regressions for both failure modes

## Divergence

Under EIP-8037, revm performs the caller balance and nonce checks in the
CREATE opcode before yielding a child frame. evm2 already used those
checks to suppress destination state-gas access, but still split child
gas, constructed a create message, and invoked the host/inspector. That
produced a spurious CREATE/CREATE2 inspector frame for an operation revm
completed locally.

This keeps the opcode charge and caller load, then matches revm by
clearing returndata, pushing zero, and returning immediately. The
destination remains untouched and no child gas split or inspector hook
occurs.

The earlier CALL runtime-OOG candidate is intentionally excluded: the
frame-preparation changes from #315 already charge runtime gas before
host/inspector execution, and its saved artifact passes.

## Validation

- `cargo test -p evm2 --lib` (480 passed)
- `LLVM_SYS_221_PREFIX=/usr/lib/llvm-22 cargo test -p evm2-jit-builtins
--lib` (7 passed)
- `LLVM_SYS_221_PREFIX=/usr/lib/llvm-22 cargo clippy -p evm2 -p
evm2-jit-builtins --all-targets -- -D warnings`
- `cargo +nightly fmt --all -- --check`
- replayed
`structured_compare_amsterdam/crash-32ae6735a8cc706d748eb11f591a96e9bb24d096`
- replayed
`bytecode_compare_amsterdam/crash-3fd99af48ccd1bbe3ef5a22fe6f9580add92ab9a`
- replayed
`bytecode_compare_amsterdam/crash-f97ed6c8940d4ebaf809c86eeb31dd32af83455e`

Built as one commit from `a48e281c76f5fd0011b7f13f17c32d3db0ffbf11`.

---------

Co-authored-by: rakita <dragan0rakita@gmail.com>

This branch was previously deployed

1 inactive deployment
codspeed — 68308741 Deployed Jul 30, 2026 by rakita via codspeed #520
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.

3 participants