From d4efd7613b9d8049af3c5ef2217a62dfd4d06102 Mon Sep 17 00:00:00 2001 From: panos Date: Tue, 15 Sep 2026 17:07:32 +0800 Subject: [PATCH 01/12] fix(revm): align call-mode fee-token execution with go-ethereum A fee token registered without a `balanceSlot` takes the EVM-call path: the protocol resolves the payer's balance with `balanceOf` and moves the fee with an ERC20 `transfer`. morph-reth diverged from morph-geth on that path in ways that change `gasUsed`, receipts and state roots, so a follower rejects blocks a geth block producer accepts. Measured against the golden fixtures added in the next commit, main fails 8 of 12 case templates on both Emerald and Jade: - The fee `transfer()` frame's SSTORE refund was discarded, so a fee that clears the payer's balance slot earned the user no refund (the receipt `gasUsed` mismatch reported in #207). go-ethereum runs that call through `evm.Call` inside `buyAltTokenGas()` before `StateDB.Prepare`, which does not reset the refund counter, so the refund reaches `refundGas()`. The net counter is now carried on `MorphEvm` and recorded before the EIP-3529 cap, so it can also be cancelled by a negative refund from main execution. - Internal calls ran with a default transaction environment and their own storage, so `balanceOf`/`transfer` saw ORIGIN = 0x0 and an effective gas price of 0. A guard on either read the wrong value and the payer's balance resolved to zero, rejecting an affordable transaction. Internal frames now keep the outer transaction's ORIGIN and GASPRICE, run in the executing block's environment, and `balanceOf` executes as a genuine static frame under the same 200k gas allowance geth uses. - A successful transfer whose return value or balance delta failed a later business check was rolled back with its logs. go-ethereum keeps the state and logs and only reports the failure. The frame now owns its checkpoint: a VM revert still rolls back, a business failure does not, and a database failure stays fatal rather than becoming a verdict about the token. - Zero fees skipped neither `transfer(0)` nor the initial `balanceOf`; geth skips both transfer modes but still performs the balance query. Nonce and cache updates, and call-mode access-list/transient cleanup, still happen. `TokenFeeInfo::effective_fee_limit` replaces the two hand-rolled fee-limit clamps the execution and pool paths each carried, so they cannot drift. `MorphEvm::from_env` gives execution and pool queries one constructor. The receipt builder now reads registry metadata with `load_storage_only`, which never builds a temporary EVM to resolve a balance it does not use. The pool's single call site is adapted to the new `load_for_caller` signature while keeping its previous behaviour: admission still evaluates a call-mode `balanceOf` under the hardfork's defaults. Threading the real head environment through admission is txpool work and is deliberately not part of this change. --- crates/evm/src/block/mod.rs | 16 +- crates/evm/src/evm.rs | 13 +- crates/revm/src/error.rs | 5 + crates/revm/src/evm.rs | 12 + crates/revm/src/handler.rs | 935 ++++++++++++++++++----- crates/revm/src/lib.rs | 4 +- crates/revm/src/token_fee.rs | 270 +++++-- crates/txpool/src/morph_tx_validation.rs | 12 +- 8 files changed, 1001 insertions(+), 266 deletions(-) diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index bb032056..a3b0a807 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -138,7 +138,6 @@ where &mut self, tx: &MorphTxEnvelope, sender: Address, - hardfork: MorphHardfork, ) -> Result, BlockExecutionError> { if !tx.is_morph_tx() { return Ok(None); @@ -169,12 +168,13 @@ where let token_info = match self.evm.cached_token_fee_info() { Some(info) => Some(info), - None => { - TokenFeeInfo::load_for_caller(self.evm.db_mut(), fee_token_id, sender, hardfork) - .map_err(|e| { - BlockExecutionError::msg(format!("Failed to fetch token fee info: {e:?}")) - })? - } + // Only `price_ratio` and `scale` are read below, and both come straight from + // registry storage. `load_storage_only` reads exactly that and never builds a + // temporary EVM to resolve a balance this receipt has no use for. + None => TokenFeeInfo::load_storage_only(self.evm.db_mut(), fee_token_id, sender) + .map_err(|e| { + BlockExecutionError::msg(format!("Failed to fetch token fee info: {e:?}")) + })?, }; Ok(token_info.map(|info| MorphReceiptTxFields { @@ -300,7 +300,7 @@ where // are tracing-only — the trait API no longer permits us to surface errors // from `commit_transaction`. let (tx, signer) = recovered.into_parts(); - let morph_tx_fields = match self.get_morph_tx_fields(&tx, signer, self.hardfork) { + let morph_tx_fields = match self.get_morph_tx_fields(&tx, signer) { Ok(fields) => fields, Err(err) => { tracing::error!( diff --git a/crates/evm/src/evm.rs b/crates/evm/src/evm.rs index e2454e4d..368f605d 100644 --- a/crates/evm/src/evm.rs +++ b/crates/evm/src/evm.rs @@ -13,7 +13,6 @@ use alloy_evm::{ use alloy_primitives::{Address, Bytes}; use morph_chainspec::hardfork::MorphHardfork; use morph_revm::{MorphHaltReason, MorphInvalidTransaction, MorphTxEnv, evm::MorphContext}; -use reth_revm::MainContext; use std::ops::{Deref, DerefMut}; use crate::MorphBlockEnv; @@ -66,17 +65,7 @@ pub struct MorphEvm { impl MorphEvm { /// Create a new [`MorphEvm`] instance. pub fn new(db: DB, input: EvmEnv) -> Self { - let ctx = Context::mainnet() - .with_db(db) - .with_block(input.block_env) - .with_cfg(input.cfg_env) - .with_tx(Default::default()) - .with_chain(morph_revm::l1block::L1BlockInfo::default()); - - // Build the inner MorphEvm which creates precompiles once. - // Derive the PrecompilesMap from the inner's precompiles to avoid - // a second MorphPrecompiles::new_with_spec call. - let inner = morph_revm::MorphEvm::new(ctx, NoOpInspector {}); + let inner = morph_revm::MorphEvm::from_env(db, input, NoOpInspector {}); let precompiles_map = PrecompilesMap::from_static(inner.precompiles.precompiles()); Self { diff --git a/crates/revm/src/error.rs b/crates/revm/src/error.rs index 4515a2ae..8ed377e6 100644 --- a/crates/revm/src/error.rs +++ b/crates/revm/src/error.rs @@ -27,6 +27,11 @@ pub enum MorphInvalidTransaction { #[error("Token with ID {0} has invalid fee configuration")] InvalidTokenConfig(u16), + /// The token balance call reverted, violated static execution, or returned malformed data. + #[error("Token balance query failed")] + TokenBalanceQueryFailed, + + /// The transfer failed or its return value/balance delta was invalid. #[error("Token transfer failed: {reason}")] TokenTransferFailed { /// Token transfer failure reason. diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index a0969696..eda44e68 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -100,6 +100,9 @@ pub struct MorphEvm { /// receipt-building path (the handler already has the encoded bytes via /// `MorphTxEnv.rlp_bytes`). pub(crate) cached_l1_data_fee: U256, + /// Signed refund counter from a successful fee deduction call. + /// Applied before the final refund cap; refund-transfer refunds are excluded. + pub(crate) pre_fee_refund: i64, /// Transfer event logs from token fee deduction (pre-execution phase). /// /// In go-ethereum, `buyAltTokenGas()` emits Transfer events into `StateDB.logs` @@ -113,6 +116,14 @@ pub struct MorphEvm { } impl MorphEvm { + /// Constructs an EVM from the full environment used by both execution and pool queries. + pub fn from_env(db: DB, env: crate::MorphEvmEnv, inspector: I) -> Self { + let ctx = MorphContext::new(db, *env.cfg_env.spec()) + .with_cfg(env.cfg_env) + .with_block(env.block_env); + Self::new(ctx, inspector) + } + /// Create a new Morph EVM. /// /// The precompiles are automatically selected based on the hardfork spec @@ -173,6 +184,7 @@ impl MorphEvm { inner, cached_token_fee_info: None, cached_l1_data_fee: U256::ZERO, + pre_fee_refund: 0, pre_fee_logs: Vec::new(), post_fee_logs: Vec::new(), } diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index a0d9f2eb..f2ab9449 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -17,11 +17,14 @@ use revm::{ }; use crate::{ - MorphEvm, MorphInvalidTransaction, MorphTxEnv, + MorphEvm, MorphInvalidTransaction, error::MorphHaltReason, evm::MorphContext, l1block::L1BlockInfo, - token_fee::{TokenRegistryEntry, compute_mapping_slot_for_address, encode_balance_of_calldata}, + token_fee::{ + TokenFeeInfo, TokenRegistryEntry, compute_mapping_slot_for_address, + encode_balance_of_calldata, read_balance_from_storage, + }, tx::MorphTxExt, }; @@ -101,6 +104,7 @@ where ) -> Result<(), Self::Error> { // Reset per-transaction caches from the previous iteration. evm.cached_l1_data_fee = U256::ZERO; + evm.pre_fee_refund = 0; evm.cached_token_fee_info = None; evm.pre_fee_logs.clear(); evm.post_fee_logs.clear(); @@ -178,6 +182,7 @@ where exec_result.gas_mut().set_refund(0); return Ok(()); } + exec_result.gas_mut().record_refund(evm.pre_fee_refund); post_execution::refund( evm.ctx().cfg().gas_params(), exec_result.gas_mut(), @@ -464,10 +469,16 @@ where .drain(log_count_before..) .collect(); evm.post_fee_logs = refund_logs; - result + result.map(|_| ()) }; if let Err(err) = refund_result { + // A contract may reject a refund, but unavailable state is not a verdict + // about the contract. Internal calls have already taken the context error, + // so it must reach the executor here rather than disappearing at finalization. + if matches!(err, EVMError::Database(_)) { + return Err(err); + } tracing::error!( target: "morph::evm", token_id = ?evm.ctx_ref().tx().fee_token_id, @@ -555,11 +566,7 @@ where let hardfork = *evm.ctx_ref().cfg().spec(); - let token_fee_info = token_registry_entry.load_for_caller( - evm.ctx_mut().journal_mut().db_mut(), - caller_addr, - hardfork, - )?; + let token_fee_info = load_token_fee_info(evm, token_registry_entry, caller_addr)?; let beneficiary = evm.ctx_ref().block().beneficiary(); let rlp_bytes = evm.ctx_ref().tx().rlp_bytes.clone().unwrap_or_default(); @@ -584,11 +591,7 @@ where // Calculate token amount required for total fee let token_amount_required = token_fee_info.eth_to_token_amount(total_eth_fee); - // Determine fee limit - let mut fee_limit = fee_limit_from_tx; - if fee_limit.is_zero() || fee_limit > token_fee_info.balance { - fee_limit = token_fee_info.balance - } + let fee_limit = token_fee_info.effective_fee_limit(fee_limit_from_tx); // Check if caller has sufficient token balance if fee_limit < token_amount_required { @@ -599,7 +602,10 @@ where .into()); } - if let Some(balance_slot) = token_fee_info.balance_slot { + if token_amount_required.is_zero() { + // Geth skips both transfer modes for a zero fee. Nonce and caches below + // still need their normal per-transaction updates. + } else if let Some(balance_slot) = token_fee_info.balance_slot { // Transfer with token slot. // Ensure token account is loaded into the journal state, because `sload`/`sstore` // assume the account is present. @@ -627,7 +633,7 @@ where } } else { // Transfer with evm call (from=caller, balance known from token registry). - transfer_erc20_with_evm( + evm.pre_fee_refund = transfer_erc20_with_evm( evm, caller_addr, beneficiary, @@ -635,7 +641,11 @@ where token_amount_required, Some(token_fee_info.balance), )?; + } + if token_fee_info.balance_slot.is_none() { + // balanceOf runs even for a zero fee. Geth Prepare clears its access + // list/transient storage before the main transaction in that case too. // Cache fee Transfer logs separately from the journal. // // go-ethereum's StateDB.logs is independent of the state snapshot/revert @@ -697,55 +707,6 @@ where } } -/// Execute `f` within a journal checkpoint, saving and restoring `evm.tx`. -/// -/// On `Ok` the checkpoint is committed; on `Err` it is reverted. -/// `evm.tx` is always restored to its original value regardless of the outcome, -/// so callers of [`evm_call`] inside `f` do not need to manage `evm.tx` themselves. -#[inline] -fn with_evm_checkpoint( - evm: &mut MorphEvm, - f: impl FnOnce(&mut MorphEvm) -> Result>, -) -> Result> -where - DB: alloy_evm::Database, -{ - let tx_origin = std::mem::take(&mut evm.tx); - let checkpoint = evm.ctx_mut().journal_mut().checkpoint(); - let result = f(evm); - evm.tx = tx_origin; - match result { - Ok(val) => { - evm.ctx_mut().journal_mut().checkpoint_commit(); - Ok(val) - } - Err(err) => { - evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); - Err(err) - } - } -} - -/// Execute `f` within a journal snapshot that always reverts, saving and restoring `evm.tx`. -/// -/// This gives `f` read-only (StaticCall-like) semantics: any state changes made by -/// [`evm_call`] inside `f` are discarded when `f` returns. -#[inline] -fn with_evm_snapshot( - evm: &mut MorphEvm, - f: impl FnOnce(&mut MorphEvm) -> T, -) -> T -where - DB: alloy_evm::Database, -{ - let tx_origin = std::mem::take(&mut evm.tx); - let checkpoint = evm.ctx_mut().journal_mut().checkpoint(); - let result = f(evm); - evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); - evm.tx = tx_origin; - result -} - /// Performs an ERC20 balance transfer by directly `sload`/`sstore`-ing the token contract storage /// using the known `balance` mapping base slot, returning the computed storage slots for `from`/`to`. #[inline] @@ -796,69 +757,154 @@ where /// Gas limit for internal EVM calls (ERC20 transfer, balanceOf). const EVM_CALL_GAS_LIMIT: u64 = 200_000; -/// Execute an internal EVM call, matching go-ethereum's `evm.Call()` semantics. -/// -/// Unlike `system_call_one_with_caller`, this only runs the handler's `execution()` -/// phase — NOT `execution_result()`. This means: -/// - Logs emitted during the call (e.g., ERC20 Transfer events) remain in the journal -/// - State changes remain in the journal -/// -/// **Caller is responsible for saving/restoring `evm.tx` if needed.** +/// Loads internal-call code without changing the account's access-list temperature. +/// Geth's direct Call/StaticCall resolve code without executing a CALL opcode. +fn internal_call_code( + journal: &mut revm::Journal, + address: Address, +) -> Result<(alloy_primitives::B256, revm::state::Bytecode), DB::Error> { + let account = journal.load_account_with_code(address)?; + let was_cold = account.is_cold; + let code = ( + account.info.code_hash(), + account.info.code.clone().unwrap_or_default(), + ); + if was_cold { + journal + .state + .get_mut(&address) + .expect("account was loaded") + .mark_cold(); + } + Ok(code) +} + +/// Executes a fee-token frame while retaining the outer transaction's ORIGIN/GASPRICE. +/// The frame owns its VM checkpoint; a successful call is not rolled back merely +/// because the token's return value or balance delta fails a later business check. fn evm_call( evm: &mut MorphEvm, caller: Address, target: Address, calldata: Bytes, + is_static: bool, ) -> Result> where DB: alloy_evm::Database, { - evm.tx = MorphTxEnv { - inner: revm::context::TxEnv { - caller, - kind: target.into(), - data: calldata, + use revm::context_interface::LocalContextTr; + use revm::interpreter::interpreter_action::FrameInit; + use revm::interpreter::{ + CallInput, CallInputs, CallScheme, CallValue, FrameInput, SharedMemory, + }; + + // Frame execution reports database failures through ctx.error. Check both before + // and after so a refund cannot overwrite an error from the main transaction. + take_context_error(evm)?; + let mut known_bytecode = internal_call_code(evm.ctx_mut().journal_mut(), target)?; + if let Some(delegate) = known_bytecode.1.eip7702_address() { + known_bytecode = internal_call_code(evm.ctx_mut().journal_mut(), delegate)?; + } + let mut memory = + SharedMemory::new_with_buffer(evm.ctx_ref().local().shared_memory_buffer().clone()); + memory.set_memory_limit(evm.ctx_ref().cfg().memory_limit()); + let frame = FrameInit { + depth: 0, + memory, + frame_input: FrameInput::Call(Box::new(CallInputs { + input: CallInput::Bytes(calldata), + return_memory_offset: 0..0, gas_limit: EVM_CALL_GAS_LIMIT, - ..Default::default() - }, - ..Default::default() + reservoir: 0, + bytecode_address: target, + known_bytecode, + target_address: target, + caller, + // A zero transfer also performs geth StaticCall's legacy account touch. + value: CallValue::Transfer(U256::ZERO), + scheme: if is_static { + CallScheme::StaticCall + } else { + CallScheme::Call + }, + is_static, + charged_new_account_state_gas: false, + })), }; - let mut h = MorphEvmHandler::::new(); - let init_and_floor_gas = InitialAndFloorGas::new(0, 0); - let mut gas = h.tx_gas(evm, &init_and_floor_gas); - // `execution` owns this checkpoint: it commits once the runtime gas phase is done, or - // unwinds to it when that phase runs out of gas. The `None` arm is only reachable - // under EIP-2780 (AMSTERDAM), which Morph never enables, so it is unreachable today; - // it is kept faithful to upstream so a future hardfork mapping cannot silently skip it. - let checkpoint = evm.ctx().journal_mut().checkpoint(); - match h.execution(evm, checkpoint, &mut gas)? { - Some(res) => Ok(res), - None => h.runtime_oog_result(evm, &init_and_floor_gas, &mut gas), + let result = MorphEvmHandler::::new().run_exec_loop(evm, frame)?; + take_context_error(evm)?; + Ok(result) +} + +/// Moves a database failure recorded on the context into the return path. +#[inline] +fn take_context_error( + evm: &mut MorphEvm, +) -> Result<(), EVMError> +where + DB: alloy_evm::Database, +{ + revm::context_interface::context::take_error::< + EVMError, + DB::Error, + >(&mut evm.ctx_mut().error) +} + +/// Queries the token using a genuine static frame, as geth's StaticCall does. +/// Successful reads retain access-list warming; writes and malformed results fail. +pub(crate) fn evm_call_balance_of( + evm: &mut MorphEvm, + token: Address, + account: Address, +) -> Result> +where + DB: alloy_evm::Database, +{ + let result = evm_call( + evm, + account, + token, + encode_balance_of_calldata(account), + true, + )?; + let output = &result.interpreter_result().output; + if !result.instruction_result().is_ok() || output.len() < 32 { + return Err(MorphInvalidTransaction::TokenBalanceQueryFailed.into()); } + Ok(U256::from_be_slice(&output[..32])) } -/// Query ERC20 `balanceOf(address)` via an internal EVM call. +/// Resolves the caller's fee-token balance against the **executing** EVM. /// -/// Uses [`with_evm_snapshot`] to match go-ethereum's StaticCall semantics: -/// all state changes and `evm.tx` mutations are reverted after the call. -fn evm_call_balance_of(evm: &mut MorphEvm, token: Address, account: Address) -> U256 +/// go-ethereum reads it through `st.evm` (`GetAltTokenBalanceHybrid`, core/token_gas.go:43), +/// so the `balanceOf` call sees the real block context, the real chain config and the user as +/// `msg.sender`. Building a throwaway EVM here instead would answer under +/// `BlockEnv::default()` and `CfgEnv::default()` — block 0, timestamp 1, chain id 1, zero +/// coinbase and base fee — with `SYSTEM_ADDRESS` as the sender and a 30M gas limit in place +/// of go-ethereum's 200k. For any token whose `balanceOf` reads that context the two clients +/// would charge different fees for the same transaction. +fn load_token_fee_info( + evm: &mut MorphEvm, + entry: TokenRegistryEntry, + caller: Address, +) -> Result> where DB: alloy_evm::Database, { - with_evm_snapshot(evm, |evm| { - let calldata = encode_balance_of_calldata(account); - match evm_call(evm, Address::ZERO, token, calldata) { - Ok(ref result) if result.instruction_result().is_ok() => { - let output = &result.interpreter_result().output; - if output.len() >= 32 { - U256::from_be_slice(&output[..32]) - } else { - U256::ZERO - } - } - _ => U256::ZERO, - } - }) + let balance = match entry.balance_slot() { + // Slot mode is a plain storage read with no environment to get wrong. It goes + // through the database rather than the journal deliberately: the journal is empty + // at this point in the transaction, and an `sload` here would warm a slot that the + // fee deduction below is careful to leave cold. + Some(slot) => read_balance_from_storage( + evm.ctx_mut().journal_mut().db_mut(), + entry.token_address(), + caller, + slot, + )?, + None => evm_call_balance_of(evm, entry.token_address(), caller)?, + }; + Ok(entry.into_fee_info(caller, balance)) } /// Matches go-ethereum's `transferAltTokenByEVM` validation: @@ -872,6 +918,8 @@ where /// /// `from_balance_before` is the sender's balance before the transfer. If `None`, /// the balance is queried via EVM call (matching go-eth's nil `userBalanceBefore`). +/// Returns the signed refund counter for successful transfers; the deduction phase +/// carries it into transaction gas accounting, while reimbursement ignores it. fn transfer_erc20_with_evm( evm: &mut MorphEvm, from: Address, @@ -879,70 +927,73 @@ fn transfer_erc20_with_evm( token_address: Address, token_amount: U256, from_balance_before: Option, -) -> Result<(), EVMError> +) -> Result> where DB: alloy_evm::Database, { + if token_amount.is_zero() { + return Ok(0); + } // Read sender balance before transfer if not provided. - // This uses with_evm_snapshot internally, so evm.tx is safe. let from_balance_before = match from_balance_before { Some(b) => b, - None => evm_call_balance_of(evm, token_address, from), + None => evm_call_balance_of(evm, token_address, from)?, }; - with_evm_checkpoint(evm, |evm| { - let calldata = build_transfer_calldata(to, token_amount); - let frame_result = evm_call(evm, from, token_address, calldata).map_err(|e| { - EVMError::Transaction(MorphInvalidTransaction::TokenTransferFailed { + // Geth checks affordability before executing the token contract. + let expected_balance = from_balance_before.checked_sub(token_amount).ok_or( + MorphInvalidTransaction::TokenTransferFailed { + reason: format!( + "sender balance {from_balance_before} less than token amount {token_amount}" + ), + }, + )?; + + let calldata = build_transfer_calldata(to, token_amount); + let frame_result = + evm_call(evm, from, token_address, calldata, false).map_err(|e| match e { + EVMError::Database(_) => e, + _ => EVMError::Transaction(MorphInvalidTransaction::TokenTransferFailed { reason: format!("Error: {e:?}"), - }) + }), })?; - if !frame_result.instruction_result().is_ok() { - return Err(MorphInvalidTransaction::TokenTransferFailed { - reason: format!("{:?}", frame_result.interpreter_result()), - } - .into()); + if !frame_result.instruction_result().is_ok() { + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: format!("{:?}", frame_result.interpreter_result()), } + .into()); + } - // Validate ABI bool return value, matching go-ethereum behavior: - // - No return data: accepted (old tokens that don't return bool) - // - 32+ bytes with last byte == 1: accepted (standard ERC20) - // - Otherwise: rejected - let output = &frame_result.interpreter_result().output; - if !output.is_empty() && (output.len() < 32 || output[31] != 1) { - return Err(MorphInvalidTransaction::TokenTransferFailed { - reason: "alt token transfer returned failure".to_string(), - } - .into()); + // Validate ABI bool return value, matching go-ethereum behavior: + // - No return data: accepted (old tokens that don't return bool) + // - 32+ bytes with last byte == 1: accepted (standard ERC20) + // - Otherwise: rejected + let output = &frame_result.interpreter_result().output; + if !output.is_empty() && (output.len() < 32 || output[31] != 1) { + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: "alt token transfer returned failure".to_string(), } + .into()); + } - // Verify sender balance changed by the expected amount, matching go-ethereum. - // evm_call_balance_of uses with_evm_snapshot, so evm.tx is safe here too. - let from_balance_after = evm_call_balance_of(evm, token_address, from); - - // Verify sender balance decreased by exactly the transfer amount. - // Matches go-ethereum's transferAltTokenByEVM which always checks this, - // even for self-transfers (from == to), where it would fail because the - // net balance change is zero but the expected decrease is `token_amount`. - let expected_balance = from_balance_before.checked_sub(token_amount).ok_or( - MorphInvalidTransaction::TokenTransferFailed { - reason: format!( - "sender balance {from_balance_before} less than token amount {token_amount}" - ), - }, - )?; - if from_balance_after != expected_balance { - return Err(MorphInvalidTransaction::TokenTransferFailed { - reason: format!( - "sender balance mismatch: expected {expected_balance}, got {from_balance_after}" - ), - } - .into()); + // Verify sender balance changed by the expected amount, matching go-ethereum. + let from_balance_after = evm_call_balance_of(evm, token_address, from)?; + + // Verify sender balance decreased by exactly the transfer amount. + // Matches go-ethereum's transferAltTokenByEVM which always checks this, + // even for self-transfers (from == to), where it would fail because the + // net balance change is zero but the expected decrease is `token_amount`. + if from_balance_after != expected_balance { + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: format!( + "sender balance mismatch: expected {expected_balance}, got {from_balance_after}" + ), } + .into()); + } - Ok(()) - }) + Ok(frame_result.gas().refunded()) } /// Build the calldata for ERC20 `transfer(address,uint256)` call. @@ -1032,6 +1083,7 @@ fn calculate_caller_fee_with_l1_cost( #[cfg(test)] mod tests { use super::*; + use crate::MorphTxEnv; use crate::{ MorphBlockEnv, token_fee::{L2_TOKEN_REGISTRY_ADDRESS, compute_mapping_slot}, @@ -1051,6 +1103,231 @@ mod tests { atomic::{AtomicBool, Ordering}, }; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + struct TokenReadFailure; + impl core::fmt::Display for TokenReadFailure { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("injected refund balance read failure") + } + } + impl core::error::Error for TokenReadFailure {} + impl revm::database_interface::DBErrorMarker for TokenReadFailure {} + + #[derive(Debug)] + struct UnreadableTokenDb { + inner: CacheDB, + token: Address, + } + impl revm::Database for UnreadableTokenDb { + type Error = TokenReadFailure; + fn basic(&mut self, address: Address) -> Result, Self::Error> { + Ok(revm::Database::basic(&mut self.inner, address).unwrap()) + } + fn code_by_hash(&mut self, hash: B256) -> Result { + Ok(revm::Database::code_by_hash(&mut self.inner, hash).unwrap()) + } + fn storage(&mut self, address: Address, index: U256) -> Result { + if address == self.token { + return Err(TokenReadFailure); + } + Ok(revm::Database::storage(&mut self.inner, address, index).unwrap()) + } + fn block_hash(&mut self, number: u64) -> Result { + Ok(revm::Database::block_hash(&mut self.inner, number).unwrap()) + } + } + + fn finish_transaction_with_refund( + code: Bytes, + ) -> Result, EVMError> + { + let caller = address!("1000000000000000000000000000000000000001"); + let beneficiary = address!("2000000000000000000000000000000000000002"); + let token = address!("3000000000000000000000000000000000000003"); + let target = address!("4000000000000000000000000000000000000004"); + let mut inner = CacheDB::new(EmptyDB::default()); + inner.insert_account_info( + token, + AccountInfo { + code_hash: keccak256(code.as_ref()), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + let mut evm = MorphEvm::new( + MorphContext::new(UnreadableTokenDb { inner, token }, MorphHardfork::Emerald), + NoOpInspector, + ); + evm.block.inner.beneficiary = beneficiary; + // Produce a real successful main frame. The remainder of this probe enters the + // normal reimbursement and result-finalization phases with an unused gas budget. + let frame = evm_call(&mut evm, caller, target, Bytes::new(), false).unwrap(); + evm.tx = MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller, + gas_price: 1, + gas_limit: 30_000, + kind: TxKind::Call(target), + ..Default::default() + }, + fee_token_id: Some(1), + ..Default::default() + }; + evm.cached_token_fee_info = Some(TokenFeeInfo { + token_address: token, + is_active: true, + price_ratio: U256::from(1), + scale: U256::from(1), + caller, + balance: U256::from(100_000), + balance_slot: None, + ..Default::default() + }); + let mut handler = MorphEvmHandler::<_, NoOpInspector>::default(); + handler + .reimburse_caller_token_fee(&mut evm, &Gas::new(1_000)) + .and_then(|_| handler.execution_result(&mut evm, frame, ResultGas::default())) + } + + /// A readable token under [`UnreadableTokenDb`], so any failure reported below comes + /// from the context, not from a read. + fn readable_token_evm() -> MorphEvm { + let token = address!("3000000000000000000000000000000000000003"); + let mut inner = CacheDB::new(EmptyDB::default()); + insert_contract( + &mut inner, + token, + alloy_primitives::bytes!("6000545f5260205ff3"), + ); + inner + .insert_account_storage(token, U256::ZERO, U256::from(42)) + .unwrap(); + MorphEvm::new( + MorphContext::new( + UnreadableTokenDb { + inner, + // Nothing reads this address, so every storage read succeeds. + token: address!("9000000000000000000000000000000000000009"), + }, + MorphHardfork::Emerald, + ), + NoOpInspector, + ) + } + + #[test] + fn a_nested_call_does_not_run_in_a_context_the_main_frame_already_poisoned() { + let token = address!("3000000000000000000000000000000000000003"); + let account = address!("1000000000000000000000000000000000000001"); + let mut evm = readable_token_evm(); + assert_eq!( + evm_call_balance_of(&mut evm, token, account).unwrap(), + U256::from(42), + "sanity: the token is readable" + ); + + // The main frame halted on a failed read; post-execution then reaches this call. + evm.ctx_mut().error = Err(revm::context_interface::context::ContextError::Db( + TokenReadFailure, + )); + let result = evm_call_balance_of(&mut evm, token, account); + assert!( + matches!(result, Err(EVMError::Database(TokenReadFailure))), + "the main frame's failure must be reported, not overwritten by a nested call: {result:?}" + ); + assert!( + evm.ctx_ref().error.is_ok(), + "the failure has been moved into the return path" + ); + } + + #[test] + fn a_refund_after_a_poisoned_main_frame_reports_the_main_frame_failure() { + let caller = address!("1000000000000000000000000000000000000001"); + let token = address!("3000000000000000000000000000000000000003"); + let mut evm = readable_token_evm(); + evm.block.inner.beneficiary = address!("2000000000000000000000000000000000000002"); + evm.tx = MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller, + gas_price: 1, + gas_limit: 30_000, + ..Default::default() + }, + fee_token_id: Some(1), + ..Default::default() + }; + evm.cached_token_fee_info = Some(TokenFeeInfo { + token_address: token, + is_active: true, + price_ratio: U256::from(1), + scale: U256::from(1), + caller, + balance: U256::from(100_000), + balance_slot: None, + ..Default::default() + }); + evm.ctx_mut().error = Err(revm::context_interface::context::ContextError::Db( + TokenReadFailure, + )); + + let result = MorphEvmHandler::<_, NoOpInspector>::default() + .reimburse_caller_token_fee(&mut evm, &Gas::new(1_000)); + assert!( + matches!(result, Err(EVMError::Database(TokenReadFailure))), + "{result:?}" + ); + } + + #[test] + fn refund_database_failure_aborts_final_execution_result() { + let result = finish_transaction_with_refund(alloy_primitives::bytes!("6000545f5260205ff3")); + assert!( + matches!(result, Err(EVMError::Database(TokenReadFailure))), + "refund I/O must abort execution, not finalize success without a refund: {result:?}" + ); + } + + #[test] + fn refund_contract_revert_still_allows_transaction_to_finish() { + let result = finish_transaction_with_refund(alloy_primitives::bytes!("5f5ffd")); + assert!( + matches!(result, Ok(ExecutionResult::Success { .. })), + "{result:?}" + ); + } + + #[test] + fn token_transfer_database_failure_is_not_transaction_invalidity() { + let token = address!("3000000000000000000000000000000000000003"); + let from = address!("1000000000000000000000000000000000000001"); + let to = address!("2000000000000000000000000000000000000002"); + let mut inner = CacheDB::new(EmptyDB::default()); + insert_contract( + &mut inner, + token, + alloy_primitives::bytes!("6000545f5260205ff3"), + ); + let mut evm = MorphEvm::new( + MorphContext::new(UnreadableTokenDb { inner, token }, MorphHardfork::Emerald), + NoOpInspector, + ); + let result = transfer_erc20_with_evm( + &mut evm, + from, + to, + token, + U256::from(1), + Some(U256::from(10)), + ); + assert!( + matches!(result, Err(EVMError::Database(TokenReadFailure))), + "{result:?}" + ); + } + fn mutating_return_code(write_value: u8, return_value: u8) -> Bytes { Bytes::from(vec![ 0x60, @@ -1104,6 +1381,77 @@ mod tests { } } + /// ` PUSH0 MSTORE PUSH1 0x20 PUSH0 RETURN` — a `balanceOf` that reports one piece + /// of its environment instead of a balance, so a call made under the wrong environment + /// shows up in the value that comes back. + fn code_returning(opcode: u8) -> Bytes { + Bytes::from(vec![opcode, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3]) + } + + fn insert_contract(db: &mut CacheDB, address: Address, code: Bytes) { + db.insert_account_info( + address, + AccountInfo { + code_hash: keccak256(code.as_ref()), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + } + + /// Loads token 1's registry entry and resolves `caller`'s balance against `evm`. + fn probe_fee_token_balance(db: CacheDB, block: BlockEnv, caller: Address) -> U256 { + let mut evm = MorphEvm::new(MorphContext::new(db, MorphHardfork::Emerald), NoOpInspector); + evm.block = MorphBlockEnv { inner: block }; + + let entry = TokenRegistryEntry::load(evm.ctx_mut().journal_mut().db_mut(), 1) + .unwrap() + .unwrap(); + load_token_fee_info(&mut evm, entry, caller) + .unwrap() + .balance + } + + #[test] + fn fee_token_balance_is_read_under_the_executing_block_environment() { + const TIMESTAMP: u64 = 1_767_765_600; + let token = address!("5300000000000000000000000000000000000042"); + let caller = address!("1000000000000000000000000000000000000001"); + + let mut db = CacheDB::new(EmptyDB::default()); + insert_test_fee_token(&mut db, 1, token, true); + insert_contract(&mut db, token, code_returning(0x42)); // TIMESTAMP + + let balance = probe_fee_token_balance( + db, + BlockEnv { + timestamp: U256::from(TIMESTAMP), + ..Default::default() + }, + caller, + ); + + // `BlockEnv::default()` reports timestamp 1, which is what a throwaway EVM would + // have answered with regardless of the block being executed. + assert_eq!(balance, U256::from(TIMESTAMP)); + } + + #[test] + fn fee_token_balance_query_names_the_queried_account_as_the_caller() { + let token = address!("5300000000000000000000000000000000000042"); + let caller = address!("1000000000000000000000000000000000000001"); + + let mut db = CacheDB::new(EmptyDB::default()); + insert_test_fee_token(&mut db, 1, token, true); + insert_contract(&mut db, token, code_returning(0x33)); // CALLER + + let balance = probe_fee_token_balance(db, BlockEnv::default(), caller); + + // go-ethereum queries as the account being asked about, not as the zero address and + // not as `SYSTEM_ADDRESS`. + assert_eq!(balance, U256::from_be_bytes(caller.into_word().0)); + } + fn insert_test_fee_token( db: &mut CacheDB, token_id: u16, @@ -1251,7 +1599,7 @@ mod tests { } #[test] - fn transfer_erc20_with_evm_reverts_state_on_validation_failure() { + fn transfer_erc20_with_evm_keeps_state_on_post_call_validation_failure() { let from = address!("1000000000000000000000000000000000000001"); let to = address!("2000000000000000000000000000000000000002"); let token = address!("3000000000000000000000000000000000000003"); @@ -1306,7 +1654,7 @@ mod tests { .get(&token) .and_then(|account| account.storage.get(&U256::ZERO)) .unwrap(); - assert_eq!(slot_state.present_value, original_balance); + assert_eq!(slot_state.present_value, U256::from(1)); } #[test] @@ -1352,14 +1700,7 @@ mod tests { err, EVMError::Transaction(MorphInvalidTransaction::TokenTransferFailed { .. }) )); - let slot_state = evm - .ctx_ref() - .journal() - .state - .get(&token) - .and_then(|account| account.storage.get(&U256::ZERO)) - .unwrap(); - assert_eq!(slot_state.present_value, original_balance); + assert!(evm.ctx_ref().journal().state.is_empty()); } #[test] @@ -1432,17 +1773,12 @@ mod tests { inner: BlockEnv::default(), }; - let balance = evm_call_balance_of(&mut evm, token, account); - - assert_eq!(balance, U256::from(42)); - let slot_state = evm - .ctx_ref() - .journal() - .state - .get(&token) - .and_then(|acct| acct.storage.get(&U256::ZERO)) - .unwrap(); - assert_eq!(slot_state.present_value, original_balance); + assert!(evm_call_balance_of(&mut evm, token, account).is_err()); + assert_eq!( + revm::Database::storage(evm.ctx_mut().journal_mut().db_mut(), token, U256::ZERO) + .unwrap(), + original_balance + ); } /// `disable_fee_charge` must leave the caller balance untouched. @@ -1588,4 +1924,249 @@ mod tests { "simulation must not query the fee-token contract or its balance storage" ); } + const FEE_REFUND_TOKEN_ID: u16 = 1; + const FEE_REFUND_GAS_LIMIT: u64 = 100_000; + const FEE_REFUND_GAS_PRICE: u128 = 10; + /// `gas_limit * effective_gas_price` (the L1 data fee is zero with an empty + /// gas-price oracle), converted at scale 1 / price_ratio 1. + const FEE_REFUND_TOKEN_FEE: u64 = 1_000_000; + const FEE_REFUND_CALLER: Address = address!("1000000000000000000000000000000000000001"); + const FEE_REFUND_TOKEN: Address = address!("3000000000000000000000000000000000000003"); + const FEE_REFUND_BENEFICIARY: Address = address!("530000000000000000000000000000000000000a"); + /// Plain EOA target: the main frame does nothing beyond intrinsic gas. + const FEE_REFUND_TARGET: Address = address!("4200000000000000000000000000000000000042"); + + /// Minimal call-mode ERC20: `balance[addr]` lives at slot `uint256(addr)` (no + /// keccak), so `CALLER`, `calldataload(4)` and `balanceOf`'s argument all name the + /// same slot. Dispatch is on `CALLDATASIZE`: + /// - 68 bytes => `transfer(address,uint256)`: SSTORE(caller, SLOAD(caller) - amount), + /// SSTORE(to, SLOAD(to) + amount), return `true`. + /// - anything else => `balanceOf(address)`: return SLOAD(calldataload(4)). + fn fee_refund_slotless_erc20_code() -> Bytes { + Bytes::from(vec![ + 0x36, // CALLDATASIZE + 0x60, 0x44, // PUSH1 68 + 0x14, // EQ + 0x60, 0x13, // PUSH1 19 (transfer JUMPDEST) + 0x57, // JUMPI + // balanceOf(address) + 0x60, 0x04, // PUSH1 4 + 0x35, // CALLDATALOAD + 0x54, // SLOAD + 0x60, 0x00, // PUSH1 0 + 0x52, // MSTORE + 0x60, 0x20, // PUSH1 32 + 0x60, 0x00, // PUSH1 0 + 0xf3, // RETURN + // transfer(address,uint256) + 0x5b, // JUMPDEST (pc 19) + 0x60, 0x24, // PUSH1 36 + 0x35, // CALLDATALOAD -> amount + 0x80, // DUP1 -> amount amount + 0x33, // CALLER -> caller amount amount + 0x54, // SLOAD -> bal_from amount amount + 0x03, // SUB -> bal_from-amount amount + 0x33, // CALLER -> caller new_from amount + 0x55, // SSTORE -> amount + 0x60, 0x04, // PUSH1 4 + 0x35, // CALLDATALOAD -> to amount + 0x80, // DUP1 -> to to amount + 0x54, // SLOAD -> bal_to to amount + 0x82, // DUP3 -> amount bal_to to amount + 0x01, // ADD -> new_to to amount + 0x90, // SWAP1 -> to new_to amount + 0x55, // SSTORE -> amount + 0x50, // POP + 0x60, 0x01, // PUSH1 1 + 0x60, 0x00, // PUSH1 0 + 0x52, // MSTORE + 0x60, 0x20, // PUSH1 32 + 0x60, 0x00, // PUSH1 0 + 0xf3, // RETURN + ]) + } + + fn fee_refund_balance_slot(account: Address) -> U256 { + U256::from_be_bytes(account.into_word().0) + } + + fn fee_refund_evm(payer_token_balance: U256) -> MorphEvm, NoOpInspector> { + let code = fee_refund_slotless_erc20_code(); + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info(FEE_REFUND_CALLER, AccountInfo::default()); + db.insert_account_info( + FEE_REFUND_TOKEN, + AccountInfo { + code_hash: keccak256(code.as_ref()), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + db.insert_account_storage( + FEE_REFUND_TOKEN, + fee_refund_balance_slot(FEE_REFUND_CALLER), + payer_token_balance, + ) + .unwrap(); + // `balanceSlot` word left at zero => call mode (`balance_slot == None`). + insert_test_fee_token(&mut db, FEE_REFUND_TOKEN_ID, FEE_REFUND_TOKEN, true); + + let mut evm = MorphEvm::new( + MorphContext::new(db, MorphHardfork::default()), + NoOpInspector, + ); + evm.block = MorphBlockEnv { + inner: BlockEnv { + basefee: 1, + beneficiary: FEE_REFUND_BENEFICIARY, + gas_limit: 30_000_000, + ..Default::default() + }, + }; + // Production Morph configuration disables the Ethereum calldata gas floor. + evm.cfg.disable_eip7623 = true; + evm + } + + fn fee_refund_present_value( + evm: &MorphEvm, NoOpInspector>, + account: Address, + ) -> U256 { + evm.ctx_ref() + .journal() + .state + .get(&FEE_REFUND_TOKEN) + .and_then(|acct| acct.storage.get(&fee_refund_balance_slot(account))) + .map(|slot| slot.present_value) + .expect("fee-token slot must be in the journal") + } + + /// Runs one call-mode token-fee MorphTx (plain call to an EOA) and returns + /// `(gas_used, final refund applied to the main frame, payer token balance after + /// reimbursement)`. + fn fee_refund_run_token_fee_tx(payer_token_balance: U256) -> (u64, u64, U256) { + let mut evm = fee_refund_evm(payer_token_balance); + let tx = MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller: FEE_REFUND_CALLER, + gas_limit: FEE_REFUND_GAS_LIMIT, + gas_price: FEE_REFUND_GAS_PRICE, + kind: TxKind::Call(FEE_REFUND_TARGET), + ..Default::default() + }, + fee_token_id: Some(FEE_REFUND_TOKEN_ID), + ..Default::default() + }; + + let result = evm + .transact_one(tx) + .expect("token-fee MorphTx must execute"); + assert!(result.is_success(), "expected success, got {result:?}"); + let gas_used = result.tx_gas_used(); + let gas_refunded = result.gas().final_refunded(); + + // Sanity: the fee was charged in call mode and equals exactly FEE_REFUND_TOKEN_FEE. + let info = evm + .cached_token_fee_info() + .expect("token fee info is cached"); + assert_eq!( + info.balance_slot, None, + "token must be registered in call mode" + ); + assert_eq!( + info.balance, payer_token_balance, + "balanceOf must see the seeded balance" + ); + assert_eq!( + info.eth_to_token_amount(U256::from( + FEE_REFUND_GAS_LIMIT as u128 * FEE_REFUND_GAS_PRICE + )), + U256::from(FEE_REFUND_TOKEN_FEE) + ); + + ( + gas_used, + gas_refunded, + fee_refund_present_value(&evm, FEE_REFUND_CALLER), + ) + } + + #[test] + fn deduction_sstore_refund_reaches_transaction_gas() { + let fee = U256::from(FEE_REFUND_TOKEN_FEE); + let (gas, refund, balance) = fee_refund_run_token_fee_tx(fee); + assert_eq!((gas, refund), (16_800, 4_200)); + assert_eq!(balance, fee - U256::from(168_000)); + let (gas, refund, balance) = fee_refund_run_token_fee_tx(fee + U256::from(1)); + assert_eq!((gas, refund), (21_000, 0)); + assert_eq!(balance, fee + U256::from(1) - U256::from(210_000)); + } + + #[test] + fn balance_queries_reject_state_writes() { + let mut evm = fee_refund_evm(U256::from(FEE_REFUND_TOKEN_FEE)); + let code = mutating_return_code(1, 1); + evm.ctx_mut().journal_mut().db_mut().insert_account_info( + FEE_REFUND_TOKEN, + AccountInfo { + code_hash: keccak256(&code), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + assert!(evm_call_balance_of(&mut evm, FEE_REFUND_TOKEN, FEE_REFUND_CALLER).is_err()); + } + + #[test] + fn internal_calls_preserve_origin_and_effective_gas_price() { + for (opcode, expected) in [ + (0x32, U256::from_be_slice(FEE_REFUND_CALLER.as_slice())), + (0x3a, U256::from(3)), + ] { + let mut evm = fee_refund_evm(U256::from(FEE_REFUND_TOKEN_FEE)); + evm.tx.inner.caller = FEE_REFUND_CALLER; + evm.tx.inner.tx_type = MORPH_TX_TYPE_ID; + evm.tx.inner.gas_price = 10; + evm.tx.inner.gas_priority_fee = Some(2); + let code = Bytes::from(vec![opcode, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3]); + evm.ctx_mut().journal_mut().db_mut().insert_account_info( + FEE_REFUND_TOKEN, + AccountInfo { + code_hash: keccak256(&code), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + assert_eq!( + evm_call_balance_of(&mut evm, FEE_REFUND_TOKEN, FEE_REFUND_BENEFICIARY).unwrap(), + expected + ); + assert_eq!(evm.tx.inner.caller, FEE_REFUND_CALLER); + } + } + + #[test] + fn zero_token_transfer_does_not_call_the_contract() { + let mut evm = fee_refund_evm(U256::ZERO); + let code = mutating_return_code(1, 0); + evm.ctx_mut().journal_mut().db_mut().insert_account_info( + FEE_REFUND_TOKEN, + AccountInfo { + code_hash: keccak256(&code), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + transfer_erc20_with_evm( + &mut evm, + FEE_REFUND_CALLER, + FEE_REFUND_BENEFICIARY, + FEE_REFUND_TOKEN, + U256::ZERO, + Some(U256::ZERO), + ) + .unwrap(); + assert!(evm.ctx_ref().journal().state.is_empty()); + } } diff --git a/crates/revm/src/lib.rs b/crates/revm/src/lib.rs index 00aea498..5e420dda 100644 --- a/crates/revm/src/lib.rs +++ b/crates/revm/src/lib.rs @@ -73,7 +73,7 @@ pub use l1block::{ }; pub use precompiles::MorphPrecompiles; pub use token_fee::{ - L2_TOKEN_REGISTRY_ADDRESS, TokenFeeInfo, compute_mapping_slot, - compute_mapping_slot_for_address, encode_balance_of_calldata, query_erc20_balance, + L2_TOKEN_REGISTRY_ADDRESS, MorphEvmEnv, TokenFeeInfo, TokenRegistryEntry, compute_mapping_slot, + compute_mapping_slot_for_address, encode_balance_of_calldata, }; pub use tx::{MorphTxEnv, MorphTxExt}; diff --git a/crates/revm/src/token_fee.rs b/crates/revm/src/token_fee.rs index c32827cb..dbbafd25 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -9,12 +9,18 @@ use alloy_evm::Database; use alloy_primitives::{Address, Bytes, U256, address, keccak256}; use morph_chainspec::hardfork::MorphHardfork; use revm::Database as RevmDatabase; -use revm::SystemCallEvm; use revm::{context_interface::result::EVMError, inspector::NoOpInspector}; -use crate::evm::MorphContext; use crate::{MorphEvm, MorphInvalidTransaction}; +/// The environment a fee-token `balanceOf` call is evaluated in. +/// +/// Produced by `ConfigureEvm::evm_env` for the block whose state is being read, so the pool +/// resolves the same balance the execution layer would. go-ethereum builds the equivalent +/// `vm.BlockContext` from the header before querying a call-mode token +/// (`pool.getBalanceFunc`, core/tx_pool.go:330). +pub type MorphEvmEnv = alloy_evm::EvmEnv; + /// L2 Token Registry contract address on Morph L2. /// Reference: pub const L2_TOKEN_REGISTRY_ADDRESS: Address = address!("5300000000000000000000000000000000000021"); @@ -50,7 +56,7 @@ pub struct TokenFeeInfo { /// Fee-token registry metadata without any caller-specific balance state. #[derive(Clone, Copy, Debug)] -pub(crate) struct TokenRegistryEntry { +pub struct TokenRegistryEntry { token_address: Address, is_active: bool, decimals: u8, @@ -60,11 +66,20 @@ pub(crate) struct TokenRegistryEntry { } impl TokenRegistryEntry { + /// The registered ERC20 contract. + pub(crate) const fn token_address(&self) -> Address { + self.token_address + } + + /// The caller's balance storage slot, when the registry declares one. + /// + /// `None` means call mode: the balance has to be read by calling `balanceOf`. + pub(crate) const fn balance_slot(&self) -> Option { + self.balance_slot + } + /// Load fee-token metadata without reading a caller's token balance. - pub(crate) fn load( - db: &mut DB, - token_id: u16, - ) -> Result, DB::Error> { + pub fn load(db: &mut DB, token_id: u16) -> Result, DB::Error> { read_registry_entry(db, token_id) } @@ -80,18 +95,18 @@ impl TokenRegistryEntry { } /// Resolve the caller's balance to produce complete fee information. - pub(crate) fn load_for_caller( + pub fn load_for_caller( self, db: &mut DB, caller: Address, - hardfork: MorphHardfork, - ) -> Result { + env: &MorphEvmEnv, + ) -> Result> { let balance = read_token_balance_with_fallback( db, self.token_address, caller, self.balance_slot, - hardfork, + env, )?; Ok(self.into_fee_info(caller, balance)) } @@ -109,7 +124,7 @@ impl TokenRegistryEntry { Ok(self.into_fee_info(caller, balance)) } - fn into_fee_info(self, caller: Address, balance: U256) -> TokenFeeInfo { + pub(crate) fn into_fee_info(self, caller: Address, balance: U256) -> TokenFeeInfo { TokenFeeInfo { token_address: self.token_address, is_active: self.is_active, @@ -124,6 +139,16 @@ impl TokenRegistryEntry { } impl TokenFeeInfo { + /// Maximum permitted token debit, bounded by the available balance. + /// A zero fee limit means that the whole balance is available. + pub fn effective_fee_limit(&self, fee_limit: U256) -> U256 { + if fee_limit.is_zero() { + self.balance + } else { + self.balance.min(fee_limit) + } + } + /// Load token fee information with EVM call fallback. /// /// Reads token parameters from L2 Token Registry storage. If the token's @@ -133,14 +158,14 @@ impl TokenFeeInfo { db: &mut DB, token_id: u16, caller: Address, - hardfork: MorphHardfork, - ) -> Result, DB::Error> { + env: &MorphEvmEnv, + ) -> Result, EVMError> { let entry = match TokenRegistryEntry::load(db, token_id)? { Some(e) => e, None => return Ok(None), }; - entry.load_for_caller(db, caller, hardfork).map(Some) + entry.load_for_caller(db, caller, env).map(Some) } /// Storage-only variant of [`Self::load_for_caller`]. @@ -292,26 +317,25 @@ fn read_token_balance_with_fallback( token: Address, account: Address, balance_slot: Option, - hardfork: MorphHardfork, -) -> Result { + env: &MorphEvmEnv, +) -> Result> { if let Some(slot) = balance_slot { - return read_balance_from_storage(db, token, account, slot); + return Ok(read_balance_from_storage(db, token, account, slot)?); } - // EVM fallback: construct temporary MorphEvm for balanceOf call + // Call mode: stand the EVM up in the caller's environment rather than a default one, + // and make the same `balanceOf` call the execution layer makes, so both reach the same + // answer for a token whose balance depends on block context or `msg.sender`. let db: &mut dyn Database = db; - let mut evm = MorphEvm::new(MorphContext::new(db, hardfork), NoOpInspector {}); - - match query_balance_via_system_call(&mut evm, token, account) { - Ok(balance) => Ok(balance), - Err(EVMError::Database(e)) => Err(e), - Err(_) => Ok(U256::ZERO), // Non-DB errors → zero (safe fallback) - } + let mut evm = MorphEvm::from_env(db, env.clone(), NoOpInspector {}); + // Geth's pool query has Origin=sender and GasPrice=0, unlike an executing transaction. + evm.tx.inner.caller = account; + crate::handler::evm_call_balance_of(&mut evm, token, account) } /// Read ERC20 balance directly from storage slot. #[inline] -fn read_balance_from_storage( +pub(crate) fn read_balance_from_storage( db: &mut DB, token: Address, account: Address, @@ -322,44 +346,6 @@ fn read_balance_from_storage( read_mapping_value(db, token, balance_slot, &key) } -/// Execute EVM `balanceOf(address)` call. -fn query_balance_via_system_call( - evm: &mut MorphEvm, - token: Address, - account: Address, -) -> Result> -where - DB: Database, -{ - let calldata = encode_balance_of_calldata(account); - match evm.system_call_one(token, calldata) { - Ok(result) if result.is_success() => { - if let Some(output) = result.output() - && output.len() >= 32 - { - return Ok(U256::from_be_slice(&output[..32])); - } - Ok(U256::ZERO) - } - Ok(_) => Ok(U256::ZERO), - Err(_) => Ok(U256::ZERO), - } -} - -/// Query ERC20 balance via EVM call. -/// -/// Use this when you have a `MorphEvm` instance and need to call `balanceOf`. -pub fn query_erc20_balance( - evm: &mut MorphEvm, - token: Address, - account: Address, -) -> Result> -where - DB: Database, -{ - query_balance_via_system_call(evm, token, account) -} - /// Encode ERC20 `balanceOf(address)` calldata. /// /// Function selector: `0x70a08231` @@ -376,6 +362,160 @@ pub fn encode_balance_of_calldata(account: Address) -> Bytes { mod tests { use super::*; + use alloy_primitives::{B256, address, bytes}; + use revm::bytecode::Bytecode; + use revm::database::{CacheDB, EmptyDB}; + use revm::state::AccountInfo; + + /// Returned by [`FeeTokenUnreadable`] so a state read failure is distinguishable. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + struct ReadFailed; + + impl core::fmt::Display for ReadFailed { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("state read failed") + } + } + + impl core::error::Error for ReadFailed {} + + impl revm::database_interface::DBErrorMarker for ReadFailed {} + + /// Fails every storage read of the fee token; everything else reads normally. + #[derive(Debug)] + struct FeeTokenUnreadable { + inner: CacheDB, + token: Address, + } + + impl RevmDatabase for FeeTokenUnreadable { + type Error = ReadFailed; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + Ok(self.inner.basic(address).unwrap()) + } + + fn code_by_hash(&mut self, code_hash: B256) -> Result { + Ok(self.inner.code_by_hash(code_hash).unwrap()) + } + + fn storage(&mut self, address: Address, index: U256) -> Result { + if address == self.token { + return Err(ReadFailed); + } + Ok(self.inner.storage(address, index).unwrap()) + } + + fn block_hash(&mut self, number: u64) -> Result { + Ok(self.inner.block_hash(number).unwrap()) + } + } + + /// Registry state for a call-mode token (no `balanceSlot`) whose `balanceOf` returns + /// storage slot 0, so reading it is a storage read of the token contract. + fn call_mode_token_state(token: Address, balance: u64) -> CacheDB { + call_mode_token_state_with_code(token, balance, bytes!("6000545f5260205ff3")) + } + + /// As [`call_mode_token_state`], with an explicit `balanceOf` implementation. + fn call_mode_token_state_with_code( + token: Address, + balance: u64, + code: Bytes, + ) -> CacheDB { + let mut db = CacheDB::new(EmptyDB::default()); + let mut token_id_bytes = [0u8; 32]; + token_id_bytes[31] = 1; + let base = compute_mapping_slot(TOKEN_REGISTRY_SLOT, &token_id_bytes); + + let mut packed = [0u8; 32]; + packed[30] = 18; // decimals + packed[31] = 1; // isActive + for (slot, value) in [ + (base, U256::from_be_bytes(token.into_word().0)), + // Zero means "no known balance slot": the EVM `balanceOf` fallback is used. + (base + U256::from(1), U256::ZERO), + (base + U256::from(2), U256::from_be_bytes(packed)), + (base + U256::from(3), U256::from(1)), // scale + ( + compute_mapping_slot(PRICE_RATIO_SLOT, &token_id_bytes), + U256::from(1), // priceRatio + ), + ] { + db.insert_account_storage(L2_TOKEN_REGISTRY_ADDRESS, slot, value) + .unwrap(); + } + + db.insert_account_info( + token, + AccountInfo { + code_hash: alloy_primitives::keccak256(code.as_ref()), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + db.insert_account_storage(token, U256::ZERO, U256::from(balance)) + .unwrap(); + db + } + + #[test] + fn call_mode_balance_is_read_under_the_supplied_block_environment() { + const TIMESTAMP: u64 = 1_767_765_600; + let token = address!("5300000000000000000000000000000000000042"); + let caller = address!("0000000000000000000000000000000000000001"); + + // TIMESTAMP PUSH0 MSTORE PUSH1 0x20 PUSH0 RETURN — a `balanceOf` that reports the + // block time, so an answer produced under the wrong environment is visible. + let mut db = call_mode_token_state_with_code(token, 0, bytes!("425f5260205ff3")); + + let env = MorphEvmEnv::new( + revm::context::CfgEnv::new_with_spec(MorphHardfork::Emerald), + crate::MorphBlockEnv { + inner: revm::context::BlockEnv { + timestamp: U256::from(TIMESTAMP), + ..Default::default() + }, + }, + ); + + let info = TokenFeeInfo::load_for_caller(&mut db, 1, caller, &env) + .unwrap() + .unwrap(); + + // `BlockEnv::default()` reports timestamp 1, which is what the pool answered with + // regardless of the block it was validating against. + assert_eq!(info.balance, U256::from(TIMESTAMP)); + } + + #[test] + fn balance_of_fallback_reports_a_failed_state_read_instead_of_a_zero_balance() { + let token = address!("5300000000000000000000000000000000000042"); + let caller = address!("0000000000000000000000000000000000000001"); + let env = MorphEvmEnv::new( + revm::context::CfgEnv::new_with_spec(MorphHardfork::Emerald), + crate::MorphBlockEnv::default(), + ); + + // Readable state: the fallback reaches the token and reads the balance. + let mut readable = call_mode_token_state(token, 10_000_000); + let info = TokenFeeInfo::load_for_caller(&mut readable, 1, caller, &env) + .unwrap() + .unwrap(); + assert_eq!(info.balance, U256::from(10_000_000)); + + // Same state, but the token's storage cannot be read. Reporting a zero balance here + // would be indistinguishable from an account that genuinely cannot pay. + let mut unreadable = FeeTokenUnreadable { + inner: call_mode_token_state(token, 10_000_000), + token, + }; + assert_eq!( + TokenFeeInfo::load_for_caller(&mut unreadable, 1, caller, &env).unwrap_err(), + EVMError::Database(ReadFailed) + ); + } + #[test] fn test_token_fee_info_default() { let info = TokenFeeInfo::default(); diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index e666c295..2c378cc8 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -8,7 +8,7 @@ use alloy_evm::Database; use alloy_primitives::{Address, U256}; use morph_chainspec::hardfork::MorphHardfork; use morph_primitives::{MorphTxEnvelope, transaction::morph_transaction::MORPH_TX_VERSION_1}; -use morph_revm::TokenFeeInfo; +use morph_revm::{MorphEvmEnv, TokenFeeInfo}; use crate::MorphTxError; @@ -107,7 +107,15 @@ pub fn validate_morph_tx( }); } - let token_info = TokenFeeInfo::load_for_caller(db, fee_token_id, input.sender, input.hardfork) + // Pool admission has no block environment, so a call-mode token's `balanceOf` + // is evaluated under the hardfork's defaults. That matches the pool's previous + // behaviour; threading the real head environment through admission is txpool + // work and does not belong in this change. + let env = MorphEvmEnv::new( + reth_revm::revm::context::CfgEnv::new_with_spec(input.hardfork), + morph_revm::MorphBlockEnv::default(), + ); + let token_info = TokenFeeInfo::load_for_caller(db, fee_token_id, input.sender, &env) .map_err(|err| MorphTxError::TokenInfoFetchFailed { token_id: fee_token_id, message: format!("{err:?}"), From 2cfa0166da9f43ee72110c9138298aea9cff9390 Mon Sep 17 00:00:00 2001 From: panos Date: Tue, 15 Sep 2026 17:07:47 +0800 Subject: [PATCH 02/12] test(statetest): add geth-derived golden fixtures for fee-token calls Twelve cases across Emerald and Jade, with state roots, logs roots and transaction gas generated by morph-geth 5744b8f66. Every case registers its token with `balanceSlot = 0`, so all twelve exercise the EVM-call path: deduction clearing the payer's ERC20 balance slot, the one-unit balance control, main-frame revert and OOG, a main call that restores the cleared slot, negative-refund cancellation, ORIGIN and GASPRICE guards, static-call violations, a successful transfer whose refund returns false, a refund that reverts, and zero-fee storage warmth. Note that "balance slot" in these case names is the ERC20's own storage slot that the fee `transfer()` clears, not the registry's optional `balanceSlot` field; the storage-slot fee path is not covered by these fixtures. Replaying them against main (v1.3.0) fails 8 of the 12 templates on both forks. With the preceding commit all 24 outcomes pass. --- .../tests/fee_token_internal_calls.rs | 33 + .../fixtures/fee_token_internal_calls.json | 1060 +++++++++++++++++ 2 files changed, 1093 insertions(+) create mode 100644 bin/morph-statetest/tests/fee_token_internal_calls.rs create mode 100644 bin/morph-statetest/tests/fixtures/fee_token_internal_calls.json diff --git a/bin/morph-statetest/tests/fee_token_internal_calls.rs b/bin/morph-statetest/tests/fee_token_internal_calls.rs new file mode 100644 index 00000000..1cf8b5c9 --- /dev/null +++ b/bin/morph-statetest/tests/fee_token_internal_calls.rs @@ -0,0 +1,33 @@ +//! Golden roots/logs generated by morph-geth 5744b8f66 (Emerald and Jade). +//! The gas constants are transaction totals; geth's statetest tool subtracts +//! intrinsic gas when the total is at least the intrinsic cost. +use morph_statetest::runner::run_suite_str; + +#[test] +fn fee_token_calls_match_geth() { + let outcomes = run_suite_str(include_str!("fixtures/fee_token_internal_calls.json")).unwrap(); + assert_eq!(outcomes.len(), 24); + for outcome in outcomes { + assert!( + outcome.pass, + "{} / {}: {}", + outcome.test, outcome.fork, outcome.error_msg + ); + let gas = match outcome.test.as_str() { + "deduct_clear" => 16_800, + "main_revert" => 16_804, + "main_oog" => 95_200, + "main_restores_cleared_slot" => 23_291, + "balance_writes" => 0, + "zero_fee_reads_balance" => 23_574, + "deduct_keep" + | "origin_guard" + | "gasprice_guard" + | "refund_false_keeps_transfer" + | "zero_fee" + | "refund_revert_rolls_back_transfer" => 21_000, + name => panic!("missing gas expectation for {name}"), + }; + assert_eq!(outcome.gas_used, gas, "{} / {}", outcome.test, outcome.fork); + } +} diff --git a/bin/morph-statetest/tests/fixtures/fee_token_internal_calls.json b/bin/morph-statetest/tests/fixtures/fee_token_internal_calls.json new file mode 100644 index 00000000..03ccffec --- /dev/null +++ b/bin/morph-statetest/tests/fixtures/fee_token_internal_calls.json @@ -0,0 +1,1060 @@ +{ + "balance_writes": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x60015f55620f42405f5260205ff3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x7c1c360fabaec3c980927e916a02d97a6ef79b93d601e0a1616e4a8014ce5669", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "expectException": "TokenBalanceQueryFailed" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x7c1c360fabaec3c980927e916a02d97a6ef79b93d601e0a1616e4a8014ce5669", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347", + "expectException": "TokenBalanceQueryFailed" + } + ] + } + }, + "deduct_clear": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xfb0ac627f1b92b10d0f1c60ef87667dc42cd3eb7a919ceb84e9427c63f9b6125", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xfb0ac627f1b92b10d0f1c60ef87667dc42cd3eb7a919ceb84e9427c63f9b6125", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "deduct_keep": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xe1a1a7e57eaf4aee439d65814e0a688a2775ee81fbdc2c4d77f06534d7088eac", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xe1a1a7e57eaf4aee439d65814e0a688a2775ee81fbdc2c4d77f06534d7088eac", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "gasprice_guard": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x3a600a14600a575f5ffd5b36604414601e576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x0d1c7d12041d949d73f15b8d08dbdcacecdf844675d40d41632a0bfb463ef36d", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x0d1c7d12041d949d73f15b8d08dbdcacecdf844675d40d41632a0bfb463ef36d", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "main_oog": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0xfe", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x1b9c3313759258951e68ab4e7754f0859f0b049431eb3d1e7a2ff460ceafe82b", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x1b9c3313759258951e68ab4e7754f0859f0b049431eb3d1e7a2ff460ceafe82b", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "main_restores_cleared_slot": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x3660041460385736604414601a576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f35b6001325500", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x3000000000000000000000000000000000000003", + "value": [ + "0x0" + ], + "data": [ + "0xdeadbeef" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xdda0e3d5c58b32c62b9168cf468233be793eb5d1d0ab886dacdaf490b84a72e2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xdda0e3d5c58b32c62b9168cf468233be793eb5d1d0ab886dacdaf490b84a72e2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "main_revert": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x5f5ffd", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x4bfefc48ad519124f544514a1d413329e38fce98ee522a763a79fdd96b68fdc2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x4bfefc48ad519124f544514a1d413329e38fce98ee522a763a79fdd96b68fdc2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "origin_guard": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x3273a94f5374fce5edbc8e2a8697c15331677e6ebf0b14601d575f5ffd5b366044146031576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x3ed6676fa11fc0e4294ed8a446546a0c3c43f5b664bfa28f4104489d6390102a", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x3ed6676fa11fc0e4294ed8a446546a0c3c43f5b664bfa28f4104489d6390102a", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "refund_false_keeps_transfer": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b602435803354033355600435805482019055505f5fa03373a94f5374fce5edbc8e2a8697c15331677e6ebf0b145f5260205ff3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x02e23c9ed829c4eb474097e4babff09ad0214f7d1f4affe7f661f4927ecba46c", + "logs": "0x773911b8633886efdf9360b2936f4f0ee4ae34b8cd1c0ffd0c06861d1c751aeb" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x02e23c9ed829c4eb474097e4babff09ad0214f7d1f4affe7f661f4927ecba46c", + "logs": "0x773911b8633886efdf9360b2936f4f0ee4ae34b8cd1c0ffd0c06861d1c751aeb" + } + ] + } + }, + "refund_revert_rolls_back_transfer": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b602435803354033355600435805482019055505f5fa03373a94f5374fce5edbc8e2a8697c15331677e6ebf0b146047575f5ffd5b600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x0b438cf7a8bddc48e5097828159eed83ccdf052406ad296ec69cb8e194de0737", + "logs": "0x16ecc5d734ad798966d0ece89ce9fc365a53ed550661fb7607afee69c831daab" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x0b438cf7a8bddc48e5097828159eed83ccdf052406ad296ec69cb8e194de0737", + "logs": "0x16ecc5d734ad798966d0ece89ce9fc365a53ed550661fb7607afee69c831daab" + } + ] + } + }, + "zero_fee": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x0", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b602435803354033355600435805482019055505f5fa0600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0x0", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x316b400ab83009d30ab6a987f74a061de29c2f3116918ca244b7267a69b75be2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x316b400ab83009d30ab6a987f74a061de29c2f3116918ca244b7267a69b75be2", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "zero_fee_reads_balance": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x0", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x0000000000000000000000000000000000000000000000000000000000000000" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0x0", + "gasLimit": [ + "0x186a0" + ], + "to": "0x3000000000000000000000000000000000000003", + "value": [ + "0x0" + ], + "data": [ + "0x70a08231000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xeb101e868f55079346e3fc2d78473e1ebf0b446070185d3a266f8f0f81f534a0", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xeb101e868f55079346e3fc2d78473e1ebf0b446070185d3a266f8f0f81f534a0", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + } +} From 3d8dc8b736631844880984c6305d556b79c82dc8 Mon Sep 17 00:00:00 2001 From: panos Date: Tue, 15 Sep 2026 17:55:34 +0800 Subject: [PATCH 03/12] docs(revm): document the transaction-id invariant behind the mid-tx finalize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The call-mode fee path commits its deduction by calling `evm.finalize()` mid-transaction, then re-marks every account and slot cold to reproduce the warmth go-ethereum's `StateDB.Prepare` would leave behind. Nothing explained why the whole journal is discarded there, or which part of that reset the correctness depends on. The load-bearing property is that the reset must not advance the transaction id. Warming a slot runs through `EvmStorageSlot::mark_warm_with_transaction_id`, which re-baselines the EIP-2200 `original_value` to the present value whenever the slot's id differs from the journal's. Had that fired on the slot the deduction just cleared, the main frame's SSTORE would be a create rather than a recreate and the `SubRefund` cancelling the deduction's `+4800` would be lost — measured on `main_restores_cleared_slot`, 23_291 gas becomes 38_391. It cannot fire here because ids stay equal across execution. revm advances the id only when a transaction finishes — `commit_tx()` from `execution_result`, or `discard_tx()` on the error path — both after the main frame; `ExecuteEvm::finalize` then resets it to ZERO before the next transaction. `finalize()` at this point is therefore idempotent for the id, while `commit_tx()` would leave the deduction-warmed slots holding 0 against a journal holding 1. Verified by substitution: swapping `commit_tx()` in fails `main_restores_cleared_slot` with a state root mismatch. --- crates/revm/src/handler.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index f2ab9449..35ac8f44 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -655,7 +655,35 @@ where evm.pre_fee_logs = std::mem::take(&mut evm.ctx_mut().journal_mut().logs); // State changes should be marked cold to avoid warm access in the main tx execution. - // finalize() clears journal state (including logs, which we already took above). + // Fee deduction ran a real EVM frame, so its state writes must survive while the + // frame's metadata must not: go-ethereum's `StateDB.Prepare` rebuilds the access + // list and resets transient storage before the main transaction + // (core/state/statedb.go:1066). `finalize()` is the nearest revm equivalent — it + // commits the deduction's state and drops the journal, undo history, logs and + // transient storage — and re-marking every account and slot cold reproduces the + // warmth `Prepare` would have left behind. + // + // The `transaction_id` handling inside `finalize()` is load-bearing, not incidental. + // Warming a slot goes through `EvmStorageSlot::mark_warm_with_transaction_id`, which + // re-baselines the EIP-2200 `original_value` to the present value whenever the slot's + // transaction id differs from the journal's (revm-state/src/lib.rs). That must not + // happen to the slot the deduction just cleared: re-baselining it to zero would make + // the main frame's SSTORE a *create* (SSTORE_SET, 20000) rather than a *recreate* + // (100), and would drop the `SubRefund` that cancels the deduction frame's `+4800`. + // Measured on `main_restores_cleared_slot`: 23_291 gas becomes 38_391 (+19_900 + // -4_800), and the state root moves with the fee it implies. + // + // It does not happen because ids stay equal throughout execution. revm advances the + // id only when a transaction finishes — `commit_tx()` from `execution_result`, or + // `discard_tx()` on the error path — both after the main frame is done; + // `ExecuteEvm::finalize` then resets it to ZERO before the next transaction. So + // across this deduction and the main frame the journal's id is 0 — and this + // `finalize()` keeps it at 0 rather than advancing it. Swapping in `commit_tx()` here + // would leave the deduction-warmed slots holding 0 while the journal held 1, and the + // main frame's first touch of them would re-baseline `original_value`; the call-path + // fixtures under `bin/morph-statetest` catch exactly that. An explicit `mark_cold` + // carries no such risk: it drives only the warm/cold gas decision, never the + // re-baseline. let mut state = evm.finalize(); state.iter_mut().for_each(|(_, acc)| { acc.mark_cold(); From 2d5b6752c9d1a3e837c3fd97ff97dd030c76e1f3 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 16 Sep 2026 09:40:50 +0800 Subject: [PATCH 04/12] fix(revm): return the fee frames' shared memory to the transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fee-token frames are top-level frames that run in the middle of a transaction, so neither of revm's truncation points covers them: `free_child_context` only releases a child frame's region, and `LocalContext::clear` only runs once the whole transaction is done. The frames therefore left their bytes on the context's shared buffer and the main transaction frame started on top of them. Measured on a call-mode MorphTx: the main frame entered with `MSIZE == 32`, and `MLOAD(0)` returned 9_000_000, the payer's post-fee token balance left behind by the internal `balanceOf`. go-ethereum allocates a fresh `Memory` for every interpreter run (core/vm/interpreter.go), so both read zero there, and both read zero here on the ETH-fee control. Any contract that reads memory it never wrote, or branches on MSIZE, produced a different result on morph-reth than on morph-geth. Carve each fee frame's memory out above whatever the buffer already holds, and release it on the way out, including on the error path. The frames keep writing into the context's buffer rather than one of their own: a nested call hands its callee a `CallInput::SharedBuffer` range, and while a contract callee resolves that range against its own frame memory, a precompile callee resolves it against the context's buffer (`CallInput::as_bytes`). A private buffer would hand every precompile called from a fee frame empty calldata — `fee_token_frames_reach_a_precompile_through_memory` fails with `InsufficientTokenBalance { available: 0 }` under that variant. Gas is unaffected, since memory expansion is charged from the per-frame `Gas` counter, and the leak did not cross transactions, since `local_mut().clear()` runs at the end of each one. The 24 geth-derived golden fixtures cannot see any of this: ten of their twelve templates call a codeless EOA and the other two call bytecode that writes before it reads. --- crates/revm/src/handler.rs | 201 ++++++++++++++++++++++++++++++++++++- 1 file changed, 199 insertions(+), 2 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 35ac8f44..7dbd44ad 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -833,8 +833,20 @@ where if let Some(delegate) = known_bytecode.1.eip7702_address() { known_bytecode = internal_call_code(evm.ctx_mut().journal_mut(), delegate)?; } - let mut memory = + // Fee frames are top-level frames that run in the middle of a transaction, so + // neither of revm's truncation points covers them: `free_child_context` only + // releases a *child* frame's region, and `LocalContext::clear` only runs once the + // whole transaction is done. Carve this frame's memory out above whatever the + // shared buffer already holds and release it on the way out, so the main + // transaction frame still starts on zeroed memory the way go-ethereum's + // per-run `NewMemory()` guarantees. The frame keeps using the context's buffer + // rather than one of its own because a nested call hands its callee a + // `CallInput::SharedBuffer` range, and a precompile callee resolves that range + // against the context's buffer (`CallInput::as_bytes`) rather than against the + // calling frame's memory. + let mut fee_frame_memory = SharedMemory::new_with_buffer(evm.ctx_ref().local().shared_memory_buffer().clone()); + let mut memory = fee_frame_memory.new_child_context(); memory.set_memory_limit(evm.ctx_ref().cfg().memory_limit()); let frame = FrameInit { depth: 0, @@ -859,7 +871,9 @@ where charged_new_account_state_gas: false, })), }; - let result = MorphEvmHandler::::new().run_exec_loop(evm, frame)?; + let result = MorphEvmHandler::::new().run_exec_loop(evm, frame); + fee_frame_memory.free_child_context(); + let result = result?; take_context_error(evm)?; Ok(result) } @@ -2197,4 +2211,187 @@ mod tests { .unwrap(); assert!(evm.ctx_ref().journal().state.is_empty()); } + + /// Runs one transaction against `code` deployed at [`FEE_REFUND_TARGET`] and + /// returns what the main frame returned. `fee_token_id` selects the call-mode + /// token-fee path or the ordinary ETH-fee path. + fn fee_refund_run_probe(code: Bytes, fee_token_id: Option) -> Bytes { + let mut evm = fee_refund_evm(U256::from(FEE_REFUND_TOKEN_FEE)); + let db = evm.ctx_mut().journal_mut().db_mut(); + insert_contract(db, FEE_REFUND_TARGET, code); + db.insert_account_info( + FEE_REFUND_CALLER, + AccountInfo { + balance: U256::from(FEE_REFUND_GAS_LIMIT as u128 * FEE_REFUND_GAS_PRICE), + ..Default::default() + }, + ); + let tx = MorphTxEnv { + inner: TxEnv { + tx_type: if fee_token_id.is_some() { + MORPH_TX_TYPE_ID + } else { + 0 + }, + caller: FEE_REFUND_CALLER, + gas_limit: FEE_REFUND_GAS_LIMIT, + gas_price: FEE_REFUND_GAS_PRICE, + kind: TxKind::Call(FEE_REFUND_TARGET), + ..Default::default() + }, + fee_token_id, + ..Default::default() + }; + let result = evm.transact_one(tx).expect("probe must execute"); + assert!(result.is_success(), "expected success, got {result:?}"); + result.output().cloned().unwrap_or_default() + } + + /// Minimal proxy: copies its calldata into memory and `DELEGATECALL`s + /// `implementation`, the way mainnet's call-mode fee tokens reach theirs. + fn delegating_proxy_code(implementation: Address) -> Bytes { + let mut code = vec![ + 0x36, // CALLDATASIZE (size) + 0x5f, // PUSH0 (offset) + 0x5f, // PUSH0 (destOffset) + 0x37, // CALLDATACOPY + 0x5f, // PUSH0 (retSize) + 0x5f, // PUSH0 (retOffset) + 0x36, // CALLDATASIZE (argsSize) + 0x5f, // PUSH0 (argsOffset) + 0x73, // PUSH20 implementation + ]; + code.extend_from_slice(implementation.as_slice()); + code.extend_from_slice(&[ + 0x5a, // GAS + 0xf4, // DELEGATECALL + 0x3d, // RETURNDATASIZE (size) + 0x5f, // PUSH0 (offset) + 0x5f, // PUSH0 (destOffset) + 0x3e, // RETURNDATACOPY + 0x50, // POP (DELEGATECALL success flag) + 0x3d, // RETURNDATASIZE (size) + 0x5f, // PUSH0 (offset) + 0xf3, // RETURN + ]); + Bytes::from(code) + } + + /// Same ERC20, except `balanceOf` returns its result through the identity + /// precompile, reading the precompile's arguments out of memory. + fn fee_refund_precompile_erc20_code() -> Bytes { + let mut code = vec![ + 0x36, // CALLDATASIZE + 0x60, 0x44, // PUSH1 68 + 0x14, // EQ + 0x60, 0x1e, // PUSH1 30 (transfer JUMPDEST) + 0x57, // JUMPI + // balanceOf(address), answered by identity(mem[0..32]) + 0x60, 0x04, // PUSH1 4 + 0x35, // CALLDATALOAD + 0x54, // SLOAD + 0x5f, // PUSH0 + 0x52, // MSTORE + 0x60, 0x20, // PUSH1 32 (retSize) + 0x60, 0x20, // PUSH1 32 (retOffset) + 0x60, 0x20, // PUSH1 32 (argsSize) + 0x5f, // PUSH0 (argsOffset) + 0x60, 0x04, // PUSH1 4 (identity precompile) + 0x5a, // GAS + 0xfa, // STATICCALL + 0x50, // POP + 0x60, 0x20, // PUSH1 32 (size) + 0x60, 0x20, // PUSH1 32 (offset) + 0xf3, // RETURN + ]; + assert_eq!(code.len(), 30, "the transfer JUMPDEST moved"); + code.extend_from_slice(&fee_refund_slotless_erc20_code()[19..]); + Bytes::from(code) + } + + /// A precompile called from inside a fee frame resolves its arguments against + /// the context's shared buffer, so the fee frames have to keep writing into that + /// buffer rather than into one of their own. + #[test] + fn fee_token_frames_reach_a_precompile_through_memory() { + let fee = U256::from(FEE_REFUND_TOKEN_FEE); + let mut evm = fee_refund_evm(fee); + let db = evm.ctx_mut().journal_mut().db_mut(); + insert_contract(db, FEE_REFUND_TOKEN, fee_refund_precompile_erc20_code()); + assert_eq!( + fee_refund_run_fee_tx(&mut evm), + fee_refund_run_token_fee_tx(fee) + ); + } + + /// Mainnet's call-mode fee tokens are proxies, so a fee frame's nested + /// `DELEGATECALL` reads its calldata back out of the frame's memory. + #[test] + fn fee_token_frames_reach_a_delegating_proxy_implementation() { + const IMPLEMENTATION: Address = address!("3000000000000000000000000000000000000004"); + let fee = U256::from(FEE_REFUND_TOKEN_FEE); + let mut evm = fee_refund_evm(fee); + let db = evm.ctx_mut().journal_mut().db_mut(); + insert_contract(db, IMPLEMENTATION, fee_refund_slotless_erc20_code()); + insert_contract(db, FEE_REFUND_TOKEN, delegating_proxy_code(IMPLEMENTATION)); + + // Indistinguishable from the same transaction against an unproxied token: + // the implementation saw exactly the calldata the fee frame wrote. + assert_eq!( + fee_refund_run_fee_tx(&mut evm), + fee_refund_run_token_fee_tx(fee) + ); + } + + /// Runs the standard call-mode token-fee MorphTx on an already-built `evm` and + /// reports it the way [`fee_refund_run_token_fee_tx`] does. + fn fee_refund_run_fee_tx( + evm: &mut MorphEvm, NoOpInspector>, + ) -> (u64, u64, U256) { + let result = evm + .transact_one(MorphTxEnv { + inner: TxEnv { + tx_type: MORPH_TX_TYPE_ID, + caller: FEE_REFUND_CALLER, + gas_limit: FEE_REFUND_GAS_LIMIT, + gas_price: FEE_REFUND_GAS_PRICE, + kind: TxKind::Call(FEE_REFUND_TARGET), + ..Default::default() + }, + fee_token_id: Some(FEE_REFUND_TOKEN_ID), + ..Default::default() + }) + .expect("token-fee MorphTx must execute"); + assert!(result.is_success(), "expected success, got {result:?}"); + ( + result.tx_gas_used(), + result.gas().final_refunded(), + fee_refund_present_value(evm, FEE_REFUND_CALLER), + ) + } + + /// go-ethereum allocates a fresh `Memory` for every interpreter run + /// (`core/vm/interpreter.go`), so a transaction's frame always starts on zeroed + /// memory. The fee-token frames run on the transaction's shared memory buffer, + /// so they have to hand it back the length they found it at. + #[test] + fn fee_token_frames_do_not_leak_memory_into_the_main_frame() { + // `MSIZE` and `MLOAD(0)`, each returned as the frame's 32-byte output. + for probe in [ + vec![0x59, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3], + vec![0x5f, 0x51, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3], + ] { + let code = Bytes::from(probe); + assert_eq!( + U256::from_be_slice(&fee_refund_run_probe(code.clone(), None)), + U256::ZERO, + "ETH-fee control" + ); + assert_eq!( + U256::from_be_slice(&fee_refund_run_probe(code, Some(FEE_REFUND_TOKEN_ID))), + U256::ZERO, + "a token-fee main frame must start on zeroed memory too" + ); + } + } } From 43104aec376fee824149dae44c431f13ab4b2b40 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 16 Sep 2026 10:52:25 +0800 Subject: [PATCH 05/12] docs(revm): correct what the pool's balanceOf query aligns with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment justified setting the query's ORIGIN to the queried account by claiming go-ethereum's pool does the same. It does not: `getBalanceFunc` builds its EVM on an empty `vm.TxContext{}` (core/tx_pool.go:341), so its ORIGIN is the zero address — and go-ethereum's own execution layer resolves the same `balanceOf` with ORIGIN set to the sender, so its pool disagrees with its own execution. Setting ORIGIN to the account is still the right call, for the opposite reason to the one recorded: admission exists to predict what the builder will be able to include, so it follows this client's execution layer rather than the other client's pool. Say that, and record the one input the query still cannot match — GASPRICE, which stays at the `TxEnv` default of zero because the effective price depends on the next block's base fee. --- crates/revm/src/token_fee.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/revm/src/token_fee.rs b/crates/revm/src/token_fee.rs index dbbafd25..a448ba7d 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -328,7 +328,15 @@ fn read_token_balance_with_fallback( // answer for a token whose balance depends on block context or `msg.sender`. let db: &mut dyn Database = db; let mut evm = MorphEvm::from_env(db, env.clone(), NoOpInspector {}); - // Geth's pool query has Origin=sender and GasPrice=0, unlike an executing transaction. + // ORIGIN follows this client's own execution layer, which resolves the same + // `balanceOf` against the transaction's `caller`. go-ethereum's pool instead builds + // its query on an empty `vm.TxContext{}` (core/tx_pool.go:341), leaving ORIGIN at the + // zero address and disagreeing with go-ethereum's own execution layer. Admission + // exists to predict what the builder will be able to include, so it follows execution + // rather than the other client's pool. GASPRICE is the one input this query still + // cannot match: it stays at the `TxEnv` default of zero because the effective price + // depends on the next block's base fee, which admission does not know. go-ethereum's + // pool has the same gap. evm.tx.inner.caller = account; crate::handler::evm_call_balance_of(&mut evm, token, account) } From 0e58849c6649ea0023fc9c5848f93c90a604ea3b Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 16 Sep 2026 11:06:51 +0800 Subject: [PATCH 06/12] test(node): run the e2e fee-token path in EVM-call mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every registered fee token on mainnet now has its `balanceSlot` cleared, so the fee is moved by real `balanceOf` and `transfer` calls into the token contract. The e2e genesis registered `balanceSlot = 2` instead, which put all 128 integration tests on the direct-storage path: the mode that is scheduled to be disabled by a hardfork, and the one production does not use. The node-level behaviour of the mode production does run — receipts, log ordering, pool admission, the replay RPCs — had no integration coverage at all, while the 24 statetest golden fixtures cover only its state effects. Give the test token the ERC20 runtime the gas-regression test already carried inline, and clear the registry's `balanceSlot`. The runtime keeps `balanceOf` at slot 1, so `test_token_balance_slot` still derives the same slot independently and remains a test oracle rather than a second copy of the code under test. Both gas regressions hold unchanged at 48_128 and 50_428: the fee frames run on their own 200k budget, and the deduction books no SSTORE refund while the payer keeps a balance. What does change is the receipt, which now carries the fee deduction and the fee reimbursement around the transaction's own transfer. Assert that ordering — deduction, main, refund — since it is what go-ethereum produces and what indexers read. --- crates/node/src/test_utils.rs | 27 ++++++++++++++++--- crates/node/tests/assets/test-genesis.json | 4 +-- crates/node/tests/it/morph_tx.rs | 31 ++++++++-------------- 3 files changed, 37 insertions(+), 25 deletions(-) diff --git a/crates/node/src/test_utils.rs b/crates/node/src/test_utils.rs index b61b5809..24d22560 100644 --- a/crates/node/src/test_utils.rs +++ b/crates/node/src/test_utils.rs @@ -828,12 +828,33 @@ impl L1MessageBuilder { /// - token_address = `TEST_TOKEN_ADDRESS` /// - price_ratio = 1e18 (1:1 with ETH) /// - decimals = 18, isActive = true +/// - balanceSlot = 0, i.e. EVM-call mode +/// +/// Call mode is what mainnet runs: every registered fee token there has its +/// `balanceSlot` cleared, so the fee is moved by real `balanceOf` / `transfer` +/// calls into the token contract and the receipt carries their `Transfer` events. pub const TEST_TOKEN_ID: u16 = 1; /// Address of the test ERC20 token deployed in the test genesis. /// /// Pre-funded with 1000 tokens (1e21 wei) for test accounts 0 and 1. /// Address: `0x5300000000000000000000000000000000000022` +/// +/// The genesis gives it the optimized runtime of: +/// +/// ```solidity +/// contract Slot1Token { +/// uint256 private dummy; +/// mapping(address => uint256) public balanceOf; // slot 1 +/// event Transfer(address indexed from, address indexed to, uint256 value); +/// function transfer(address to, uint256 amount) external returns (bool) { ... } +/// } +/// ``` +/// +/// Real code is what makes the registry's EVM-call mode usable: the fee path calls +/// `balanceOf` and `transfer` on this contract rather than writing its storage +/// directly. Keeping `balanceOf` at slot 1 also lets [`test_token_balance_slot`] +/// derive the same slot independently as a test oracle. pub const TEST_TOKEN_ADDRESS: Address = Address::new([ 0x53, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x22, @@ -849,9 +870,9 @@ pub const TEST_FEE_VAULT_ADDRESS: Address = Address::new([ /// Base slot of the test token's `balances` mapping. /// -/// The registry stores this one-based so that zero means "unknown", and -/// `morph_revm`'s token-fee reader subtracts one. The test genesis registers `2` -/// for `TEST_TOKEN_ID`, so the effective base slot is `1`. +/// The registry's own `balanceSlot` is zero (call mode), so this is not read from +/// the registry — it mirrors the layout of the token contract's bytecode so tests +/// can check balances without going through the fee-token code under test. const TEST_TOKEN_BALANCE_BASE_SLOT: u64 = 1; /// Storage slot holding `account`'s balance of the test ERC20 token. diff --git a/crates/node/tests/assets/test-genesis.json b/crates/node/tests/assets/test-genesis.json index 09cc751b..c23a20b8 100644 --- a/crates/node/tests/assets/test-genesis.json +++ b/crates/node/tests/assets/test-genesis.json @@ -67,7 +67,7 @@ "code": "0x00", "storage": { "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000005300000000000000000000000000000000000022", - "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000", "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000de0b6b3a7640000" @@ -75,7 +75,7 @@ }, "0x5300000000000000000000000000000000000022": { "balance": "0x0", - "code": "0x00", + "code": "0x608060405234801561000f575f5ffd5b5060043610610034575f3560e01c806370a0823114610038578063a9059cbb1461006a575b5f5ffd5b61005761004636600461015e565b60016020525f908152604090205481565b6040519081526020015b60405180910390f35b61007d61007836600461017e565b61008d565b6040519015158152602001610061565b335f90815260016020526040812054828110156100da5760405162461bcd60e51b815260206004820152600760248201526662616c616e636560c81b604482015260640160405180910390fd5b335f81815260016020908152604080832087860390556001600160a01b03881680845292819020805488019055518681529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35060019392505050565b80356001600160a01b0381168114610159575f5ffd5b919050565b5f6020828403121561016e575f5ffd5b61017782610143565b9392505050565b5f5f6040838503121561018f575f5ffd5b61019883610143565b94602093909301359350505056", "storage": { "0xa3c1274aadd82e4d12c8004c33fb244ca686dad4fcc8957fc5668588c11d9502": "0x00000000000000000000000000000000000000000000003635c9adc5dea00000", "0x3c8e904cdb19937d60d41c8d984b1a8803ad6e0891b4f9e032dcec2a22c2c7f5": "0x00000000000000000000000000000000000000000000003635c9adc5dea00000" diff --git a/crates/node/tests/it/morph_tx.rs b/crates/node/tests/it/morph_tx.rs index 97cef34d..8f96d3d1 100644 --- a/crates/node/tests/it/morph_tx.rs +++ b/crates/node/tests/it/morph_tx.rs @@ -376,21 +376,6 @@ fn address_topic(address: Address) -> B256 { B256::from(topic) } -/// Optimized runtime for: -/// -/// ```solidity -/// contract Slot1Token { -/// uint256 private dummy; -/// mapping(address => uint256) public balanceOf; // slot 1 -/// event Transfer(address indexed from, address indexed to, uint256 value); -/// function transfer(address to, uint256 amount) external returns (bool) { ... } -/// } -/// ``` -/// -/// Keeping `balanceOf` at slot 1 lets the test token use the same storage layout -/// as `tests/assets/test-genesis.json` and the token registry's direct-slot path. -const SLOT1_ERC20_RUNTIME_CODE: &str = "0x608060405234801561000f575f5ffd5b5060043610610034575f3560e01c806370a0823114610038578063a9059cbb1461006a575b5f5ffd5b61005761004636600461015e565b60016020525f908152604090205481565b6040519081526020015b60405180910390f35b61007d61007836600461017e565b61008d565b6040519015158152602001610061565b335f90815260016020526040812054828110156100da5760405162461bcd60e51b815260206004820152600760248201526662616c616e636560c81b604482015260640160405180910390fd5b335f81815260016020908152604080832087860390556001600160a01b03881680845292819020805488019055518681529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35060019392505050565b80356001600160a01b0381168114610159575f5ffd5b919050565b5f6020828403121561016e575f5ffd5b61017782610143565b9392505050565b5f5f6040838503121561018f575f5ffd5b61019883610143565b94602093909301359350505056"; - /// After a successful MorphTx v0 with ERC20 fee, the sender's token balance /// must decrease (fee was charged from tokens, not ETH). #[tokio::test(flavor = "multi_thread")] @@ -492,7 +477,6 @@ async fn token_fee_transfer_gas_regression( let token_addr = morph_node::test_utils::TEST_TOKEN_ADDRESS; let (mut nodes, wallet) = TestNodeBuilder::new() .with_schedule(schedule) - .with_account_code(token_addr, SLOT1_ERC20_RUNTIME_CODE) .build() .await?; let mut node = nodes.pop().unwrap(); @@ -560,17 +544,24 @@ async fn token_fee_transfer_gas_regression( .iter() .filter(|log| log.address == token_addr && log.topics().first() == Some(&transfer_topic)) .collect(); + // In call mode the fee is moved by real ERC20 calls, so the transaction's own + // transfer arrives bracketed by them, in go-ethereum's order: deduction, main, + // reimbursement. assert_eq!( transfer_logs.len(), - 1, - "the main ERC20 transfer should execute against the fee token contract" + 3, + "receipt should carry the fee deduction, the main transfer and the fee refund" ); assert_eq!(transfer_logs[0].topics()[1], address_topic(sender)); - assert_eq!(transfer_logs[0].topics()[2], address_topic(recipient)); + assert_eq!(transfer_logs[0].topics()[2], address_topic(fee_vault)); + assert_eq!(transfer_logs[1].topics()[1], address_topic(sender)); + assert_eq!(transfer_logs[1].topics()[2], address_topic(recipient)); assert_eq!( - transfer_logs[0].data.data.as_ref(), + transfer_logs[1].data.data.as_ref(), amount.to_be_bytes::<32>() ); + assert_eq!(transfer_logs[2].topics()[1], address_topic(fee_vault)); + assert_eq!(transfer_logs[2].topics()[2], address_topic(sender)); let state_after = node.inner.provider.latest()?; let sender_after = state_after From 7098b30524205b4f29804a70e720e84d05759a95 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 16 Sep 2026 15:09:06 +0800 Subject: [PATCH 07/12] fix(revm): make the fee path's load-bearing invariants local and true MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six items a review of this branch turned up, each verified against revm 42 and go-ethereum before being acted on. `reimburse_caller_token_fee`'s slot branch reaches `sload`/`sstore` on the token directly, which panic rather than error when the account is absent from `journal.state` (`sload_assume_account_present` -> `ColdLoadSkipped` -> `unwrap_db_error`). It relied on the deduction having loaded it, but the deduction skips both transfer modes for a zero fee. The two cannot disagree today — `eth_to_token_amount` rounds up, so a zero token fee means a zero ETH fee, which returns before the transfer — but that proof lives in another function. Load it where it is needed instead; today the load is a no-op. `evm_call` has the same shape of hidden dependency: its `CallValue::Transfer` frame runs `Journal::transfer_loaded`, whose zero-value path is `self.state.get_mut(&to).unwrap()`. An ordinary CALL is safe because the opcode's `load_acc_and_calc_gas` loaded the account; an internal call has no opcode, so `internal_call_code` is the only load. Its doc comment described itself purely as a warmth-preserving code read. Say what it is also for. `MorphBlockExecutor::hardfork` became write-only when `get_morph_tx_fields` stopped taking a hardfork, leaving a doc comment claiming it is "reused in `commit_transaction`". Removing it leaves `spec` dead as well — it existed only to compute it. Drop both, and the constructor argument with them. Two comments claimed things that are not true of the pinned revm or of the code they describe. `load_token_fee_info` blamed a "30M gas limit" on the previous path, which went through `system_call_one` and so already capped at go-ethereum's 200k; the divergence was the environment and the sender. And `ExecutionResult::Revert` does carry a `logs` field in revm 42 — the fee logs need their side channel because the mid-transaction `finalize()` clears the journal's logs, not because the variant cannot hold them. The pool restated the fee-limit clamp by hand under a "Match REVM semantics" comment, although `TokenFeeInfo::effective_fee_limit` was added to be the one copy. Use it. Finally, `transfer_erc20_with_evm`'s affordability check built its error message with `ok_or`, rendering two U256s and allocating a String on every successful call-mode fee transfer; `ok_or_else` defers it. --- crates/evm/src/block/factory.rs | 2 +- crates/evm/src/block/mod.rs | 21 +----------- crates/revm/src/handler.rs | 43 +++++++++++++++++------- crates/txpool/src/morph_tx_validation.rs | 11 ++---- 4 files changed, 36 insertions(+), 41 deletions(-) diff --git a/crates/evm/src/block/factory.rs b/crates/evm/src/block/factory.rs index 0ba66897..a1219465 100644 --- a/crates/evm/src/block/factory.rs +++ b/crates/evm/src/block/factory.rs @@ -74,6 +74,6 @@ impl BlockExecutorFactory for MorphBlockExecutorFactory { DB: StateDB, I: Inspector>, { - MorphBlockExecutor::new(evm, self.spec.clone(), self.receipt_builder) + MorphBlockExecutor::new(evm, self.receipt_builder) } } diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index a3b0a807..fbdecf02 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -23,7 +23,6 @@ use alloy_evm::{ }, }; use alloy_primitives::{Address, Log, U256}; -use morph_chainspec::{MorphChainSpec, MorphHardfork, MorphHardforks}; use morph_primitives::{MorphReceipt, MorphTxEnvelope}; use morph_revm::{L1_GAS_PRICE_ORACLE_ADDRESS, MorphHaltReason, TokenFeeInfo, evm::MorphContext}; use reth_primitives_traits::Recovered; @@ -84,8 +83,6 @@ impl TxResult for MorphTxResult { pub struct MorphBlockExecutor { /// The EVM used by executor (owned, not a reference) evm: MorphEvm, - /// Chain specification - spec: std::sync::Arc, /// Receipt builder receipt_builder: DefaultMorphReceiptBuilder, /// Receipts of executed transactions @@ -97,9 +94,6 @@ pub struct MorphBlockExecutor { /// Unlike receipt gas, L1 messages reserve their full gas limit because Morph geth does not /// return their unused gas to the block gas pool. gas_pool_used: u64, - /// Cached hardfork for this block (constant across all transactions). - /// Set in `apply_pre_execution_changes`, reused in `commit_transaction`. - hardfork: MorphHardfork, } impl MorphBlockExecutor @@ -111,21 +105,14 @@ where /// /// # Arguments /// * `evm` - The EVM instance configured for Morph execution - /// * `spec` - Chain specification containing hardfork information /// * `receipt_builder` - Builder for constructing transaction receipts - pub(crate) fn new( - evm: MorphEvm, - spec: std::sync::Arc, - receipt_builder: DefaultMorphReceiptBuilder, - ) -> Self { + pub(crate) fn new(evm: MorphEvm, receipt_builder: DefaultMorphReceiptBuilder) -> Self { Self { evm, - spec, receipt_builder, receipts: Vec::new(), gas_used: 0, gas_pool_used: 0, - hardfork: MorphHardfork::default(), } } @@ -218,12 +205,6 @@ where .basic(L1_GAS_PRICE_ORACLE_ADDRESS) .map_err(BlockExecutionError::other)?; - let block_number: u64 = self.evm.block().number.to(); - let hardfork = self - .spec - .morph_hardfork_at(block_number, self.evm.block().timestamp.to::()); - self.hardfork = hardfork; - Ok(()) } diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 7dbd44ad..1cc85500 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -441,6 +441,14 @@ where // should not cause transaction to fail" (state_transition.go:698). let refund_result = if let Some(balance_slot) = token_fee_info.balance_slot { let journal = evm.ctx().journal_mut(); + // `transfer_erc20_with_slot` reaches the journal's `sload`/`sstore` directly, + // which panic rather than error when the account is absent from `journal.state`. + // The deduction loads it — but it skips both transfer modes for a zero fee, so + // this must not rely on that having happened. Today the two cannot disagree + // (`eth_to_token_amount` rounds up, so a zero token fee means a zero ETH fee, + // which returns above), which makes this load a no-op that keeps the invariant + // local to the code that needs it. + let _ = journal.load_account_mut(token_fee_info.token_address)?; transfer_erc20_with_slot( journal, beneficiary, @@ -649,9 +657,11 @@ where // Cache fee Transfer logs separately from the journal. // // go-ethereum's StateDB.logs is independent of the state snapshot/revert - // mechanism — fee logs survive regardless of main tx result. revm's - // ExecutionResult::Revert has no logs field, so we keep fee logs out of - // the handler pipeline entirely and merge them in the receipt builder. + // mechanism — fee logs survive regardless of main tx result. In revm they + // would not: the `finalize()` below clears the journal's logs, and whatever + // survived would still be dropped when `execution_result` commits the + // transaction. So the fee logs are kept out of the handler pipeline entirely + // and merged back in the receipt builder. evm.pre_fee_logs = std::mem::take(&mut evm.ctx_mut().journal_mut().logs); // State changes should be marked cold to avoid warm access in the main tx execution. @@ -787,6 +797,13 @@ const EVM_CALL_GAS_LIMIT: u64 = 200_000; /// Loads internal-call code without changing the account's access-list temperature. /// Geth's direct Call/StaticCall resolve code without executing a CALL opcode. +/// +/// This is also what puts `address` into `journal.state`, which [`evm_call`] depends on: +/// a `CallValue::Transfer` frame runs `Journal::transfer_loaded`, and its zero-value path +/// is `self.state.get_mut(&to).unwrap()` — a panic, not an error. In an ordinary CALL the +/// account is there because the opcode's `load_acc_and_calc_gas` put it there; an internal +/// call has no opcode, so this is the only load. Resolving the bytecode some other way +/// (caching it, hoisting it, short-circuiting on a known code hash) must keep the load. fn internal_call_code( journal: &mut revm::Journal, address: Address, @@ -920,11 +937,13 @@ where /// /// go-ethereum reads it through `st.evm` (`GetAltTokenBalanceHybrid`, core/token_gas.go:43), /// so the `balanceOf` call sees the real block context, the real chain config and the user as -/// `msg.sender`. Building a throwaway EVM here instead would answer under -/// `BlockEnv::default()` and `CfgEnv::default()` — block 0, timestamp 1, chain id 1, zero -/// coinbase and base fee — with `SYSTEM_ADDRESS` as the sender and a 30M gas limit in place -/// of go-ethereum's 200k. For any token whose `balanceOf` reads that context the two clients -/// would charge different fees for the same transaction. +/// `msg.sender`. Building a throwaway EVM here instead — as this path used to, through +/// `system_call_one` — answers under `BlockEnv::default()` and `CfgEnv::default()`: block 0, +/// timestamp 1, chain id 1, zero coinbase and base fee, with `SYSTEM_ADDRESS` as the sender. +/// For any token whose `balanceOf` reads that context the two clients would charge different +/// fees for the same transaction. The gas budget was never the problem: `system_call_one` +/// capped at `SYSTEM_CALL_GAS_LIMIT`, which is go-ethereum's 200k, and so does +/// [`EVM_CALL_GAS_LIMIT`]. fn load_token_fee_info( evm: &mut MorphEvm, entry: TokenRegistryEntry, @@ -983,13 +1002,13 @@ where }; // Geth checks affordability before executing the token contract. - let expected_balance = from_balance_before.checked_sub(token_amount).ok_or( - MorphInvalidTransaction::TokenTransferFailed { + let expected_balance = from_balance_before + .checked_sub(token_amount) + .ok_or_else(|| MorphInvalidTransaction::TokenTransferFailed { reason: format!( "sender balance {from_balance_before} less than token amount {token_amount}" ), - }, - )?; + })?; let calldata = build_transfer_calldata(to, token_amount); let frame_result = diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index 2c378cc8..b6abf576 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -144,14 +144,9 @@ pub fn validate_morph_tx( let total_token_fee = token_gas_fee.saturating_add(input.l1_data_fee); let required_token_amount = token_info.eth_to_token_amount(total_token_fee); - // Match REVM semantics: - // - fee_limit == 0 => use token balance as effective limit - // - fee_limit > balance => cap by token balance - let effective_limit = if fee_limit.is_zero() || fee_limit > token_info.balance { - token_info.balance - } else { - fee_limit - }; + // Share the execution layer's clamp rather than restating it: a zero `fee_limit` + // means the whole token balance, and a larger one is capped by it. + let effective_limit = token_info.effective_fee_limit(fee_limit); // Check token balance against effective limit. if effective_limit < required_token_amount { From 200753ef77b80eaaa3f91ec55ddb5b0c141dbe5d Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 16 Sep 2026 15:15:30 +0800 Subject: [PATCH 08/12] test(statetest): cover uninitialized memory and the direct-slot path The twelve golden cases all target either a codeless EOA or bytecode that writes before it reads, and all twelve register the token in EVM-call mode. Two consensus-relevant behaviours were therefore invisible to them. `main_reads_uninitialized_memory` commits MSIZE and MLOAD(0) to storage from the transaction's own frame, before writing either. go-ethereum allocates a fresh `Memory` for every interpreter run, so both read zero and neither SSTORE changes state; a client whose fee frames leave their bytes on the transaction's shared memory writes two non-zero slots and misses the root. Verified to have teeth: reverting the fee frames to a checkpoint-zero `SharedMemory` fails it with a state root mismatch. `slot_deduct_keep` and `slot_deduct_clear` are the first coverage of the registry's direct-slot path, which has to keep working for replaying blocks produced before every mainnet token moved to the call path. `slot_deduct_clear` is byte-for-byte the transaction `deduct_clear` runs and costs 21_000 against its 16_800: clearing the payer's balance through a real `transfer` books a `+4800` SSTORE refund that reaches the transaction, while `SetState` books nothing. That 4_200 is the only way the two modes bill differently, and it is now pinned from both sides. Roots and logs hashes come from morph-geth 4012f174b, which reproduces all twelve existing cases unchanged. --- .../tests/fee_token_internal_calls.rs | 18 +- .../fixtures/fee_token_internal_calls.json | 264 ++++++++++++++++++ 2 files changed, 280 insertions(+), 2 deletions(-) diff --git a/bin/morph-statetest/tests/fee_token_internal_calls.rs b/bin/morph-statetest/tests/fee_token_internal_calls.rs index 1cf8b5c9..4318da7a 100644 --- a/bin/morph-statetest/tests/fee_token_internal_calls.rs +++ b/bin/morph-statetest/tests/fee_token_internal_calls.rs @@ -1,4 +1,6 @@ -//! Golden roots/logs generated by morph-geth 5744b8f66 (Emerald and Jade). +//! Golden roots/logs generated by morph-geth (Emerald and Jade): the original twelve +//! cases on 5744b8f66, the three later ones on 4012f174b, which differs only in the +//! transaction-size limit in `core/tx_pool.go` and reproduces all twelve unchanged. //! The gas constants are transaction totals; geth's statetest tool subtracts //! intrinsic gas when the total is at least the intrinsic cost. use morph_statetest::runner::run_suite_str; @@ -6,7 +8,7 @@ use morph_statetest::runner::run_suite_str; #[test] fn fee_token_calls_match_geth() { let outcomes = run_suite_str(include_str!("fixtures/fee_token_internal_calls.json")).unwrap(); - assert_eq!(outcomes.len(), 24); + assert_eq!(outcomes.len(), 30); for outcome in outcomes { assert!( outcome.pass, @@ -20,6 +22,18 @@ fn fee_token_calls_match_geth() { "main_restores_cleared_slot" => 23_291, "balance_writes" => 0, "zero_fee_reads_balance" => 23_574, + // The transaction's own frame reads MSIZE and MLOAD(0) into storage before + // writing either. go-ethereum allocates a fresh `Memory` per interpreter run, + // so it sees zeros and neither SSTORE changes state; a client whose fee frames + // leave their bytes on the transaction's memory writes two non-zero slots and + // misses this root by two SSTORE_SETs. + "main_reads_uninitialized_memory" => 25_417, + // The registry's direct-slot path. `slot_deduct_clear` is the same transaction + // as `deduct_clear`, which costs 16_800: clearing the payer's balance through a + // real `transfer` books a `+4800` SSTORE refund that reaches the transaction, + // while `SetState` books nothing. That 4_200 is the only way the two modes bill + // differently. + "slot_deduct_keep" | "slot_deduct_clear" => 21_000, "deduct_keep" | "origin_guard" | "gasprice_guard" diff --git a/bin/morph-statetest/tests/fixtures/fee_token_internal_calls.json b/bin/morph-statetest/tests/fixtures/fee_token_internal_calls.json index 03ccffec..c8ca1348 100644 --- a/bin/morph-statetest/tests/fixtures/fee_token_internal_calls.json +++ b/bin/morph-statetest/tests/fixtures/fee_token_internal_calls.json @@ -441,6 +441,94 @@ ] } }, + "main_reads_uninitialized_memory": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x000000000000000000000000a94f5374fce5edbc8e2a8697c15331677e6ebf0b": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x5960005560005160015500", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000000", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xb05c0ccb41d7ad9b13f5e8ac1b93f42dc8c8b8bbd312e0af5f91fa5525f47a49", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xb05c0ccb41d7ad9b13f5e8ac1b93f42dc8c8b8bbd312e0af5f91fa5525f47a49", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, "main_restores_cleared_slot": { "env": { "currentCoinbase": "0x530000000000000000000000000000000000000a", @@ -881,6 +969,182 @@ ] } }, + "slot_deduct_clear": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x9734b052146069605dcf2a05300c1dd5cd5852a2844e5491b2eb25d6daa909bc": "0x00000000000000000000000000000000000000000000000000000000000f4240" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x84e1019fa81970b8488a94f2651174f4b8d901e6d71c51a3f2d43bc1b2d2736f", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x84e1019fa81970b8488a94f2651174f4b8d901e6d71c51a3f2d43bc1b2d2736f", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, + "slot_deduct_keep": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x1c9c380", + "currentNumber": "0x1", + "currentTimestamp": "0x1", + "currentBaseFee": "0x1", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0xde0b6b3a7640000", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x3000000000000000000000000000000000000003": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x366044146013576004355460005260206000f35b60243580335403335560043580548201905550600160005260206000f3", + "storage": { + "0x9734b052146069605dcf2a05300c1dd5cd5852a2844e5491b2eb25d6daa909bc": "0x00000000000000000000000000000000000000000000000000000000000f4241" + } + }, + "0x4200000000000000000000000000000000000042": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x0000000000000000000000003000000000000000000000000000000000000003", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000002", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000001201", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x0", + "gasPrice": "0xa", + "gasLimit": [ + "0x186a0" + ], + "to": "0x4200000000000000000000000000000000000042", + "value": [ + "0x0" + ], + "data": [ + "0x" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Emerald": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xd4d266ff3372ea95e9fd510e2e3be04679fdb72b83671acf29222402bdb4e0e8", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ], + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0xd4d266ff3372ea95e9fd510e2e3be04679fdb72b83671acf29222402bdb4e0e8", + "logs": "0x1dcc4de8dec75d7aab85b567b6ccd41ad312451b948a7413f0a142fd40d49347" + } + ] + } + }, "zero_fee": { "env": { "currentCoinbase": "0x530000000000000000000000000000000000000a", From 41a07143b0980e9f6a18d0205b9ec376355fdc8a Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 16 Sep 2026 16:12:47 +0800 Subject: [PATCH 09/12] test(node): assert the fee logs survive a reverting main frame `receipt.rs` caches the fee `Transfer` events outside the journal because go-ethereum's `StateDB.logs` is not part of the state snapshot/revert mechanism: when the main frame reverts, the deduction's log must still be in the receipt. Nothing asserted that. Every reverting golden case used a token that emits no logs, so its expected `logs` hash is the empty hash and a client that dropped `pre_fee_logs` on the floor would produce the same value. The property decides the receipt's logs and therefore the block's receipts root. `morph_tx_v0_token_fee_still_charged_on_revert` already reverts the main frame against the real ERC20 test token and already runs through `MorphBlockExecutor` and the production receipt builder. Assert the two fee transfers it must carry, in go-ethereum's order, and that the deduction moved a non-zero fee. Verified to have teeth: not extending `pre_fee_logs` in `build_receipt` fails it. One comment described the code wrongly and is corrected: - The call-mode deduction comment said re-marking accounts and slots cold "reproduces the warmth `Prepare` would have left behind". It does not, and must not: the coinbase and access list are re-warmed later by upstream `pre_execution::load_accounts`, which runs after this deduction because the deduction happens in `validate()`. Say so, and say what breaks if a future change reorders those phases. `load_token_fee_info`'s claim that the old path "capped at `SYSTEM_CALL_GAS_LIMIT`, which is go-ethereum's 200k" reads wrong, because revm's `SYSTEM_CALL_GAS_LIMIT` is 30_000_000. It is right, though: this crate defines its own 200_000 in `exec.rs` and sets it in the `SystemCallEvm` impl, shadowing revm's. Name that shadowing, since the bare constant reads as a mistake and invites exactly the wrong "fix". `expectException` stays presence-only, which reads like an oversight. It is deliberate: go-ethereum's own statetest harness returns early on `len(ExpectException) > 0` under a standing "TODO check error string", so matching the text here would make this runner stricter than the client the fixtures come from. A comment now records that. --- bin/morph-statetest/src/runner.rs | 9 +++++++ crates/node/tests/it/morph_tx.rs | 45 +++++++++++++++++++++++++++++++ crates/revm/src/handler.rs | 21 +++++++++++---- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/bin/morph-statetest/src/runner.rs b/bin/morph-statetest/src/runner.rs index a3c650b9..1fed0dc6 100644 --- a/bin/morph-statetest/src/runner.rs +++ b/bin/morph-statetest/src/runner.rs @@ -246,6 +246,15 @@ fn validation_error( where E: std::fmt::Display, { + // `expectException` is checked for presence, deliberately not for its text. + // go-ethereum's own statetest harness does the same -- `tests/state_test.go` + // returns early on `len(ExpectException) > 0` under a standing + // "TODO check error string" -- so matching on the text here would make this + // runner stricter than the client the fixtures are generated from, and a + // fixture imported from go-ethereum could fail on wording alone. The string + // stays in the JSON as documentation of which failure the case is meant to + // provoke; the assertion is that the transaction is *rejected*, which is what + // both clients agree on. match (&test.expect_exception, exec_result) { (Some(_), Err(_)) => return None, (Some(expected), Ok(_)) => { diff --git a/crates/node/tests/it/morph_tx.rs b/crates/node/tests/it/morph_tx.rs index 8f96d3d1..7fe7d58d 100644 --- a/crates/node/tests/it/morph_tx.rs +++ b/crates/node/tests/it/morph_tx.rs @@ -631,10 +631,21 @@ const RUNTIME_REVERT_INIT: &[u8] = &[ /// 1. Block 1: Deploy a contract whose runtime always reverts (EIP-1559 tx) /// 2. Block 2: Call that contract with MorphTx v0 (ERC20 fee) /// 3. Verify: receipt.status = false, but token balance decreased +/// 4. Verify: the receipt still carries both fee `Transfer` events /// /// This exercises the handler's `validate_and_deduct_token_fee` (charges fee /// upfront) and `reimburse_caller_token_fee` (partial refund for unused gas) /// paths when the main transaction execution reverts. +/// +/// The log assertion is the point of running the fee path on a *reverting* main +/// frame. go-ethereum keeps `StateDB.logs` outside the state snapshot/revert +/// mechanism, so the deduction's `Transfer` survives a main-frame revert; that +/// is the entire reason morph-reth caches fee logs in `pre_fee_logs` / +/// `post_fee_logs` instead of leaving them in the journal (`crates/evm/src/block/receipt.rs`). +/// A regression there -- the fee logs dropped, or restored into the reverted +/// frame -- changes the receipt's logs and therefore the block's receipts root, +/// and no state assertion in this test would notice. This is the only test that +/// runs the production receipt builder against a reverting main frame. #[tokio::test(flavor = "multi_thread")] async fn morph_tx_v0_token_fee_still_charged_on_revert() -> eyre::Result<()> { reth_tracing::init_test_tracing(); @@ -711,6 +722,40 @@ async fn morph_tx_v0_token_fee_still_charged_on_revert() -> eyre::Result<()> { before={bal_before}, after={bal_after}" ); + // Both fee transfers must survive the main frame's revert: go-ethereum keeps + // `StateDB.logs` outside the state snapshot/revert mechanism, so the deduction's + // `Transfer` is still in the receipt while the reverted main frame contributes + // none. Order is go-ethereum's: deduction, (empty) main frame, reimbursement. + let fee_vault = morph_node::test_utils::TEST_FEE_VAULT_ADDRESS; + let transfer_topic = erc20_transfer_topic(); + let transfer_logs: Vec<_> = receipt + .logs() + .iter() + .filter(|log| log.address == token_addr && log.topics().first() == Some(&transfer_topic)) + .collect(); + assert_eq!( + transfer_logs.len(), + 2, + "receipt must carry the fee deduction and the fee reimbursement even though \ + the main frame reverted; dropping the deduction's log changes the receipts root. \ + got {transfer_logs:?}" + ); + assert_eq!( + (transfer_logs[0].topics()[1], transfer_logs[0].topics()[2]), + (address_topic(sender), address_topic(fee_vault)), + "first log must be the fee deduction (sender -> fee vault)" + ); + assert_ne!( + transfer_logs[0].data.data.as_ref(), + [0u8; 32], + "the deduction must move a non-zero fee" + ); + assert_eq!( + (transfer_logs[1].topics()[1], transfer_logs[1].topics()[2]), + (address_topic(fee_vault), address_topic(sender)), + "second log must be the fee reimbursement (fee vault -> sender)" + ); + // The receipt should carry MorphTx-specific fee fields match &receipt { morph_primitives::MorphReceipt::Morph(morph_receipt) => { diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 1cc85500..16631077 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -670,8 +670,17 @@ where // list and resets transient storage before the main transaction // (core/state/statedb.go:1066). `finalize()` is the nearest revm equivalent — it // commits the deduction's state and drops the journal, undo history, logs and - // transient storage — and re-marking every account and slot cold reproduces the - // warmth `Prepare` would have left behind. + // transient storage. + // + // `mark_cold` below only has to *drop* the warmth this frame's own CALL created; it + // does not restore what `Prepare` would have left warm, and must not try to. That + // warmth arrives later from upstream, which is why the two cannot be swapped: + // `run_without_catch_error` runs this deduction inside `validate()`, then + // `pre_execution()` → `pre_execution::load_accounts` re-warms the coinbase + // (EIP-3651) and the transaction's access list, and the nonce bump just below + // re-loads the caller. If a future change reorders those phases, a main frame that + // reads `COINBASE` would be charged 2600 instead of go-ethereum's 100; nothing here + // would catch it, because no fixture's main frame touches the coinbase. // // The `transaction_id` handling inside `finalize()` is load-bearing, not incidental. // Warming a slot goes through `EvmStorageSlot::mark_warm_with_transaction_id`, which @@ -941,9 +950,11 @@ where /// `system_call_one` — answers under `BlockEnv::default()` and `CfgEnv::default()`: block 0, /// timestamp 1, chain id 1, zero coinbase and base fee, with `SYSTEM_ADDRESS` as the sender. /// For any token whose `balanceOf` reads that context the two clients would charge different -/// fees for the same transaction. The gas budget was never the problem: `system_call_one` -/// capped at `SYSTEM_CALL_GAS_LIMIT`, which is go-ethereum's 200k, and so does -/// [`EVM_CALL_GAS_LIMIT`]. +/// fees for the same transaction. The gas budget was never the problem: this crate's +/// `system_call_one` set the limit to its own `SYSTEM_CALL_GAS_LIMIT` — `exec.rs`, 200_000, +/// which deliberately shadows revm's `SYSTEM_CALL_GAS_LIMIT` of 30_000_000 at the +/// `SystemCallEvm` impl — and that 200k is go-ethereum's `maxGas`. [`EVM_CALL_GAS_LIMIT`] +/// carries the same number forward. fn load_token_fee_info( evm: &mut MorphEvm, entry: TokenRegistryEntry, From 5274885b854a6ecacd63d68ee515634227be07d4 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 16 Sep 2026 18:00:01 +0800 Subject: [PATCH 10/12] refactor(revm): keep the fee-token helpers' invariants inside them Four cleanups from a review of this branch. None of them changes execution. `transfer_erc20_with_slot` needs the token account in `journal.state`, because the journal's `sload`/`sstore` panic rather than error when it is absent, and both callers loaded it themselves with a comment apiece saying why. The helper now loads and touches the token ahead of its checkpoint. That is exactly what the deduction did before. The refund's extra touch is a no-op: a refund only runs after a non-zero deduction, which already touched the token in the same transaction. The slot-path golden fixtures pass unchanged, and the unit test that exercises the helper no longer pre-loads the token. `reimburse_caller_token_fee` built its missing-cache error with `ok_or`, allocating the message on every token refund. It now uses `ok_or_else`. `TokenRegistryEntry`, its `load` and its `load_for_caller` had become `pub` and re-exported with no user outside this crate, and `load_for_caller` hands back a `TokenFeeInfo` without going through `ensure_usable`. They are `pub(crate)` again. The pool keeps using `TokenFeeInfo::load_for_caller`. The handler and token-fee tests each carried an identical database that fails storage reads of one token. A single copy now lives in the token-fee test module, which is `pub(crate)` so the handler tests can use it. --- crates/revm/src/handler.rs | 68 ++++++++---------------------------- crates/revm/src/lib.rs | 2 +- crates/revm/src/token_fee.rs | 44 +++++++++++++---------- 3 files changed, 40 insertions(+), 74 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 16631077..c2b04d81 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -427,11 +427,11 @@ where // This ensures the same price_ratio/scale is used for both deduction and reimbursement. // The cache is kept populated (not taken) so the block executor's receipt builder // can also read it without re-querying the DB. - let token_fee_info = - evm.cached_token_fee_info - .ok_or(MorphInvalidTransaction::TokenTransferFailed { - reason: "cached_token_fee_info not set by validate_and_deduct_token_fee".into(), - })?; + let token_fee_info = evm.cached_token_fee_info.ok_or_else(|| { + MorphInvalidTransaction::TokenTransferFailed { + reason: "cached_token_fee_info not set by validate_and_deduct_token_fee".into(), + } + })?; // Calculate token amount required for total fee let token_amount_required = token_fee_info.eth_to_token_amount(reimburse_eth); @@ -441,14 +441,6 @@ where // should not cause transaction to fail" (state_transition.go:698). let refund_result = if let Some(balance_slot) = token_fee_info.balance_slot { let journal = evm.ctx().journal_mut(); - // `transfer_erc20_with_slot` reaches the journal's `sload`/`sstore` directly, - // which panic rather than error when the account is absent from `journal.state`. - // The deduction loads it — but it skips both transfer modes for a zero fee, so - // this must not rely on that having happened. Today the two cannot disagree - // (`eth_to_token_amount` rounds up, so a zero token fee means a zero ETH fee, - // which returns above), which makes this load a no-op that keeps the invariant - // local to the code that needs it. - let _ = journal.load_account_mut(token_fee_info.token_address)?; transfer_erc20_with_slot( journal, beneficiary, @@ -615,11 +607,7 @@ where // still need their normal per-transaction updates. } else if let Some(balance_slot) = token_fee_info.balance_slot { // Transfer with token slot. - // Ensure token account is loaded into the journal state, because `sload`/`sstore` - // assume the account is present. let journal = evm.ctx_mut().journal_mut(); - let _ = journal.load_account_mut(token_fee_info.token_address)?; - journal.touch(token_fee_info.token_address); let (from_storage_slot, to_storage_slot) = transfer_erc20_with_slot( journal, caller_addr, @@ -756,6 +744,11 @@ where /// Performs an ERC20 balance transfer by directly `sload`/`sstore`-ing the token contract storage /// using the known `balance` mapping base slot, returning the computed storage slots for `from`/`to`. +/// +/// The token account is loaded and touched here, ahead of the checkpoint, rather than by the +/// callers. The journal's `sload`/`sstore` panic instead of erroring when the account is absent +/// from `journal.state`, and touching keeps the token among the transaction's state changes even +/// for a self-transfer that writes no slot, as go-ethereum's `SetState` still marks it dirty. #[inline] fn transfer_erc20_with_slot( journal: &mut revm::Journal, @@ -768,6 +761,8 @@ fn transfer_erc20_with_slot( where DB: alloy_evm::Database, { + let _ = journal.load_account_mut(token)?; + journal.touch(token); with_journal_checkpoint(journal, |journal| { // Sub amount (checked: reject if insufficient, matching go-ethereum's // changeAltTokenBalanceByState which returns an error on underflow) @@ -1156,6 +1151,7 @@ fn calculate_caller_fee_with_l1_cost( mod tests { use super::*; use crate::MorphTxEnv; + use crate::token_fee::tests::{TokenReadFailure, UnreadableTokenDb}; use crate::{ MorphBlockEnv, token_fee::{L2_TOKEN_REGISTRY_ADDRESS, compute_mapping_slot}, @@ -1175,40 +1171,6 @@ mod tests { atomic::{AtomicBool, Ordering}, }; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - struct TokenReadFailure; - impl core::fmt::Display for TokenReadFailure { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.write_str("injected refund balance read failure") - } - } - impl core::error::Error for TokenReadFailure {} - impl revm::database_interface::DBErrorMarker for TokenReadFailure {} - - #[derive(Debug)] - struct UnreadableTokenDb { - inner: CacheDB, - token: Address, - } - impl revm::Database for UnreadableTokenDb { - type Error = TokenReadFailure; - fn basic(&mut self, address: Address) -> Result, Self::Error> { - Ok(revm::Database::basic(&mut self.inner, address).unwrap()) - } - fn code_by_hash(&mut self, hash: B256) -> Result { - Ok(revm::Database::code_by_hash(&mut self.inner, hash).unwrap()) - } - fn storage(&mut self, address: Address, index: U256) -> Result { - if address == self.token { - return Err(TokenReadFailure); - } - Ok(revm::Database::storage(&mut self.inner, address, index).unwrap()) - } - fn block_hash(&mut self, number: u64) -> Result { - Ok(revm::Database::block_hash(&mut self.inner, number).unwrap()) - } - } - fn finish_transaction_with_refund( code: Bytes, ) -> Result, EVMError> @@ -1799,10 +1761,8 @@ mod tests { inner: BlockEnv::default(), }; + // Nothing loads the token first: the helper has to put it in `journal.state` itself. let journal = evm.ctx_mut().journal_mut(); - let _ = journal.load_account_mut(token).unwrap(); - journal.touch(token); - let err = transfer_erc20_with_slot(journal, from, to, token, U256::from(1), balance_slot) .unwrap_err(); diff --git a/crates/revm/src/lib.rs b/crates/revm/src/lib.rs index 5e420dda..a887c914 100644 --- a/crates/revm/src/lib.rs +++ b/crates/revm/src/lib.rs @@ -73,7 +73,7 @@ pub use l1block::{ }; pub use precompiles::MorphPrecompiles; pub use token_fee::{ - L2_TOKEN_REGISTRY_ADDRESS, MorphEvmEnv, TokenFeeInfo, TokenRegistryEntry, compute_mapping_slot, + L2_TOKEN_REGISTRY_ADDRESS, MorphEvmEnv, TokenFeeInfo, compute_mapping_slot, compute_mapping_slot_for_address, encode_balance_of_calldata, }; pub use tx::{MorphTxEnv, MorphTxExt}; diff --git a/crates/revm/src/token_fee.rs b/crates/revm/src/token_fee.rs index a448ba7d..7b1b6574 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -56,7 +56,7 @@ pub struct TokenFeeInfo { /// Fee-token registry metadata without any caller-specific balance state. #[derive(Clone, Copy, Debug)] -pub struct TokenRegistryEntry { +pub(crate) struct TokenRegistryEntry { token_address: Address, is_active: bool, decimals: u8, @@ -79,7 +79,10 @@ impl TokenRegistryEntry { } /// Load fee-token metadata without reading a caller's token balance. - pub fn load(db: &mut DB, token_id: u16) -> Result, DB::Error> { + pub(crate) fn load( + db: &mut DB, + token_id: u16, + ) -> Result, DB::Error> { read_registry_entry(db, token_id) } @@ -95,7 +98,7 @@ impl TokenRegistryEntry { } /// Resolve the caller's balance to produce complete fee information. - pub fn load_for_caller( + pub(crate) fn load_for_caller( self, db: &mut DB, caller: Address, @@ -367,7 +370,7 @@ pub fn encode_balance_of_calldata(account: Address) -> Bytes { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use alloy_primitives::{B256, address, bytes}; @@ -375,29 +378,32 @@ mod tests { use revm::database::{CacheDB, EmptyDB}; use revm::state::AccountInfo; - /// Returned by [`FeeTokenUnreadable`] so a state read failure is distinguishable. + /// The storage read failure injected by [`UnreadableTokenDb`], distinguishable from any + /// error a real database would report. Shared with the handler tests. #[derive(Debug, Clone, Copy, PartialEq, Eq)] - struct ReadFailed; + pub(crate) struct TokenReadFailure; - impl core::fmt::Display for ReadFailed { + impl core::fmt::Display for TokenReadFailure { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - f.write_str("state read failed") + f.write_str("injected token storage read failure") } } - impl core::error::Error for ReadFailed {} + impl core::error::Error for TokenReadFailure {} - impl revm::database_interface::DBErrorMarker for ReadFailed {} + impl revm::database_interface::DBErrorMarker for TokenReadFailure {} - /// Fails every storage read of the fee token; everything else reads normally. + /// Fails every storage read of `token` with [`TokenReadFailure`]; everything else reads + /// normally, so a failure a test observes comes from that token's storage. Shared with the + /// handler tests. #[derive(Debug)] - struct FeeTokenUnreadable { - inner: CacheDB, - token: Address, + pub(crate) struct UnreadableTokenDb { + pub(crate) inner: CacheDB, + pub(crate) token: Address, } - impl RevmDatabase for FeeTokenUnreadable { - type Error = ReadFailed; + impl RevmDatabase for UnreadableTokenDb { + type Error = TokenReadFailure; fn basic(&mut self, address: Address) -> Result, Self::Error> { Ok(self.inner.basic(address).unwrap()) @@ -409,7 +415,7 @@ mod tests { fn storage(&mut self, address: Address, index: U256) -> Result { if address == self.token { - return Err(ReadFailed); + return Err(TokenReadFailure); } Ok(self.inner.storage(address, index).unwrap()) } @@ -514,13 +520,13 @@ mod tests { // Same state, but the token's storage cannot be read. Reporting a zero balance here // would be indistinguishable from an account that genuinely cannot pay. - let mut unreadable = FeeTokenUnreadable { + let mut unreadable = UnreadableTokenDb { inner: call_mode_token_state(token, 10_000_000), token, }; assert_eq!( TokenFeeInfo::load_for_caller(&mut unreadable, 1, caller, &env).unwrap_err(), - EVMError::Database(ReadFailed) + EVMError::Database(TokenReadFailure) ); } From 6af740ef473bda16764ee309635fea53f4b81cd6 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 16 Sep 2026 18:00:01 +0800 Subject: [PATCH 11/12] docs(evm): give the real reason fee logs are kept out of the result The receipt builder said fee logs are cached apart from the journal because revm's `ExecutionResult::Revert` carries no logs. In revm 42 it does. The real reason is that the call-mode deduction runs a mid-transaction `finalize()` that clears the journal's logs, so the handler moves them out first, and it drains the refund's logs the same way. `result` then holds only the main frame's logs, which a revert has already discarded. The same wrong claim was corrected in `handler.rs` earlier on this branch; this is the copy that was left behind. --- crates/evm/src/block/receipt.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/evm/src/block/receipt.rs b/crates/evm/src/block/receipt.rs index 347c8265..4d791b3d 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -161,8 +161,12 @@ impl MorphReceiptBuilder for DefaultMorphReceiptBuilder { // Assemble logs in chronological order matching go-ethereum: // [deduct Transfer] + [main tx logs] + [refund Transfer] - // Fee logs are cached separately from the journal so they survive - // main tx revert (revm's ExecutionResult::Revert carries no logs). + // The fee logs cannot come from `result`. The call-mode deduction runs a + // mid-transaction `finalize()` that clears the journal's logs, so the handler + // moves them out first, and it drains the refund's logs the same way. `result` + // carries only the main frame's logs, which a revert has already discarded, + // while the fee logs survive it as they do in go-ethereum, whose `StateDB.logs` + // sit outside the snapshot/revert mechanism. let is_success = result.is_success(); let main_logs = result.into_logs(); let mut logs = From f89ba741b83c637207a802abe0c7d070b8b08e7d Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 16 Sep 2026 18:00:01 +0800 Subject: [PATCH 12/12] test(statetest): replay a mainnet slot-mode fee-token transaction Slot mode is being retired on mainnet, but blocks that already ran it must keep replaying identically, and only two synthetic golden cases exercised the registry's direct-slot path. The new case replays transaction 0x9ebfdac9040d7c2a8739ffdaae8baf5e7aa22fdb48585f80592de4b4cf39ed44 from block 26836567: a V0 MorphTx paying its fee in token 1 through the direct slot, sent by an EIP-7702-delegated account holding no ETH, whose call transfers that same token. One transaction covers the deduction, the main frame writing the payer's already-debited balance slot, and the slot-mode refund. go-ethereum's state-test runner only signs with `secretKey` and fixes the chain id to 1, so the sender moves to the harness account, carrying its nonce, delegation code and re-keyed token balance, and the fee vault's balance is re-keyed to the harness vault. The prestate tracer reports zero for the balance slots the fee logic reads straight from state, so those, the registry entry and the L1 gas price oracle slots are taken from the parent block. That is exact here because the transaction is alone in its block. Roots come from morph-geth 4012f174b, which passes the fixture, as does this runner. The gas used equals the on-chain receipt's 51_257, and the logs root equals the on-chain logs with the sender topic substituted. Verified to have teeth: swapping the slot-mode refund's direction fails it with a state root mismatch. --- .../tests/fee_token_internal_calls.rs | 29 ++++++ .../fixtures/mainnet_slot_mode_fee_token.json | 96 +++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 bin/morph-statetest/tests/fixtures/mainnet_slot_mode_fee_token.json diff --git a/bin/morph-statetest/tests/fee_token_internal_calls.rs b/bin/morph-statetest/tests/fee_token_internal_calls.rs index 4318da7a..16958bad 100644 --- a/bin/morph-statetest/tests/fee_token_internal_calls.rs +++ b/bin/morph-statetest/tests/fee_token_internal_calls.rs @@ -45,3 +45,32 @@ fn fee_token_calls_match_geth() { assert_eq!(outcome.gas_used, gas, "{} / {}", outcome.test, outcome.fork); } } + +/// Replays mainnet transaction `0x9ebfdac9040d7c2a8739ffdaae8baf5e7aa22fdb48585f80592de4b4cf39ed44` +/// (block 26836567, Jade): a MorphTx V0 that pays its fee in token 1, which the registry still +/// resolves through the direct-slot path, and whose call transfers that same token. The fixtures +/// above are synthetic; this is slot mode as mainnet actually ran it, which every node must keep +/// replaying identically after the registry moves off slot mode. +/// +/// go-ethereum's state-test runner signs with `secretKey` and fixes the chain id to 1, so the +/// sender is the harness account `0xa94f…6ebf0b`, carrying the real sender's nonce, EIP-7702 +/// delegation code and token balance under its own balance slot, and the fee vault's balance sits +/// under the harness vault. The prestate tracer does not see the slots the fee logic reads straight +/// from state, so the token balances, the registry entry and the L1 gas price oracle come from the +/// parent block, which is exact here because the transaction is alone in its block. Roots are from +/// morph-geth 4012f174b. The logs root equals the on-chain receipt's logs with the sender topic +/// substituted, and the gas matches the on-chain receipt: the substitution changes the L1 data fee, +/// and with it the token amount charged, but not the gas burned. +#[test] +fn mainnet_slot_mode_fee_token_transfer_matches_geth() { + let outcomes = + run_suite_str(include_str!("fixtures/mainnet_slot_mode_fee_token.json")).unwrap(); + assert_eq!(outcomes.len(), 1); + let outcome = &outcomes[0]; + assert!( + outcome.pass, + "{} / {}: {}", + outcome.test, outcome.fork, outcome.error_msg + ); + assert_eq!(outcome.gas_used, 51_257); +} diff --git a/bin/morph-statetest/tests/fixtures/mainnet_slot_mode_fee_token.json b/bin/morph-statetest/tests/fixtures/mainnet_slot_mode_fee_token.json new file mode 100644 index 00000000..30868ac0 --- /dev/null +++ b/bin/morph-statetest/tests/fixtures/mainnet_slot_mode_fee_token.json @@ -0,0 +1,96 @@ +{ + "mainnet_26836567_slot_mode_fee_token_transfer": { + "env": { + "currentCoinbase": "0x530000000000000000000000000000000000000a", + "currentDifficulty": "0x0", + "currentGasLimit": "0x2aea540", + "currentNumber": "0x1997e57", + "currentTimestamp": "0x6aa9b017", + "currentBaseFee": "0xf4240", + "currentChainID": "0x1" + }, + "pre": { + "0xa94f5374fce5edbc8e2a8697c15331677e6ebf0b": { + "balance": "0x0", + "nonce": "0x1", + "code": "0xef0100a845c74344fc9405b1fcf712f04668979573c1bf", + "storage": {} + }, + "0xc7d67a9cbb121b3b0b9c053dd9f469523243379a": { + "balance": "0x0", + "nonce": "0x1", + "code": "0x363d3d373d3d3d363d73530000000000000000000000000000000000000d5af43d82803e903d91602b57fd5bf3", + "storage": { + "0x079b50c9ea8b3523e07d83295bfee8283b220c82526d7e046695778db0093d05": "0x00000000000000000000000000000000000000000000000000000000002d4c65", + "0x660c1bc2d47d7d811c3cf3324a889eebebd8edca5e38a70b3ba2905b38e251d3": "0x000000000000000000000000000000000000000000000000000000000011c927" + } + }, + "0x530000000000000000000000000000000000000d": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x608060405234801561000f575f80fd5b506004361061016e575f3560e01c806370a08231116100d25780639dc29fac11610088578063c820f14611610063578063c820f14614610354578063d505accf14610367578063dd62ed3e1461037a575f80fd5b80639dc29fac1461031b578063a457c2d71461032e578063a9059cbb14610341575f80fd5b80637ecebe00116100b85780637ecebe00146102e557806384b0196e146102f857806395d89b4114610313575f80fd5b806370a0823114610290578063797594b0146102c5575f80fd5b8063313ce56711610127578063395093511161010d57806339509351146102555780634000aea01461026857806340c10f191461027b575f80fd5b8063313ce5671461021d5780633644e5151461024d575f80fd5b8063116191b611610157578063116191b6146101b357806318160ddd146101f857806323b872dd1461020a575f80fd5b806306fdde0314610172578063095ea7b314610190575b5f80fd5b61017a6103bf565b60405161018791906119ce565b60405180910390f35b6101a361019e366004611a0f565b61044f565b6040519015158152602001610187565b60cc546101d39073ffffffffffffffffffffffffffffffffffffffff1681565b60405173ffffffffffffffffffffffffffffffffffffffff9091168152602001610187565b6035545b604051908152602001610187565b6101a3610218366004611a37565b610468565b60cd5474010000000000000000000000000000000000000000900460ff1660405160ff9091168152602001610187565b6101fc61048b565b6101a3610263366004611a0f565b610499565b6101a3610276366004611a70565b6104e4565b61028e610289366004611a0f565b61054d565b005b6101fc61029e366004611af0565b73ffffffffffffffffffffffffffffffffffffffff165f9081526033602052604090205490565b60cd546101d39073ffffffffffffffffffffffffffffffffffffffff1681565b6101fc6102f3366004611af0565b6105c7565b6103006105f1565b6040516101879796959493929190611b09565b61017a6106ae565b61028e610329366004611a0f565b6106bd565b6101a361033c366004611a0f565b61072e565b6101a361034f366004611a0f565b6107e4565b61028e610362366004611cad565b6107f1565b61028e610375366004611d3d565b610a08565b6101fc610388366004611da2565b73ffffffffffffffffffffffffffffffffffffffff9182165f90815260346020908152604080832093909416825291909152205490565b6060603680546103ce90611dd3565b80601f01602080910402602001604051908101604052809291908181526020018280546103fa90611dd3565b80156104455780601f1061041c57610100808354040283529160200191610445565b820191905f5260205f20905b81548152906001019060200180831161042857829003601f168201915b5050505050905090565b5f3361045c818585610b90565b60019150505b92915050565b5f33610475858285610d0f565b610480858585610dcb565b506001949350505050565b5f610494610ff1565b905090565b335f81815260346020908152604080832073ffffffffffffffffffffffffffffffffffffffff8716845290915281205490919061045c90829086906104df908790611e1e565b610b90565b5f6104ef85856107e4565b5073ffffffffffffffffffffffffffffffffffffffff85163b1561048057610480858585858080601f0160208091040260200160405190810160405280939291908181526020018383808284375f92019190915250610ffa92505050565b60cc5473ffffffffffffffffffffffffffffffffffffffff1633146105b95760405162461bcd60e51b815260206004820152600c60248201527f4f6e6c792047617465776179000000000000000000000000000000000000000060448201526064015b60405180910390fd5b6105c38282611085565b5050565b73ffffffffffffffffffffffffffffffffffffffff81165f90815260996020526040812054610462565b5f6060805f805f60606065545f801b14801561060d5750606654155b6106595760405162461bcd60e51b815260206004820152601560248201527f4549503731323a20556e696e697469616c697a6564000000000000000000000060448201526064016105b0565b61066161115e565b61066961116d565b604080515f808252602082019092527f0f000000000000000000000000000000000000000000000000000000000000009b939a50919850469750309650945092509050565b6060603780546103ce90611dd3565b60cc5473ffffffffffffffffffffffffffffffffffffffff1633146107245760405162461bcd60e51b815260206004820152600c60248201527f4f6e6c792047617465776179000000000000000000000000000000000000000060448201526064016105b0565b6105c3828261117c565b335f81815260346020908152604080832073ffffffffffffffffffffffffffffffffffffffff87168452909152812054909190838110156107d75760405162461bcd60e51b815260206004820152602560248201527f45524332303a2064656372656173656420616c6c6f77616e63652062656c6f7760448201527f207a65726f00000000000000000000000000000000000000000000000000000060648201526084016105b0565b6104808286868403610b90565b5f3361045c818585610dcb565b5f54610100900460ff161580801561080f57505f54600160ff909116105b806108285750303b15801561082857505f5460ff166001145b61089a5760405162461bcd60e51b815260206004820152602e60248201527f496e697469616c697a61626c653a20636f6e747261637420697320616c72656160448201527f647920696e697469616c697a656400000000000000000000000000000000000060648201526084016105b0565b5f80547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff0016600117905580156108f6575f80547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00ff166101001790555b6108ff86611309565b61090986866113c7565b60cd805460cc805473ffffffffffffffffffffffffffffffffffffffff8088167fffffffffffffffffffffffff00000000000000000000000000000000000000009283161790925590851660ff88167401000000000000000000000000000000000000000002919091167fffffffffffffffffffffff000000000000000000000000000000000000000000909216919091171790558015610a00575f80547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff00ff169055604051600181527f7f26b83ff96e1f2b6a682f133852f6798a09c465da95921460cefb38474024989060200160405180910390a15b505050505050565b83421115610a585760405162461bcd60e51b815260206004820152601d60248201527f45524332305065726d69743a206578706972656420646561646c696e6500000060448201526064016105b0565b5f7f6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9888888610a868c61144d565b60408051602081019690965273ffffffffffffffffffffffffffffffffffffffff94851690860152929091166060840152608083015260a082015260c0810186905260e0016040516020818303038152906040528051906020012090505f610aed82611481565b90505f610afc828787876114c8565b90508973ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614610b795760405162461bcd60e51b815260206004820152601e60248201527f45524332305065726d69743a20696e76616c6964207369676e6174757265000060448201526064016105b0565b610b848a8a8a610b90565b50505050505050505050565b73ffffffffffffffffffffffffffffffffffffffff8316610c185760405162461bcd60e51b8152602060048201526024808201527f45524332303a20617070726f76652066726f6d20746865207a65726f2061646460448201527f726573730000000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff8216610ca15760405162461bcd60e51b815260206004820152602260248201527f45524332303a20617070726f766520746f20746865207a65726f20616464726560448201527f737300000000000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff8381165f8181526034602090815260408083209487168084529482529182902085905590518481527f8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b92591015b60405180910390a3505050565b73ffffffffffffffffffffffffffffffffffffffff8381165f908152603460209081526040808320938616835292905220547fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8114610dc55781811015610db85760405162461bcd60e51b815260206004820152601d60248201527f45524332303a20696e73756666696369656e7420616c6c6f77616e636500000060448201526064016105b0565b610dc58484848403610b90565b50505050565b73ffffffffffffffffffffffffffffffffffffffff8316610e545760405162461bcd60e51b815260206004820152602560248201527f45524332303a207472616e736665722066726f6d20746865207a65726f20616460448201527f647265737300000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff8216610edd5760405162461bcd60e51b815260206004820152602360248201527f45524332303a207472616e7366657220746f20746865207a65726f206164647260448201527f657373000000000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff83165f9081526033602052604090205481811015610f785760405162461bcd60e51b815260206004820152602660248201527f45524332303a207472616e7366657220616d6f756e742065786365656473206260448201527f616c616e6365000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff8085165f8181526033602052604080822086860390559286168082529083902080548601905591517fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef90610fe49086815260200190565b60405180910390a3610dc5565b5f6104946114ee565b6040517fa4c0ed36000000000000000000000000000000000000000000000000000000008152839073ffffffffffffffffffffffffffffffffffffffff82169063a4c0ed369061105290339087908790600401611e56565b5f604051808303815f87803b158015611069575f80fd5b505af115801561107b573d5f803e3d5ffd5b5050505050505050565b73ffffffffffffffffffffffffffffffffffffffff82166110e85760405162461bcd60e51b815260206004820152601f60248201527f45524332303a206d696e7420746f20746865207a65726f20616464726573730060448201526064016105b0565b8060355f8282546110f99190611e1e565b909155505073ffffffffffffffffffffffffffffffffffffffff82165f818152603360209081526040808320805486019055518481527fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef910160405180910390a35050565b6060606780546103ce90611dd3565b6060606880546103ce90611dd3565b73ffffffffffffffffffffffffffffffffffffffff82166112055760405162461bcd60e51b815260206004820152602160248201527f45524332303a206275726e2066726f6d20746865207a65726f2061646472657360448201527f730000000000000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff82165f90815260336020526040902054818110156112a05760405162461bcd60e51b815260206004820152602260248201527f45524332303a206275726e20616d6f756e7420657863656564732062616c616e60448201527f636500000000000000000000000000000000000000000000000000000000000060648201526084016105b0565b73ffffffffffffffffffffffffffffffffffffffff83165f8181526033602090815260408083208686039055603580548790039055518581529192917fddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef9101610d02565b505050565b5f54610100900460ff166113855760405162461bcd60e51b815260206004820152602b60248201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960448201527f6e697469616c697a696e6700000000000000000000000000000000000000000060648201526084016105b0565b6113c4816040518060400160405280600181526020017f3100000000000000000000000000000000000000000000000000000000000000815250611561565b50565b5f54610100900460ff166114435760405162461bcd60e51b815260206004820152602b60248201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960448201527f6e697469616c697a696e6700000000000000000000000000000000000000000060648201526084016105b0565b6105c38282611604565b73ffffffffffffffffffffffffffffffffffffffff81165f9081526099602052604090208054600181018255905b50919050565b5f61046261148d610ff1565b836040517f19010000000000000000000000000000000000000000000000000000000000008152600281019290925260228201526042902090565b5f805f6114d787878787611699565b915091506114e481611781565b5095945050505050565b5f7f8b73c3c69bb8fe3d512ecc4cf759cc79239f7b179b0ffacaa9a75d522b39400f6115186118e5565b61152061193d565b60408051602081019490945283019190915260608201524660808201523060a082015260c00160405160208183030381529060405280519060200120905090565b5f54610100900460ff166115dd5760405162461bcd60e51b815260206004820152602b60248201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960448201527f6e697469616c697a696e6700000000000000000000000000000000000000000060648201526084016105b0565b60676115e98382611ede565b5060686115f68282611ede565b50505f606581905560665550565b5f54610100900460ff166116805760405162461bcd60e51b815260206004820152602b60248201527f496e697469616c697a61626c653a20636f6e7472616374206973206e6f74206960448201527f6e697469616c697a696e6700000000000000000000000000000000000000000060648201526084016105b0565b603661168c8382611ede565b5060376113048282611ede565b5f807f7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a08311156116ce57505f90506003611778565b604080515f8082526020820180845289905260ff881692820192909252606081018690526080810185905260019060a0016020604051602081039080840390855afa15801561171f573d5f803e3d5ffd5b50506040517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0015191505073ffffffffffffffffffffffffffffffffffffffff8116611772575f60019250925050611778565b91505f90505b94509492505050565b5f81600481111561179457611794611ff6565b0361179c5750565b60018160048111156117b0576117b0611ff6565b036117fd5760405162461bcd60e51b815260206004820152601860248201527f45434453413a20696e76616c6964207369676e6174757265000000000000000060448201526064016105b0565b600281600481111561181157611811611ff6565b0361185e5760405162461bcd60e51b815260206004820152601f60248201527f45434453413a20696e76616c6964207369676e6174757265206c656e6774680060448201526064016105b0565b600381600481111561187257611872611ff6565b036113c45760405162461bcd60e51b815260206004820152602260248201527f45434453413a20696e76616c6964207369676e6174757265202773272076616c60448201527f756500000000000000000000000000000000000000000000000000000000000060648201526084016105b0565b5f806118ef61115e565b805190915015611906578051602090910120919050565b60655480156119155792915050565b7fc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a4709250505090565b5f8061194761116d565b80519091501561195e578051602090910120919050565b60665480156119155792915050565b5f81518084525f5b8181101561199157602081850181015186830182015201611975565b505f6020828601015260207fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f83011685010191505092915050565b602081525f6119e0602083018461196d565b9392505050565b803573ffffffffffffffffffffffffffffffffffffffff81168114611a0a575f80fd5b919050565b5f8060408385031215611a20575f80fd5b611a29836119e7565b946020939093013593505050565b5f805f60608486031215611a49575f80fd5b611a52846119e7565b9250611a60602085016119e7565b9150604084013590509250925092565b5f805f8060608587031215611a83575f80fd5b611a8c856119e7565b935060208501359250604085013567ffffffffffffffff80821115611aaf575f80fd5b818701915087601f830112611ac2575f80fd5b813581811115611ad0575f80fd5b886020828501011115611ae1575f80fd5b95989497505060200194505050565b5f60208284031215611b00575f80fd5b6119e0826119e7565b7fff00000000000000000000000000000000000000000000000000000000000000881681525f602060e06020840152611b4560e084018a61196d565b8381036040850152611b57818a61196d565b6060850189905273ffffffffffffffffffffffffffffffffffffffff8816608086015260a0850187905284810360c0860152855180825260208088019350909101905f5b81811015611bb757835183529284019291840191600101611b9b565b50909c9b505050505050505050505050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52604160045260245ffd5b5f82601f830112611c05575f80fd5b813567ffffffffffffffff80821115611c2057611c20611bc9565b604051601f83017fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0908116603f01168101908282118183101715611c6657611c66611bc9565b81604052838152866020858801011115611c7e575f80fd5b836020870160208301375f602085830101528094505050505092915050565b803560ff81168114611a0a575f80fd5b5f805f805f60a08688031215611cc1575f80fd5b853567ffffffffffffffff80821115611cd8575f80fd5b611ce489838a01611bf6565b96506020880135915080821115611cf9575f80fd5b50611d0688828901611bf6565b945050611d1560408701611c9d565b9250611d23606087016119e7565b9150611d31608087016119e7565b90509295509295909350565b5f805f805f805f60e0888a031215611d53575f80fd5b611d5c886119e7565b9650611d6a602089016119e7565b95506040880135945060608801359350611d8660808901611c9d565b925060a0880135915060c0880135905092959891949750929550565b5f8060408385031215611db3575f80fd5b611dbc836119e7565b9150611dca602084016119e7565b90509250929050565b600181811c90821680611de757607f821691505b60208210810361147b577f4e487b71000000000000000000000000000000000000000000000000000000005f52602260045260245ffd5b80820180821115610462577f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b73ffffffffffffffffffffffffffffffffffffffff84168152826020820152606060408201525f611e8a606083018461196d565b95945050505050565b601f82111561130457805f5260205f20601f840160051c81016020851015611eb85750805b601f840160051c820191505b81811015611ed7575f8155600101611ec4565b5050505050565b815167ffffffffffffffff811115611ef857611ef8611bc9565b611f0c81611f068454611dd3565b84611e93565b602080601f831160018114611f5e575f8415611f285750858301515b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff600386901b1c1916600185901b178555610a00565b5f858152602081207fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08616915b82811015611faa57888601518255948401946001909101908401611f8b565b5085821015611fe657878501517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff600388901b60f8161c191681555b5050505050600190811b01905550565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52602160045260245ffdfea164736f6c6343000818000a", + "storage": {} + }, + "0x5300000000000000000000000000000000000021": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706d": "0x000000000000000000000000c7d67a9cbb121b3b0b9c053dd9f469523243379a", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706e": "0x0000000000000000000000000000000000000000000000000000000000000034", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e637706f": "0x0000000000000000000000000000000000000000000000000000000000000601", + "0x53bdca72fa8d2e145a1b3bd11cde5bd75428acd18eac3d6adf4e06e7e6377070": "0x00000000000000000000000000000000000000000000000000000000000f4240", + "0xbb86fbc034f4e382929974bcd8419ed626b0ea647f962d89ba2fb6bd28785ab9": "0x00000000000000000000000000000000000000000000000000017969711b4fae" + } + }, + "0x530000000000000000000000000000000000000f": { + "balance": "0x0", + "nonce": "0x0", + "code": "0x", + "storage": { + "0x0000000000000000000000000000000000000000000000000000000000000000": "0x000000000000000000000000f4a82c4ab398771c6c7a0c0dedf0b34fc0161192", + "0x0000000000000000000000000000000000000000000000000000000000000001": "0x000000000000000000000000000000000000000000000000000000000c669869", + "0x0000000000000000000000000000000000000000000000000000000000000002": "0x00000000000000000000000000000000000000000000000000000000000009c4", + "0x0000000000000000000000000000000000000000000000000000000000000003": "0x000000000000000000000000000000000000000000000000000000003b9aca00", + "0x0000000000000000000000000000000000000000000000000000000000000004": "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000006": "0x00000000000000000000000000000000000000000000000000000000005cc6c6", + "0x0000000000000000000000000000000000000000000000000000000000000007": "0x000000000000000000000000000000000000000000000000000001e6a77be7ac", + "0x0000000000000000000000000000000000000000000000000000000000000008": "0x0000000000000000000000000000000000000000000000000000000034faa1da", + "0x0000000000000000000000000000000000000000000000000000000000000009": "0x0000000000000000000000000000000000000000000000000000000000000001" + } + } + }, + "transaction": { + "type": "0x7f", + "version": "0x0", + "feeTokenID": "0x1", + "feeLimit": "0x0", + "nonce": "0x1", + "gasPrice": "0xf4240", + "gasLimit": [ + "0x181b1" + ], + "to": "0xc7d67a9cbb121b3b0b9c053dd9f469523243379a", + "value": [ + "0x0" + ], + "data": [ + "0xa9059cbb0000000000000000000000007ef77f9a8cbf84d1becf98b7768d49c7b4fef6ed00000000000000000000000000000000000000000000000000000000002d2a80" + ], + "secretKey": "0x45a915e4d060149eb4365960e6a7a45f334393093061116b197e3240065ff2d8" + }, + "post": { + "Jade": [ + { + "indexes": { + "data": 0, + "gas": 0, + "value": 0 + }, + "hash": "0x78bee0ec231c3fc7b87ebd7ec48c3d3c61a35338749c8727315fd2c8507daaf1", + "logs": "0x735e75fcb5f5511a4bf02e5f79e7b123630ef4b1b7208a71bf2eaf451221bd59" + } + ] + } + } +}