feat(amsterdam): glamsterdam devnet-7 (EIP-2780 runtime gas phase) - #315
Conversation
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(); |
There was a problem hiding this comment.
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.
| 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 } |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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)); | ||
| } |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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.
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() |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
By moving the loading of eip2780 stuff to pre execution, we can remove depth == 0 custom stuff from the loop
|
cyclops audit |
|
cc @rakita Cyclops audit event published. View workflow run Config: config: |
tempoxyz-bot
left a comment
There was a problem hiding this comment.
👁️ 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-105says rollback keeps the recipient/authorities out of the EIP-7928 block access list, but authorities already loaded duringvalidate_one_authcan remain as BAL reads. Add a fixture or adjust the comments to describe only never-loaded later authorities. - ⚡ Type-4 hook ordering:
TxHandlerHooks::before_executionnow 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_framecurrently treats anyHost::load_accounterror asOk(None)and relies onfinalize_gasto surfacehost.error_code. MatchingErr(InstrStop::OutOfGas)explicitly would avoid coupling custom settlement hooks to the default finalizer. - ⚡ Low-level
Messageconstruction contract: In-tree producers now populateMessage.codeand createdestination, but customHost::execute_messagecallers can still build inconsistent messages. Constructors or debug assertions would reduce this API footgun. - ⚡ Custom gas-parameter consistency:
RuntimeAuthCharges::newhardcodesEIP8038_ACCOUNT_WRITEwhile other authorization prices come fromVersion::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); |
There was a problem hiding this comment.
🛡️ [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.
There was a problem hiding this comment.
This is fine, assumption is that EIP-2780 is not enabled without EIP-8037
| gas_price, | ||
| gas_limit: tx.gas_limit, | ||
| floor_gas, | ||
| initial_state_gas: 0, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
True, initial_state_gas should be taken into account in settle_gas. Commit 6830874
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.
…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>
Aligns Amsterdam to the glamsterdam devnet-7 spec and the
tests-glamsterdam-devnet@v7.2.0fixtures. All Amsterdamstate_testsandblockchain_testspass (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.
execute_create_message), conditional on the destination not already existing. The intrinsiccreate_state_gasand its refund path are removed.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-levelGasTracker(theRuntimeAuthChargesaccounting), stopping at the first unaffordable charge. A runtime out-of-gas reverts the applied delegations and includes the tx as an OOG halt.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_oogsignals 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 ondepth == 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
TX_BASE.ACCOUNT_WRITEcharged once per authority, skipped when already paid (sender / value-recipient / a preceding valid authorization).CREATE/CREATE2account-creation state gas charged conditionally at access (endowment/nonce pre-check + a single destination read), refilled on failure. Mirrored in the JIT builtin.SSTOREcovers the slot's access cost before the implicit read; an unaffordable cold access skips the read (Evm::sstorenow honorsskip_cold_load).TxResult::regular_gas_spent = max(total - state, floor)).Cleanups
create_initial_state_gas,refund_create_state_gas,MessageResult::created_target_was_alive,settle_gas'sis_createpath).rollback_failed_execution: failed execution is already rolled back to the message's own checkpoint (and halt gas zeroed) insideexecute_message, so it was redundant.Follow-up refactors
Messagenow carries itsdestination— the CREATE/CREATE2 address is derived when the message is constructed (Message::derive_destination) instead of being re-derived at frame entry.apply_auth_listloop (validate → account → apply) driven by anAuthAccountingstrategy —RuntimeAuthCharges(EIP-2780 runtime metering, abortable mid-list) andAuthRefunds(pre-Amsterdam pessimistic-intrinsic refunds, owning the EIP-8037 gating). The handler tail funnels every exit through a singlesettleclosure (settle_oogfor the runtime OOG halts), andinitial_gas_and_reservoirloses its always-zerostate_refundparameter — the pre-Amsterdam state refund is credited straight to the reservoir at the call site, matching execution-specsset_delegation. Crate constants are nowpub.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
AGENTS.md).chainId(v7 fixtures test type-0INVALID_CHAINID).Testing
state_tests: 406/406;blockchain_tests: 657/657.Two revm changes were no-ops in evm2: EIP-7708 (already implemented) and
JournalEntry::CodeChangerecording prior code (evm2'sAccountChangealready snapshots the full priorAccountInfo).