From 5480095ec3f0e18a351d9e1547ee45ab04bc1b2a Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Tue, 10 Mar 2026 22:48:56 +0800 Subject: [PATCH 1/4] fix(revm): restore original_value after token fee reimbursement --- crates/revm/src/handler.rs | 190 ++++++++++++++++++++++++++++++++++++- 1 file changed, 186 insertions(+), 4 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index cdb6c92..ccb31d0 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -414,12 +414,10 @@ where // Calculate token amount required for total fee let token_amount_required = token_fee_info.eth_to_token_amount(reimburse_eth); - // Get mutable access to journal components - let journal = evm.ctx().journal_mut(); - if let Some(balance_slot) = token_fee_info.balance_slot { // Transfer with token slot. - let _ = transfer_erc20_with_slot( + let (_, tx, _, journal, _, _) = evm.ctx().all_mut(); + let (from_storage_slot, to_storage_slot) = transfer_erc20_with_slot( journal, beneficiary, caller, @@ -427,6 +425,12 @@ where token_amount_required, balance_slot, )?; + restore_saved_original_values( + tx, + &mut journal.state, + token_fee_info.token_address, + [from_storage_slot, to_storage_slot], + ); } else { // Transfer with evm call. transfer_erc20_with_evm( @@ -614,6 +618,26 @@ where } } +#[inline] +fn restore_saved_original_values( + tx: &crate::MorphTxEnv, + state: &mut revm::state::EvmState, + address: Address, + slots: impl IntoIterator, +) { + slots.into_iter().for_each(|slot_key| { + if let Some(&(_, _, original_db_value)) = tx + .fee_slot_original_values + .iter() + .find(|(saved_address, saved_slot, _)| *saved_address == address && *saved_slot == slot_key) + && let Some(account) = state.get_mut(&address) + && let Some(slot) = account.storage.get_mut(&slot_key) + { + slot.original_value = original_db_value; + } + }); +} + /// 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`. fn transfer_erc20_with_slot( @@ -755,3 +779,161 @@ fn calculate_caller_fee_with_l1_cost( Ok(new_balance) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + MorphBlockEnv, MorphTxEnv, compute_mapping_slot, token_fee::L2_TOKEN_REGISTRY_ADDRESS, + }; + use alloy_primitives::address; + use morph_chainspec::hardfork::MorphHardfork; + use revm::{ + database::{CacheDB, EmptyDB}, + inspector::NoOpInspector, + state::AccountInfo, + }; + + fn insert_registry_entry( + db: &mut CacheDB, + token_id: u16, + token: Address, + balance_slot: U256, + ) { + let mut token_id_bytes = [0u8; 32]; + token_id_bytes[30..32].copy_from_slice(&token_id.to_be_bytes()); + let base = compute_mapping_slot(U256::from(151), token_id_bytes.to_vec()); + let price_ratio_slot = compute_mapping_slot(U256::from(153), token_id_bytes.to_vec()); + + let mut token_word = [0u8; 32]; + token_word[12..32].copy_from_slice(token.as_slice()); + + let mut active_and_decimals = [0u8; 32]; + active_and_decimals[30] = 8; + active_and_decimals[31] = 1; + + db.insert_account_info(L2_TOKEN_REGISTRY_ADDRESS, AccountInfo::default()); + db.insert_account_storage( + L2_TOKEN_REGISTRY_ADDRESS, + base, + U256::from_be_bytes(token_word), + ) + .unwrap(); + db.insert_account_storage( + L2_TOKEN_REGISTRY_ADDRESS, + base + U256::from(1), + balance_slot + U256::from(1), + ) + .unwrap(); + db.insert_account_storage( + L2_TOKEN_REGISTRY_ADDRESS, + base + U256::from(2), + U256::from_be_bytes(active_and_decimals), + ) + .unwrap(); + db.insert_account_storage( + L2_TOKEN_REGISTRY_ADDRESS, + base + U256::from(3), + U256::from(1), + ) + .unwrap(); + db.insert_account_storage(L2_TOKEN_REGISTRY_ADDRESS, price_ratio_slot, U256::from(1)) + .unwrap(); + } + + #[test] + fn reimburse_token_fee_preserves_original_values_for_touched_slots() { + let caller = address!("1000000000000000000000000000000000000001"); + let beneficiary = address!("2000000000000000000000000000000000000002"); + let token = address!("3000000000000000000000000000000000000003"); + let token_id = 5; + let balance_slot = U256::from(7); + let caller_slot = compute_mapping_slot_for_address(balance_slot, caller); + let beneficiary_slot = compute_mapping_slot_for_address(balance_slot, beneficiary); + + let caller_balance = U256::from(100); + let beneficiary_balance = U256::from(50); + let deducted = U256::from(10); + let refunded = U256::from(4); + + let mut db = CacheDB::new(EmptyDB::default()); + insert_registry_entry(&mut db, token_id, token, balance_slot); + db.insert_account_info(token, AccountInfo::default()); + db.insert_account_storage(token, caller_slot, caller_balance) + .unwrap(); + db.insert_account_storage(token, beneficiary_slot, beneficiary_balance) + .unwrap(); + + let mut evm = MorphEvm::new(MorphContext::new(db, MorphHardfork::default()), NoOpInspector); + evm.tx = MorphTxEnv { + inner: revm::context::TxEnv { + caller, + gas_price: 1, + gas_limit: 100_000, + ..Default::default() + }, + fee_token_id: Some(token_id), + fee_slot_original_values: vec![ + (token, caller_slot, caller_balance), + (token, beneficiary_slot, beneficiary_balance), + ], + ..Default::default() + }; + evm.block = MorphBlockEnv { + inner: revm::context::BlockEnv { + beneficiary, + ..Default::default() + }, + }; + + { + let journal = evm.ctx().journal_mut(); + let _ = journal.load_account_mut(token).unwrap(); + journal.touch(token); + let (from_storage_slot, to_storage_slot) = transfer_erc20_with_slot( + journal, + caller, + beneficiary, + token, + deducted, + balance_slot, + ) + .unwrap(); + + let token_account = journal.state.get_mut(&token).unwrap(); + token_account.mark_cold(); + token_account + .storage + .get_mut(&from_storage_slot) + .unwrap() + .mark_cold(); + token_account + .storage + .get_mut(&to_storage_slot) + .unwrap() + .mark_cold(); + } + + let mut gas = Gas::new(10); + gas.set_spent(6); + + MorphEvmHandler::, NoOpInspector>::new() + .reimburse_caller_token_fee(&mut evm, &gas, token_id) + .unwrap(); + + let token_account = evm.ctx_ref().journal().state.get(&token).unwrap(); + let caller_slot_state = token_account.storage.get(&caller_slot).unwrap(); + let beneficiary_slot_state = token_account.storage.get(&beneficiary_slot).unwrap(); + + assert_eq!(caller_slot_state.original_value, caller_balance); + assert_eq!( + caller_slot_state.present_value, + caller_balance - deducted + refunded + ); + assert_eq!(beneficiary_slot_state.original_value, beneficiary_balance); + assert_eq!( + beneficiary_slot_state.present_value, + beneficiary_balance + deducted - refunded + ); + } +} From c61633f74c4da1530e4a1d8ee14b07c8383e135e Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Tue, 10 Mar 2026 23:00:54 +0800 Subject: [PATCH 2/4] fix(revm): track forced-cold slot originals in tx runtime --- crates/evm/src/evm.rs | 54 ++- crates/revm/src/evm.rs | 311 ++++++++++----- crates/revm/src/handler.rs | 751 +++++++++++++++++++++++++------------ crates/revm/src/l1block.rs | 56 +-- crates/revm/src/lib.rs | 4 + crates/revm/src/runtime.rs | 75 ++++ crates/revm/src/tx.rs | 56 +-- 7 files changed, 874 insertions(+), 433 deletions(-) create mode 100644 crates/revm/src/runtime.rs diff --git a/crates/evm/src/evm.rs b/crates/evm/src/evm.rs index 4a873ef..4c64400 100644 --- a/crates/evm/src/evm.rs +++ b/crates/evm/src/evm.rs @@ -7,11 +7,9 @@ use alloy_evm::{ inspector::NoOpInspector, }, }; -use alloy_primitives::{Address, Bytes, Log}; +use alloy_primitives::{Address, Bytes}; use morph_chainspec::hardfork::MorphHardfork; -use morph_revm::{ - MorphHaltReason, MorphInvalidTransaction, MorphPrecompiles, MorphTxEnv, evm::MorphContext, -}; +use morph_revm::{MorphHaltReason, MorphInvalidTransaction, MorphTxEnv, evm::MorphContext}; use reth_revm::MainContext; use std::ops::{Deref, DerefMut}; @@ -66,19 +64,21 @@ pub struct MorphEvm { impl MorphEvm { /// Create a new [`MorphEvm`] instance. pub fn new(db: DB, input: EvmEnv) -> Self { - let spec = input.cfg_env.spec; let ctx = Context::mainnet() .with_db(db) .with_block(input.block_env) .with_cfg(input.cfg_env) - .with_tx(Default::default()); + .with_tx(Default::default()) + .with_chain(morph_revm::MorphTxRuntime::default()); - // Create precompiles for the hardfork and wrap in PrecompilesMap - let morph_precompiles = MorphPrecompiles::new_with_spec(spec); - let precompiles_map = PrecompilesMap::from_static(morph_precompiles.precompiles()); + // 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 precompiles_map = PrecompilesMap::from_static(inner.precompiles.precompiles()); Self { - inner: morph_revm::MorphEvm::new(ctx, NoOpInspector {}), + inner, precompiles_map, inspect: false, } @@ -105,16 +105,34 @@ impl MorphEvm { } } - /// Takes the inner EVM's revert logs. - /// - /// This is used as a work around to allow logs to be - /// included for reverting transactions. + /// Returns the cached token fee info from the handler's validation phase. /// - /// TODO: remove once revm supports emitting logs for reverted transactions + /// Avoids redundant DB reads when the block executor needs token fee + /// parameters (e.g., for receipt construction). + #[inline] + pub fn cached_token_fee_info(&self) -> Option { + self.inner.cached_token_fee_info() + } + + /// Returns the L1 data fee cached during handler validation. /// - /// - pub fn take_revert_logs(&mut self) -> Vec { - std::mem::take(&mut self.inner.logs) + /// Avoids re-encoding the full transaction RLP in the block executor's + /// receipt-building path. + #[inline] + pub fn cached_l1_data_fee(&self) -> alloy_primitives::U256 { + self.inner.cached_l1_data_fee() + } + + /// Takes the cached pre-execution fee logs (token fee deduction Transfer events). + #[inline] + pub fn take_pre_fee_logs(&mut self) -> Vec { + self.inner.take_pre_fee_logs() + } + + /// Takes the cached post-execution fee logs (token fee reimbursement Transfer events). + #[inline] + pub fn take_post_fee_logs(&mut self) -> Vec { + self.inner.take_post_fee_logs() } } diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index 4a311f5..04dfe3c 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -1,24 +1,37 @@ -use crate::{MorphBlockEnv, MorphTxEnv, precompiles::MorphPrecompiles}; +use crate::{ + MorphBlockEnv, MorphTxEnv, precompiles::MorphPrecompiles, runtime::MorphTxRuntime, + token_fee::TokenFeeInfo, +}; use alloy_evm::Database; -use alloy_primitives::{Log, U256, keccak256}; +use alloy_primitives::{U256, keccak256}; use morph_chainspec::hardfork::MorphHardfork; use revm::{ Context, Inspector, - context::{CfgEnv, ContextError, Evm, FrameStack}, - context_interface::{cfg::gas::BLOCKHASH, host::LoadError}, + context::{CfgEnv, ContextError, Evm, FrameStack, Journal}, + context_interface::{ + cfg::gas::{BLOCKHASH, WARM_STORAGE_READ_COST}, + host::LoadError, + }, handler::{ EthFrame, EvmTr, FrameInitOrResult, FrameTr, ItemOrResult, instructions::EthInstructions, }, inspector::InspectorEvmTr, interpreter::{ - Host, Instruction, InstructionContext, interpreter::EthInterpreter, - interpreter_types::StackTr, + Host, Instruction, InstructionContext, InstructionResult, + interpreter::EthInterpreter, + interpreter_types::{RuntimeFlag, StackTr}, + }, + primitives::{ + BLOCK_HASH_HISTORY, + hardfork::SpecId::{BERLIN, ISTANBUL}, }, - primitives::BLOCK_HASH_HISTORY, }; /// The Morph EVM context type. -pub type MorphContext = Context, DB>; +/// +/// Uses [`MorphTxRuntime`] as the extra per-transaction runtime state payload. +pub type MorphContext = + Context, DB, Journal, MorphTxRuntime>; #[inline] fn as_u64_saturated(value: U256) -> u64 { @@ -50,105 +63,162 @@ fn morph_blockhash_result(chain_id: u64, current_number: u64, requested_number: } } -/// Morph custom SLOAD opcode. -/// -/// This wraps the standard SLOAD logic and adds a fix for the revm-state 9.0.0 -/// `mark_warm_with_transaction_id` behavior change. -/// -/// ## Background -/// -/// In revm-state 9.0.0, `EvmStorageSlot::mark_warm_with_transaction_id` resets -/// `original_value = present_value` when a cold slot becomes warm. This is -/// semantically correct for standard per-transaction state, but breaks Morph's -/// token fee mechanism where storage slots are modified *before* the main -/// transaction and then marked cold. -/// -/// When the main transaction SLOADs such a slot, the cold→warm transition -/// resets `original_value`, making the slot appear "clean" (original == present). -/// A subsequent SSTORE then uses EIP-2200 "clean" pricing (2900 gas) instead of -/// "dirty" pricing (100 gas), creating a **2800 gas** discrepancy vs go-ethereum. +/// Morph custom BLOCKHASH opcode. /// -/// ## Fix +/// Morph geth does not read historical header hashes for BLOCKHASH. Instead it returns: +/// `keccak256(chain_id(8-byte big-endian) || block_number(8-byte big-endian))` +/// for numbers within the 256-block lookup window. +fn blockhash_morph( + context: InstructionContext<'_, MorphContext, EthInterpreter>, +) { + let Some(([], number)) = StackTr::popn_top::<0>(&mut context.interpreter.stack) else { + context.interpreter.halt_underflow(); + return; + }; + + let requested_number_u64 = as_u64_saturated(*number); + let current_number_u64 = as_u64_saturated(context.host.block_number()); + let chain_id_u64 = as_u64_saturated(context.host.chain_id()); + + *number = morph_blockhash_result(chain_id_u64, current_number_u64, requested_number_u64); +} + +#[inline] +fn restore_tracked_original_value( + context: &mut InstructionContext<'_, MorphContext, EthInterpreter>, + address: alloy_primitives::Address, + slot: U256, +) -> Option { + context.host.chain.restore_tracked_original_value( + &mut context.host.journaled_state.inner.state, + address, + slot, + ) +} + +/// Morph custom SLOAD opcode. /// -/// After the standard SLOAD completes (including `mark_warm_with_transaction_id`), -/// this instruction checks whether the loaded slot is one of the fee-deducted -/// slots whose original DB value was saved in `MorphTxEnv::fee_slot_original_values`. -/// If so, it restores `original_value` on the `EvmStorageSlot` in journal state -/// to the true DB value, so that SSTORE gas calculation sees the correct -/// dirty/clean status. -fn sload_morph(context: InstructionContext<'_, MorphContext, EthInterpreter>) { - let Some(([], index)) = StackTr::popn_top::<0>(&mut context.interpreter.stack) else { +/// Restores tx-original values for forced-cold slots after revm warms them. +fn sload_morph( + mut context: InstructionContext<'_, MorphContext, EthInterpreter>, +) { + let Some([key]) = StackTr::popn::<1>(&mut context.interpreter.stack) else { context.interpreter.halt_underflow(); return; }; let target = context.interpreter.input.target_address; - let key = *index; - // Berlin+ path (Morph is always post-Berlin). - // Charge WARM_STORAGE_READ_COST (100) as static gas via the Instruction wrapper, - // then charge the additional cold cost (2000) here if the slot is cold. let additional_cold_cost = context.host.gas_params().cold_storage_additional_cost(); let skip_cold = context.interpreter.gas.remaining() < additional_cold_cost; + let res = context.host.sload_skip_cold_load(target, key, skip_cold); - match context.host.sload_skip_cold_load(target, key, skip_cold) { + match res { Ok(storage) => { - if storage.is_cold && !context.interpreter.gas.record_cost(additional_cold_cost) { - context.interpreter.halt_oog(); - return; + if storage.is_cold { + let _ = restore_tracked_original_value(&mut context, target, key); + if !context.interpreter.gas.record_cost(additional_cold_cost) { + context.interpreter.halt_oog(); + return; + } } - *index = storage.data; - } - Err(LoadError::ColdLoadSkipped) => { - context.interpreter.halt_oog(); - return; - } - Err(LoadError::DBError) => { - context.interpreter.halt_fatal(); - return; - } - } - // Morph fix: restore original_value for slots modified by token fee deduction. - // After mark_warm_with_transaction_id reset original_value = present_value, - // we set it back to the true DB value so SSTORE sees the slot as dirty. - // - // We use `.iter().find()` instead of `.remove()` so the entry is kept for the - // lifetime of the transaction. This is revert-safe: if a sub-call REVERTs, - // the journal rolls the slot back to cold, and a later SLOAD to the same slot - // would corrupt original_value again — the kept entry ensures every cold→warm - // transition is corrected, not just the first one. - if let Some(&(_, _, original_db_value)) = context - .host - .tx - .fee_slot_original_values - .iter() - .find(|(addr, slot_key, _)| *addr == target && *slot_key == key) - && let Some(acc) = context.host.journaled_state.state.get_mut(&target) - && let Some(slot) = acc.storage.get_mut(&key) - { - slot.original_value = original_db_value; + let _ = context.interpreter.stack.push(storage.data); + } + Err(LoadError::ColdLoadSkipped) => context.interpreter.halt_oog(), + Err(LoadError::DBError) => context.interpreter.halt_fatal(), } } -/// Morph custom BLOCKHASH opcode. +/// Morph custom SSTORE opcode. /// -/// Morph geth does not read historical header hashes for BLOCKHASH. Instead it returns: -/// `keccak256(chain_id(8-byte big-endian) || block_number(8-byte big-endian))` -/// for numbers within the 256-block lookup window. -fn blockhash_morph( - context: InstructionContext<'_, MorphContext, EthInterpreter>, +/// revm's standard SSTORE warms a cold slot through the same path as SLOAD, so +/// forced-cold token-fee slots need the same tx-original restoration before gas +/// accounting uses `SStoreResult::original_value`. +fn sstore_morph( + mut context: InstructionContext<'_, MorphContext, EthInterpreter>, ) { - let Some(([], number)) = StackTr::popn_top::<0>(&mut context.interpreter.stack) else { + if context.interpreter.runtime_flag.is_static() { + context + .interpreter + .halt(InstructionResult::StateChangeDuringStaticCall); + return; + } + + let Some([index, value]) = StackTr::popn::<2>(&mut context.interpreter.stack) else { context.interpreter.halt_underflow(); return; }; - let requested_number_u64 = as_u64_saturated(*number); - let current_number_u64 = as_u64_saturated(context.host.block_number()); - let chain_id_u64 = as_u64_saturated(context.host.chain_id()); + let target = context.interpreter.input.target_address; + let spec_id = context.interpreter.runtime_flag.spec_id(); - *number = morph_blockhash_result(chain_id_u64, current_number_u64, requested_number_u64); + if spec_id.is_enabled_in(ISTANBUL) + && context.interpreter.gas.remaining() <= context.host.gas_params().call_stipend() + { + context + .interpreter + .halt(InstructionResult::ReentrancySentryOOG); + return; + } + + if !context + .interpreter + .gas + .record_cost(context.host.gas_params().sstore_static_gas()) + { + context.interpreter.halt_oog(); + return; + } + + let mut state_load = if spec_id.is_enabled_in(BERLIN) { + let additional_cold_cost = context.host.gas_params().cold_storage_additional_cost(); + let skip_cold = context.interpreter.gas.remaining() < additional_cold_cost; + match context + .host + .sstore_skip_cold_load(target, index, value, skip_cold) + { + Ok(load) => load, + Err(LoadError::ColdLoadSkipped) => { + context.interpreter.halt_oog(); + return; + } + Err(LoadError::DBError) => { + context.interpreter.halt_fatal(); + return; + } + } + } else { + let Some(load) = context.host.sstore(target, index, value) else { + context.interpreter.halt_fatal(); + return; + }; + load + }; + + if state_load.is_cold + && let Some(original_value) = restore_tracked_original_value(&mut context, target, index) + { + state_load.data.original_value = original_value; + } + + let is_istanbul = spec_id.is_enabled_in(ISTANBUL); + let dynamic_gas = context.host.gas_params().sstore_dynamic_gas( + is_istanbul, + &state_load.data, + state_load.is_cold, + ); + if !context.interpreter.gas.record_cost(dynamic_gas) { + context.interpreter.halt_oog(); + return; + } + + context.interpreter.gas.record_refund( + context + .host + .gas_params() + .sstore_refund(is_istanbul, &state_load.data), + ); } /// MorphEvm extends the Evm with Morph specific types and logic. @@ -165,8 +235,25 @@ pub struct MorphEvm { MorphPrecompiles, EthFrame, >, - /// Preserved logs from the last transaction - pub logs: Vec, + /// Cached token fee info from the validation/deduction phase. + /// Ensures consistent price_ratio/scale between deduct and reimburse, + /// matching go-ethereum's `st.feeRate`/`st.tokenScale` caching pattern. + pub(crate) cached_token_fee_info: Option, + /// Cached L1 data fee calculated during handler validation. + /// Avoids re-encoding the full transaction RLP in the block executor's + /// receipt-building path (the handler already has the encoded bytes via + /// `MorphTxEnv.rlp_bytes`). + pub(crate) cached_l1_data_fee: U256, + /// Transfer event logs from token fee deduction (pre-execution phase). + /// + /// In go-ethereum, `buyAltTokenGas()` emits Transfer events into `StateDB.logs` + /// which is independent of the state snapshot/revert mechanism — logs survive + /// regardless of main tx result. revm's `ExecutionResult::Revert` has no `logs` + /// field, so we cache fee-related logs separately from the journal and merge + /// them into the receipt in the block executor. + pub(crate) pre_fee_logs: Vec, + /// Transfer event logs from token fee reimbursement (post-execution phase). + pub(crate) post_fee_logs: Vec, } impl MorphEvm { @@ -180,18 +267,14 @@ impl MorphEvm { let precompiles = MorphPrecompiles::new_with_spec(spec); let mut instructions = EthInstructions::new_mainnet(); - // Morph custom SLOAD: restores original_value after revm-state 9.0.0's - // mark_warm_with_transaction_id resets it, fixing EIP-2200 gas for - // token fee deducted slots. Static gas = WARM_STORAGE_READ_COST (100). - instructions.insert_instruction( - 0x54, // SLOAD - Instruction::new( - sload_morph::, - revm::context_interface::cfg::gas::WARM_STORAGE_READ_COST, - ), - ); // Morph custom BLOCKHASH implementation (matches Morph geth). instructions.insert_instruction(0x40, Instruction::new(blockhash_morph::, BLOCKHASH)); + // Morph custom SLOAD: fixes original_value corruption from token fee deduction. + instructions.insert_instruction( + 0x54, + Instruction::new(sload_morph::, WARM_STORAGE_READ_COST), + ); + instructions.insert_instruction(0x55, Instruction::new(sstore_morph::, 0)); // SELFDESTRUCT is disabled in Morph instructions.insert_instruction(0xff, Instruction::unknown()); // BLOBHASH is disabled in Morph @@ -207,7 +290,6 @@ impl MorphEvm { }) } - /// Inner helper function to create a new Morph EVM with empty logs. #[inline] #[expect(clippy::type_complexity)] fn new_inner( @@ -221,7 +303,10 @@ impl MorphEvm { ) -> Self { Self { inner, - logs: Vec::new(), + cached_token_fee_info: None, + cached_l1_data_fee: U256::ZERO, + pre_fee_logs: Vec::new(), + post_fee_logs: Vec::new(), } } } @@ -242,10 +327,36 @@ impl MorphEvm { self.inner.into_inspector() } - /// Take logs from the EVM. + /// Returns the cached token fee info set during handler validation. + /// + /// The cache is populated by `validate_and_deduct_token_fee` and persists + /// through the handler lifecycle so that post-execution code (e.g., the + /// block executor's receipt builder) can reuse it without re-reading the DB. + #[inline] + pub fn cached_token_fee_info(&self) -> Option { + self.cached_token_fee_info.clone() + } + + /// Returns the L1 data fee cached during handler validation. + /// + /// Set in `validate_and_deduct_eth_fee` / `validate_and_deduct_token_fee` and + /// reused by `reward_beneficiary` and the block executor's receipt builder, + /// avoiding redundant `calculate_tx_l1_cost` calls and RLP re-encoding. + #[inline] + pub fn cached_l1_data_fee(&self) -> U256 { + self.cached_l1_data_fee + } + + /// Takes the cached pre-execution fee logs (token fee deduction Transfer events). + #[inline] + pub fn take_pre_fee_logs(&mut self) -> Vec { + std::mem::take(&mut self.pre_fee_logs) + } + + /// Takes the cached post-execution fee logs (token fee reimbursement Transfer events). #[inline] - pub fn take_logs(&mut self) -> Vec { - std::mem::take(&mut self.logs) + pub fn take_post_fee_logs(&mut self) -> Vec { + std::mem::take(&mut self.post_fee_logs) } } diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index ccb31d0..b9459b7 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -2,7 +2,7 @@ use alloy_primitives::{Address, Bytes, U256}; use revm::{ - ExecuteEvm, SystemCallEvm, + ExecuteEvm, context::{ Cfg, ContextTr, JournalTr, Transaction, result::{EVMError, ExecutionResult, InvalidTransaction}, @@ -14,11 +14,11 @@ use revm::{ }; use crate::{ - MorphEvm, MorphInvalidTransaction, + MorphEvm, MorphInvalidTransaction, MorphTxEnv, error::MorphHaltReason, evm::MorphContext, l1block::L1BlockInfo, - token_fee::{TokenFeeInfo, compute_mapping_slot_for_address}, + token_fee::{TokenFeeInfo, compute_mapping_slot_for_address, encode_balance_of_calldata}, tx::MorphTxExt, }; @@ -75,11 +75,6 @@ where evm: &mut Self::Evm, result: <::Frame as FrameTr>::FrameResult, ) -> Result, Self::Error> { - evm.logs.clear(); - if !result.instruction_result().is_ok() { - evm.logs = evm.journal_mut().take_logs(); - } - MainnetHandler::default() .execution_result(evm, result) .map(|result| result.map_haltreason(Into::into)) @@ -95,7 +90,14 @@ where &self, evm: &mut Self::Evm, ) -> Result<(), Self::Error> { - let (_, tx, _, journal, _, _) = evm.ctx().all_mut(); + // Reset per-transaction caches from the previous iteration. + evm.cached_l1_data_fee = U256::ZERO; + evm.cached_token_fee_info = None; + evm.pre_fee_logs.clear(); + evm.post_fee_logs.clear(); + + let (_, tx, _, journal, chain, _) = evm.ctx().all_mut(); + chain.reset(); // L1 message - skip fee validation if tx.is_l1_msg() { @@ -138,7 +140,12 @@ where if tx.is_morph_tx() { let token_id = tx.fee_token_id.unwrap_or_default(); if token_id > 0 { - return self.reimburse_caller_token_fee(evm, exec_result.gas(), token_id); + // When fee charge was disabled (eth_call), no token was deducted and + // cached_token_fee_info was not set — skip reimbursement entirely. + if evm.cached_token_fee_info.is_none() { + return Ok(()); + } + return self.reimburse_caller_token_fee(evm, exec_result.gas()); } // fee_token_id == 0 follows standard ETH reimbursement flow post_execution::reimburse_caller(evm.ctx(), exec_result.gas(), U256::ZERO)?; @@ -175,7 +182,12 @@ where evm: &mut Self::Evm, exec_result: &mut <::Frame as FrameTr>::FrameResult, ) -> Result<(), Self::Error> { - let (block, tx, cfg, journal, _, _) = evm.ctx().all_mut(); + // Reuse the L1 data fee cached during validate_and_deduct_eth_fee / + // validate_and_deduct_token_fee, avoiding a redundant calculate_tx_l1_cost call. + // Read before ctx().all_mut() borrows evm. + let l1_data_fee = evm.cached_l1_data_fee; + + let (block, tx, _, journal, _, _) = evm.ctx().all_mut(); // L1 messages skip all reward. // Token-fee MorphTx rewards are already applied when token fee is deducted. @@ -188,23 +200,6 @@ where let basefee = block.basefee() as u128; let effective_gas_price = tx.effective_gas_price(basefee); - // Get the current hardfork for L1 fee calculation - let hardfork = cfg.spec(); - - // Fetch L1 block info from the L1 Gas Price Oracle contract - let l1_block_info = L1BlockInfo::try_fetch(journal.db_mut(), *hardfork)?; - - // Get RLP-encoded transaction bytes for L1 fee calculation - // This represents the full transaction data posted to L1 for data availability - let rlp_bytes = tx - .rlp_bytes - .as_ref() - .map(|b| b.as_ref()) - .unwrap_or_default(); - - // Calculate L1 data fee based on full RLP-encoded transaction - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, *hardfork); - let gas_used = exec_result.gas().used(); let execution_fee = U256::from(effective_gas_price).saturating_mul(U256::from(gas_used)); @@ -323,18 +318,21 @@ where DB: alloy_evm::Database, { /// Validate and deduct ETH-based gas fees. + #[inline] fn validate_and_deduct_eth_fee( &self, evm: &mut MorphEvm, ) -> Result<(), EVMError> { - // Get the current hardfork for L1 fee calculation let hardfork = *evm.ctx_ref().cfg().spec(); - // Fetch L1 block info from the L1 Gas Price Oracle contract + // Fetch L1 block info from the L1 Gas Price Oracle contract per-tx. + // Must NOT use a per-block cache because the oracle can be updated by a + // regular transaction (from the external gas-oracle service) within the + // same block. Subsequent user txs must see the updated fee parameters, + // matching go-ethereum's per-tx L1BlockInfo read. let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().db_mut(), hardfork)?; // Get RLP-encoded transaction bytes for L1 fee calculation - // This represents the full transaction data posted to L1 for data availability let rlp_bytes = evm .ctx_ref() .tx() @@ -345,6 +343,7 @@ where // Calculate L1 data fee based on full RLP-encoded transaction let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, hardfork); + evm.cached_l1_data_fee = l1_data_fee; // Get mutable access to context components let (block, tx, cfg, journal, _, _) = evm.ctx().all_mut(); @@ -376,14 +375,15 @@ where Ok(()) } - /// Validate and deduct token-based gas fees. + /// Reimburse unused gas fees in ERC20 tokens. /// - /// This handles gas payment using ERC20 tokens instead of ETH. + /// Uses the cached `TokenFeeInfo` from the deduction phase to ensure + /// consistent price_ratio/scale, matching go-ethereum's `st.feeRate`/`st.tokenScale`. + #[inline] fn reimburse_caller_token_fee( &self, evm: &mut MorphEvm, gas: &Gas, - token_id: u16, ) -> Result<(), EVMError> { // Get caller address let caller = evm.ctx_ref().tx().caller(); @@ -392,55 +392,82 @@ where let basefee = evm.ctx.block().basefee() as u128; let effective_gas_price = evm.ctx.tx().effective_gas_price(basefee); + let refunded = gas.refunded().max(0) as u64; let reimburse_eth = U256::from( - effective_gas_price.saturating_mul((gas.remaining() + gas.refunded() as u64) as u128), + effective_gas_price.saturating_mul(gas.remaining().saturating_add(refunded) as u128), ); if reimburse_eth.is_zero() { return Ok(()); } - // Fetch token fee info from Token Registry - let spec = *evm.ctx_ref().cfg().spec(); - let token_fee_info = - TokenFeeInfo::load_for_caller(evm.ctx_mut().db_mut(), token_id, caller, spec)? - .ok_or(MorphInvalidTransaction::TokenNotRegistered(token_id))?; - - // Check if token is active - if !token_fee_info.is_active { - return Err(MorphInvalidTransaction::TokenNotActive(token_id).into()); - } + // Use cached token fee info from the deduction phase (set in validate_and_deduct_token_fee). + // 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.clone().ok_or( + 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); - if let Some(balance_slot) = token_fee_info.balance_slot { - // Transfer with token slot. - let (_, tx, _, journal, _, _) = evm.ctx().all_mut(); - let (from_storage_slot, to_storage_slot) = transfer_erc20_with_slot( + // Attempt token refund. Matches go-ethereum's refundGas() which silently logs + // and continues on failure: "Continue execution even if refund fails - refund + // 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, runtime, _) = evm.ctx().all_mut(); + let from_storage_slot = compute_mapping_slot_for_address(balance_slot, beneficiary); + let to_storage_slot = compute_mapping_slot_for_address(balance_slot, caller); + let result = transfer_erc20_with_slot( journal, beneficiary, caller, token_fee_info.token_address, token_amount_required, balance_slot, - )?; - restore_saved_original_values( - tx, + ) + .map(|_| ()); + restore_tracked_original_values( + runtime, &mut journal.state, token_fee_info.token_address, [from_storage_slot, to_storage_slot], ); + result } else { - // Transfer with evm call. - transfer_erc20_with_evm( + // Cache refund Transfer logs separately, matching the pre_fee_logs + // pattern from validate_and_deduct_token_fee. + let log_count_before = evm.ctx_mut().journal_mut().logs.len(); + let result = transfer_erc20_with_evm( evm, beneficiary, - token_fee_info.caller, + caller, token_fee_info.token_address, token_amount_required, - )?; + None, + ); + let refund_logs: Vec<_> = evm + .ctx_mut() + .journal_mut() + .logs + .drain(log_count_before..) + .collect(); + evm.post_fee_logs = refund_logs; + result + }; + + if let Err(err) = refund_result { + tracing::error!( + target: "morph::evm", + token_id = ?evm.ctx_ref().tx().fee_token_id, + %err, + "failed to refund alt token gas, continuing execution" + ); } + Ok(()) } @@ -457,43 +484,98 @@ where return Err(MorphInvalidTransaction::TokenIdZeroNotSupported.into()); } - let (block, tx, cfg, journal, _, _) = evm.ctx_mut().all_mut(); + { + let (_, tx, cfg, journal, _, _) = evm.ctx_mut().all_mut(); + let caller_addr = tx.caller(); + let nonce = tx.nonce(); + + // Validate account nonce and code (EIP-3607) BEFORE any state mutations, + // matching the order used in validate_and_deduct_eth_fee. + let caller = journal.load_account_with_code_mut(caller_addr)?.data; + pre_execution::validate_account_nonce_and_code( + &caller.account().info, + nonce, + cfg.is_eip3607_disabled(), + cfg.is_nonce_check_disabled(), + )?; + } - // Get caller address - let caller_addr = tx.caller(); - // Get coinbase address - let beneficiary = block.beneficiary(); - // Get the current hardfork for L1 fee calculation - let hardfork = *cfg.spec(); + let caller_addr = evm.ctx_ref().tx().caller(); + let is_call = evm.ctx_ref().tx().kind().is_call(); + + // eth_call (disable_fee_charge): skip token fee deduction entirely. + // Only nonce/code validation (above) and nonce bump are needed. + // This matches the ETH path's disable_fee_charge semantics and ensures + // eth_call is a pure simulation without token registry lookups, balance + // checks, or ERC20 transfers. + if evm.ctx_ref().cfg().is_fee_charge_disabled() { + if is_call { + let mut caller = evm + .ctx_mut() + .journal_mut() + .load_account_with_code_mut(caller_addr)? + .data; + caller.bump_nonce(); + } + return Ok(()); + } + + let beneficiary = evm.ctx_ref().block().beneficiary(); + let hardfork = *evm.ctx_ref().cfg().spec(); + let tx_value = evm.ctx_ref().tx().value(); + let rlp_bytes = evm.ctx_ref().tx().rlp_bytes.clone().unwrap_or_default(); + let gas_limit = evm.ctx_ref().tx().gas_limit(); + let fee_limit_from_tx = evm.ctx_ref().tx().fee_limit.unwrap_or_default(); + let basefee = evm.ctx_ref().block().basefee() as u128; + let effective_gas_price = evm.ctx_ref().tx().effective_gas_price(basefee); + + // Check that caller has enough ETH to cover the value transfer. + // This matches go-ethereum's buyAltTokenGas() which checks + // state.GetBalance(from) >= value before proceeding. + // Without this, the tx would proceed to EVM execution and fail there + // (consuming gas), whereas go-ethereum rejects at the preCheck stage + // (not consuming gas). + if !tx_value.is_zero() { + let caller_eth_balance = *evm + .ctx_mut() + .journal_mut() + .load_account_mut(caller_addr)? + .data + .balance(); + if caller_eth_balance < tx_value { + return Err(MorphInvalidTransaction::EthInvalidTransaction( + InvalidTransaction::LackOfFundForMaxFee { + fee: Box::new(tx_value), + balance: Box::new(caller_eth_balance), + }, + ) + .into()); + } + } // Fetch token fee info from Token Registry - let token_fee_info = - TokenFeeInfo::load_for_caller(journal.db_mut(), token_id, caller_addr, hardfork)? - .ok_or(MorphInvalidTransaction::TokenNotRegistered(token_id))?; + let token_fee_info = TokenFeeInfo::load_for_caller( + evm.ctx_mut().journal_mut().db_mut(), + token_id, + caller_addr, + hardfork, + )? + .ok_or(MorphInvalidTransaction::TokenNotRegistered(token_id))?; // Check if token is active if !token_fee_info.is_active { return Err(MorphInvalidTransaction::TokenNotActive(token_id).into()); } - // Fetch L1 block info from the L1 Gas Price Oracle contract - let l1_block_info = L1BlockInfo::try_fetch(journal.db_mut(), hardfork)?; - // Get RLP-encoded transaction bytes for L1 fee calculation - // This represents the full transaction data posted to L1 for data availability - let rlp_bytes = tx - .rlp_bytes - .as_ref() - .map(|b| b.as_ref()) - .unwrap_or_default(); + // Fetch L1 block info per-tx (same rationale as validate_and_deduct_eth_fee). + let l1_block_info = L1BlockInfo::try_fetch(evm.ctx_mut().journal_mut().db_mut(), hardfork)?; + let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes.as_ref(), hardfork); - // Calculate L1 data fee (in ETH) based on full RLP-encoded transaction - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(rlp_bytes, hardfork); - - // Calculate L2 gas fee (in ETH) - let gas_limit = tx.gas_limit(); - let gas_price = tx.gas_price(); - let l2_gas_fee = U256::from(gas_limit).saturating_mul(U256::from(gas_price)); + // Calculate L2 gas fee using effective_gas_price (= min(gasTipCap + baseFee, gasFeeCap)), + // matching go-ethereum's buyAltTokenGas() which uses st.gasPrice (effective gas price). + // tx.gas_price() returns max_fee_per_gas and would overcharge when tip + basefee < feeCap. + let l2_gas_fee = U256::from(gas_limit).saturating_mul(U256::from(effective_gas_price)); // Total fee in ETH let total_eth_fee = l2_gas_fee.saturating_add(l1_data_fee); @@ -502,7 +584,7 @@ where let token_amount_required = token_fee_info.eth_to_token_amount(total_eth_fee); // Determine fee limit - let mut fee_limit = tx.fee_limit.unwrap_or_default(); + 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 } @@ -516,19 +598,11 @@ where .into()); } - // Collect original DB values for fee-deducted slots (revm-state 9.0.0 workaround). - // - // revm-state 9.0.0's `mark_warm_with_transaction_id` resets - // `original_value = present_value` when a cold slot becomes warm. This - // breaks EIP-2200 gas for slots modified during pre-tx fee deduction. - // We save the true DB values here; the custom SLOAD instruction in - // `sload_morph` restores them after the cold→warm transition. - let mut fee_slot_saves: Vec<(Address, U256, U256)> = Vec::new(); - 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, chain, _) = evm.ctx_mut().all_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( @@ -544,40 +618,50 @@ where if let Some(token_acc) = journal.state.get_mut(&token_fee_info.token_address) { token_acc.mark_cold(); if let Some(slot) = token_acc.storage.get_mut(&from_storage_slot) { - fee_slot_saves.push(( + chain.track_forced_cold_slot( token_fee_info.token_address, from_storage_slot, slot.original_value, - )); + ); slot.mark_cold(); } if let Some(slot) = token_acc.storage.get_mut(&to_storage_slot) { - fee_slot_saves.push(( + chain.track_forced_cold_slot( token_fee_info.token_address, to_storage_slot, slot.original_value, - )); + ); slot.mark_cold(); } } } else { - // Transfer with evm call. + // Transfer with evm call (from=caller, balance known from token registry). transfer_erc20_with_evm( evm, - token_fee_info.caller, + caller_addr, beneficiary, token_fee_info.token_address, token_amount_required, + Some(token_fee_info.balance), )?; + // 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. + 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. - // Also save original_value for changed slots (see workaround above). + // finalize() clears journal state (including logs, which we already took above). let mut state = evm.finalize(); + let (_, _, _, _, chain, _) = evm.ctx_mut().all_mut(); state.iter_mut().for_each(|(addr, acc)| { acc.mark_cold(); acc.storage.iter_mut().for_each(|(key, slot)| { if slot.original_value != slot.present_value { - fee_slot_saves.push((*addr, *key, slot.original_value)); + chain.track_forced_cold_slot(*addr, *key, slot.original_value); } slot.mark_cold(); }); @@ -585,61 +669,28 @@ where evm.ctx_mut().journal_mut().state.extend(state); } - // Store the saved original values in the tx env. We access the `tx` field - // directly because `ContextTr::all_mut()` returns tx as `&Self::Tx` (immutable). - if !fee_slot_saves.is_empty() { - evm.inner.ctx.tx.fee_slot_original_values = fee_slot_saves; - } - - let (_, tx, cfg, journal, _, _) = evm.ctx().all_mut(); - - // Extract the required tx fields (Copy) before mutating accounts. - let caller_addr = tx.caller(); - let nonce = tx.nonce(); - let is_call = tx.kind().is_call(); - - // Load caller's account for nonce/code validation - let mut caller = journal.load_account_with_code_mut(caller_addr)?.data; - - // Validate account nonce and code (EIP-3607) - pre_execution::validate_account_nonce_and_code( - &caller.account().info, - nonce, - cfg.is_eip3607_disabled(), - cfg.is_nonce_check_disabled(), - )?; - // Bump nonce for calls (CREATE nonce is bumped in make_create_frame) if is_call { + let mut caller = evm + .ctx_mut() + .journal_mut() + .load_account_with_code_mut(caller_addr)? + .data; caller.bump_nonce(); } + // Cache token fee info for the reimburse phase, ensuring consistent + // price_ratio/scale between deduction and reimbursement. + evm.cached_token_fee_info = Some(token_fee_info); + evm.cached_l1_data_fee = l1_data_fee; + Ok(()) } } -#[inline] -fn restore_saved_original_values( - tx: &crate::MorphTxEnv, - state: &mut revm::state::EvmState, - address: Address, - slots: impl IntoIterator, -) { - slots.into_iter().for_each(|slot_key| { - if let Some(&(_, _, original_db_value)) = tx - .fee_slot_original_values - .iter() - .find(|(saved_address, saved_slot, _)| *saved_address == address && *saved_slot == slot_key) - && let Some(account) = state.get_mut(&address) - && let Some(slot) = account.storage.get_mut(&slot_key) - { - slot.original_value = original_db_value; - } - }); -} - /// 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] fn transfer_erc20_with_slot( journal: &mut revm::Journal, from: Address, @@ -651,62 +702,206 @@ fn transfer_erc20_with_slot( where DB: alloy_evm::Database, { - // Sub amount + // Sub amount (checked: reject if insufficient, matching go-ethereum's + // changeAltTokenBalanceByState which returns an error on underflow) let from_storage_slot = compute_mapping_slot_for_address(token_balance_slot, from); - let balance = journal.sload(token, from_storage_slot)?; - journal.sstore( - token, - from_storage_slot, - balance.saturating_sub(token_amount), + let balance = *journal.sload(token, from_storage_slot)?; + let new_balance = balance.checked_sub(token_amount).ok_or( + MorphInvalidTransaction::InsufficientTokenBalance { + required: token_amount, + available: balance, + }, )?; + journal.sstore(token, from_storage_slot, new_balance)?; - // Add amount + // Add amount (checked: reject on overflow to maintain token conservation, + // matching go-ethereum's big.Int Add which is unbounded) let to_storage_slot = compute_mapping_slot_for_address(token_balance_slot, to); let balance = journal.sload(token, to_storage_slot)?; - journal.sstore(token, to_storage_slot, balance.saturating_add(token_amount))?; + let new_to_balance = + balance + .checked_add(token_amount) + .ok_or(MorphInvalidTransaction::TokenTransferFailed { + reason: "recipient token balance overflow".into(), + })?; + journal.sstore(token, to_storage_slot, new_to_balance)?; Ok((from_storage_slot, to_storage_slot)) } -/// Transfers ERC20 tokens by executing a `transfer(address,uint256)` call via the EVM. -fn transfer_erc20_with_evm( +/// Restores tx-original values for tracked slots after direct journal access. +#[inline] +fn restore_tracked_original_values( + runtime: &crate::MorphTxRuntime, + state: &mut revm::state::EvmState, + address: Address, + slots: impl IntoIterator, +) { + slots.into_iter().for_each(|slot| { + let _ = runtime.restore_tracked_original_value(state, address, slot); + }); +} + +/// 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.** +fn evm_call( evm: &mut MorphEvm, caller: Address, + target: Address, + calldata: Bytes, +) -> Result> +where + DB: alloy_evm::Database, +{ + evm.tx = MorphTxEnv { + inner: revm::context::TxEnv { + caller, + kind: target.into(), + data: calldata, + gas_limit: EVM_CALL_GAS_LIMIT, + ..Default::default() + }, + ..Default::default() + }; + let mut h = MorphEvmHandler::::new(); + h.execution(evm, &InitialAndFloorGas::new(0, 0)) +} + +/// Query ERC20 `balanceOf(address)` via an internal EVM call. +/// +/// Uses [`evm_call`] so that journal logs are not drained. +fn evm_call_balance_of(evm: &mut MorphEvm, token: Address, account: Address) -> U256 +where + DB: alloy_evm::Database, +{ + // Record log count so we can discard any logs emitted during the call. + // go-ethereum uses evm.StaticCall() for balanceOf which cannot emit events; + // we truncate to match that read-only semantic. + let log_count_before = evm.ctx_mut().journal_mut().logs.len(); + let calldata = encode_balance_of_calldata(account); + let result = evm_call(evm, Address::ZERO, token, calldata); + evm.ctx_mut().journal_mut().logs.truncate(log_count_before); + match result { + 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, + } +} + +/// Matches go-ethereum's `transferAltTokenByEVM` validation: +/// 1. Checks EVM call succeeded (no revert) +/// 2. Validates ABI-decoded bool return value (supports old tokens with no return data) +/// 3. Verifies sender balance changed by the expected amount +/// +/// Uses [`evm_call`] instead of `system_call_one_with_caller` so that event logs +/// (e.g., ERC20 Transfer) naturally remain in the journal and appear in the +/// transaction receipt, matching go-ethereum's `evm.Call()` behavior. +/// +/// `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`). +fn transfer_erc20_with_evm( + evm: &mut MorphEvm, + from: Address, to: Address, token_address: Address, token_amount: U256, + from_balance_before: Option, ) -> Result<(), EVMError> where DB: alloy_evm::Database, { - let tx_origin = evm.tx.clone(); + // Save the original tx by swapping in a default, avoiding a full clone of + // MorphTxEnv (which contains Bytes, AccessList, etc.). + let tx_origin = std::mem::take(&mut evm.tx); + + // Read sender balance before transfer if not provided + let from_balance_before = match from_balance_before { + Some(b) => b, + None => evm_call_balance_of(evm, token_address, from), + }; let calldata = build_transfer_calldata(to, token_amount); - let res = match evm.system_call_one_with_caller(caller, token_address, calldata) { - Ok(result) => { - if result.is_success() { - Ok(()) - } else { - Err(MorphInvalidTransaction::TokenTransferFailed { - reason: format!("{result:?}"), - } - .into()) + let frame_result = match evm_call(evm, from, token_address, calldata) { + Ok(result) => result, + Err(e) => { + evm.tx = tx_origin; + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: format!("Error: {e:?}"), } + .into()); } - Err(e) => Err(MorphInvalidTransaction::TokenTransferFailed { - reason: format!("Error: {e:?}"), - } - .into()), }; - // restore the original transaction + if !frame_result.instruction_result().is_ok() { + evm.tx = tx_origin; + 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) { + evm.tx = tx_origin; + return Err(MorphInvalidTransaction::TokenTransferFailed { + reason: "alt token transfer returned failure".to_string(), + } + .into()); + } + + // Verify sender balance changed by the expected amount, matching go-ethereum. + let from_balance_after = evm_call_balance_of(evm, token_address, from); + + // Restore the original transaction evm.tx = tx_origin; - res + // 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_else(|| { + EVMError::Transaction(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()); + } + + Ok(()) } /// Build the calldata for ERC20 transfer(address,amount) call. /// /// Method signature: `transfer(address,amount) -> 0xa9059cbb` +#[inline] fn build_transfer_calldata(to: Address, token_amount: alloy_primitives::Uint<256, 4>) -> Bytes { let method_id = [0xa9u8, 0x05, 0x9c, 0xbb]; // Encode calldata: method_id + padded to address + amount @@ -733,6 +928,7 @@ fn build_transfer_calldata(to: Address, token_amount: alloy_primitives::Uint<256 /// /// # Returns /// The new balance after deducting all fees, or an error if balance is insufficient. +#[inline] fn calculate_caller_fee_with_l1_cost( balance: U256, tx: impl Transaction, @@ -745,31 +941,33 @@ fn calculate_caller_fee_with_l1_cost( let is_balance_check_disabled = cfg.is_balance_check_disabled(); let is_fee_charge_disabled = cfg.is_fee_charge_disabled(); - // Calculate L2 effective balance spending (gas + value + blob fees) - let effective_balance_spending = tx - .effective_balance_spending(basefee, blob_price) - .expect("effective balance is always smaller than max balance so it can't overflow"); - - // Total spending = L2 fees + L1 data fee - let total_spending = effective_balance_spending.saturating_add(l1_data_fee); - - // Skip balance check if either: - // - Balance check is explicitly disabled (for special scenarios) - // - Fee charge is disabled (eth_call simulation - no point checking balance if fees won't be charged) - if !is_balance_check_disabled && !is_fee_charge_disabled && balance < total_spending { - return Err(InvalidTransaction::LackOfFundForMaxFee { - fee: Box::new(total_spending), - balance: Box::new(balance), - }); + // Validate balance against max possible spending using max_fee_per_gas (not effective_gas_price). + // go-eth's buyGas() checks: balance >= gasFeeCap * gas + value + l1DataFee. + // This ensures the sender can afford the worst-case gas cost before deducting the actual cost. + if !is_balance_check_disabled && !is_fee_charge_disabled { + let max_gas_spending = U256::from( + (tx.gas_limit() as u128) + .checked_mul(tx.max_fee_per_gas()) + .ok_or(InvalidTransaction::OverflowPaymentInTransaction)?, + ); + let max_spending = max_gas_spending + .checked_add(tx.value()) + .and_then(|v| v.checked_add(l1_data_fee)) + .ok_or(InvalidTransaction::OverflowPaymentInTransaction)?; + if balance < max_spending { + return Err(InvalidTransaction::LackOfFundForMaxFee { + fee: Box::new(max_spending), + balance: Box::new(balance), + }); + } } - // Calculate gas balance spending (excluding value transfer) + // Deduct using effective_gas_price (not max_fee_per_gas). + // go-eth's buyGas(): SubBalance(from, gasPrice * gas + l1DataFee) + let effective_balance_spending = tx.effective_balance_spending(basefee, blob_price)?; let gas_balance_spending = effective_balance_spending - tx.value(); - - // Total fee deduction = L2 gas fees + L1 data fee (value is transferred separately) let total_fee_deduction = gas_balance_spending.saturating_add(l1_data_fee); - // New balance after fee deduction let mut new_balance = balance.saturating_sub(total_fee_deduction); if is_balance_check_disabled { @@ -783,62 +981,109 @@ fn calculate_caller_fee_with_l1_cost( #[cfg(test)] mod tests { use super::*; - use crate::{ - MorphBlockEnv, MorphTxEnv, compute_mapping_slot, token_fee::L2_TOKEN_REGISTRY_ADDRESS, - }; - use alloy_primitives::address; + use crate::MorphBlockEnv; + use alloy_primitives::{Bytes, address, keccak256}; use morph_chainspec::hardfork::MorphHardfork; use revm::{ + context::{BlockEnv, TxEnv}, database::{CacheDB, EmptyDB}, inspector::NoOpInspector, - state::AccountInfo, + state::{AccountInfo, Bytecode}, }; - fn insert_registry_entry( - db: &mut CacheDB, - token_id: u16, - token: Address, - balance_slot: U256, - ) { - let mut token_id_bytes = [0u8; 32]; - token_id_bytes[30..32].copy_from_slice(&token_id.to_be_bytes()); - let base = compute_mapping_slot(U256::from(151), token_id_bytes.to_vec()); - let price_ratio_slot = compute_mapping_slot(U256::from(153), token_id_bytes.to_vec()); - - let mut token_word = [0u8; 32]; - token_word[12..32].copy_from_slice(token.as_slice()); - - let mut active_and_decimals = [0u8; 32]; - active_and_decimals[30] = 8; - active_and_decimals[31] = 1; - - db.insert_account_info(L2_TOKEN_REGISTRY_ADDRESS, AccountInfo::default()); - db.insert_account_storage( - L2_TOKEN_REGISTRY_ADDRESS, - base, - U256::from_be_bytes(token_word), - ) - .unwrap(); - db.insert_account_storage( - L2_TOKEN_REGISTRY_ADDRESS, - base + U256::from(1), - balance_slot + U256::from(1), - ) - .unwrap(); - db.insert_account_storage( - L2_TOKEN_REGISTRY_ADDRESS, - base + U256::from(2), - U256::from_be_bytes(active_and_decimals), - ) - .unwrap(); - db.insert_account_storage( - L2_TOKEN_REGISTRY_ADDRESS, - base + U256::from(3), - U256::from(1), - ) - .unwrap(); - db.insert_account_storage(L2_TOKEN_REGISTRY_ADDRESS, price_ratio_slot, U256::from(1)) + fn sstore_code(slot: U256, value: u8) -> Bytes { + let mut code = Vec::with_capacity(1 + 1 + 1 + 32 + 2); + code.push(0x60); // PUSH1 value + code.push(value); + code.push(0x7f); // PUSH32 slot + code.extend_from_slice(&slot.to_be_bytes::<32>()); + code.push(0x55); // SSTORE + code.push(0x00); // STOP + Bytes::from(code) + } + + #[test] + fn forced_cold_slot_sstore_preserves_original_value() { + let caller = address!("1000000000000000000000000000000000000001"); + let token = address!("3000000000000000000000000000000000000003"); + let balance_slot = U256::from(7); + let caller_slot = compute_mapping_slot_for_address(balance_slot, caller); + + let original_balance = U256::from(100); + let deducted_balance = U256::from(90); + let final_balance = U256::from(80); + let contract_code = sstore_code(caller_slot, 80); + + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info( + caller, + AccountInfo { + balance: U256::from(1_000_000), + ..Default::default() + }, + ); + db.insert_account_info( + token, + AccountInfo { + code_hash: keccak256(contract_code.as_ref()), + code: Some(Bytecode::new_raw(contract_code)), + ..Default::default() + }, + ); + db.insert_account_storage(token, caller_slot, original_balance) .unwrap(); + + let mut evm = MorphEvm::new( + MorphContext::new(db, MorphHardfork::default()), + NoOpInspector, + ); + evm.tx = MorphTxEnv { + inner: TxEnv { + caller, + kind: token.into(), + gas_limit: 100_000, + ..Default::default() + }, + ..Default::default() + }; + evm.block = MorphBlockEnv { + inner: BlockEnv::default(), + }; + + { + let (_, _, _, journal, chain, _) = evm.ctx().all_mut(); + let _ = journal.load_account_mut(token).unwrap(); + journal.touch(token); + journal + .sstore(token, caller_slot, deducted_balance) + .unwrap(); + + let token_account = journal.state.get_mut(&token).unwrap(); + token_account.mark_cold(); + token_account + .storage + .get_mut(&caller_slot) + .unwrap() + .mark_cold(); + + chain.track_forced_cold_slot(token, caller_slot, original_balance); + } + + let frame_result = MorphEvmHandler::, NoOpInspector>::new() + .execution(&mut evm, &InitialAndFloorGas::new(0, 0)) + .unwrap(); + assert!(frame_result.instruction_result().is_ok()); + + let slot_state = evm + .ctx_ref() + .journal() + .state + .get(&token) + .and_then(|account| account.storage.get(&caller_slot)) + .unwrap(); + + assert_eq!(slot_state.present_value, final_balance); + assert_eq!(slot_state.original_value, original_balance); } #[test] @@ -846,7 +1091,6 @@ mod tests { let caller = address!("1000000000000000000000000000000000000001"); let beneficiary = address!("2000000000000000000000000000000000000002"); let token = address!("3000000000000000000000000000000000000003"); - let token_id = 5; let balance_slot = U256::from(7); let caller_slot = compute_mapping_slot_for_address(balance_slot, caller); let beneficiary_slot = compute_mapping_slot_for_address(balance_slot, beneficiary); @@ -857,14 +1101,16 @@ mod tests { let refunded = U256::from(4); let mut db = CacheDB::new(EmptyDB::default()); - insert_registry_entry(&mut db, token_id, token, balance_slot); db.insert_account_info(token, AccountInfo::default()); db.insert_account_storage(token, caller_slot, caller_balance) .unwrap(); db.insert_account_storage(token, beneficiary_slot, beneficiary_balance) .unwrap(); - let mut evm = MorphEvm::new(MorphContext::new(db, MorphHardfork::default()), NoOpInspector); + let mut evm = MorphEvm::new( + MorphContext::new(db, MorphHardfork::default()), + NoOpInspector, + ); evm.tx = MorphTxEnv { inner: revm::context::TxEnv { caller, @@ -872,22 +1118,25 @@ mod tests { gas_limit: 100_000, ..Default::default() }, - fee_token_id: Some(token_id), - fee_slot_original_values: vec![ - (token, caller_slot, caller_balance), - (token, beneficiary_slot, beneficiary_balance), - ], + fee_token_id: Some(5), ..Default::default() }; evm.block = MorphBlockEnv { - inner: revm::context::BlockEnv { + inner: BlockEnv { beneficiary, ..Default::default() }, }; + evm.cached_token_fee_info = Some(TokenFeeInfo { + token_address: token, + price_ratio: U256::from(1), + scale: U256::from(1), + balance_slot: Some(balance_slot), + ..Default::default() + }); { - let journal = evm.ctx().journal_mut(); + let (_, _, _, journal, chain, _) = evm.ctx().all_mut(); let _ = journal.load_account_mut(token).unwrap(); journal.touch(token); let (from_storage_slot, to_storage_slot) = transfer_erc20_with_slot( @@ -907,18 +1156,20 @@ mod tests { .get_mut(&from_storage_slot) .unwrap() .mark_cold(); + chain.track_forced_cold_slot(token, from_storage_slot, caller_balance); token_account .storage .get_mut(&to_storage_slot) .unwrap() .mark_cold(); + chain.track_forced_cold_slot(token, to_storage_slot, beneficiary_balance); } let mut gas = Gas::new(10); gas.set_spent(6); MorphEvmHandler::, NoOpInspector>::new() - .reimburse_caller_token_fee(&mut evm, &gas, token_id) + .reimburse_caller_token_fee(&mut evm, &gas) .unwrap(); let token_account = evm.ctx_ref().journal().state.get(&token).unwrap(); diff --git a/crates/revm/src/l1block.rs b/crates/revm/src/l1block.rs index d570862..cbe5b6a 100644 --- a/crates/revm/src/l1block.rs +++ b/crates/revm/src/l1block.rs @@ -103,6 +103,10 @@ pub const INITIAL_BLOB_SCALAR: U256 = U256::from_limbs([417565260, 0, 0, 0]); /// Curie hardfork flag value (1 = true). pub const IS_CURIE: U256 = U256::from_limbs([1, 0, 0, 0]); +/// Maximum L1 data fee cap for circuit compatibility. +/// Matches go-ethereum's `CalculateL1DataFee` cap in `rollup/fees/rollup_fee.go`. +const L1_FEE_CAP: U256 = U256::from_limbs([u64::MAX, 0, 0, 0]); + /// Storage updates for L1 gas price oracle Curie hardfork initialization. /// /// These storage slots are initialized when the Curie hardfork activates: @@ -125,7 +129,7 @@ pub const CURIE_L1_GAS_PRICE_ORACLE_STORAGE: [(U256, U256); 4] = [ /// /// Contains the fee parameters fetched from the L1 Gas Price Oracle contract. /// These parameters are used to calculate the L1 data fee for transactions. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Copy, Debug, Default)] pub struct L1BlockInfo { /// The base fee of the L1 origin block. pub l1_base_fee: U256, @@ -133,14 +137,14 @@ pub struct L1BlockInfo { pub l1_fee_overhead: U256, /// The current L1 fee scalar. pub l1_base_fee_scalar: U256, - /// The current L1 blob base fee, None if before Curie. - pub l1_blob_base_fee: Option, - /// The current L1 commit scalar, None if before Curie. - pub l1_commit_scalar: Option, - /// The current L1 blob scalar, None if before Curie. - pub l1_blob_scalar: Option, - /// The current call data gas (l1_commit_scalar * l1_base_fee), None if before Curie. - pub calldata_gas: Option, + /// The current L1 blob base fee (zero if before Curie). + pub l1_blob_base_fee: U256, + /// The current L1 commit scalar (zero if before Curie). + pub l1_commit_scalar: U256, + /// The current L1 blob scalar (zero if before Curie). + pub l1_blob_scalar: U256, + /// The current call data gas: `l1_commit_scalar * l1_base_fee` (zero if before Curie). + pub calldata_gas: U256, } impl L1BlockInfo { @@ -177,10 +181,10 @@ impl L1BlockInfo { l1_base_fee, l1_fee_overhead, l1_base_fee_scalar, - l1_blob_base_fee: Some(l1_blob_base_fee), - l1_commit_scalar: Some(l1_commit_scalar), - l1_blob_scalar: Some(l1_blob_scalar), - calldata_gas: Some(calldata_gas), + l1_blob_base_fee, + l1_commit_scalar, + l1_blob_scalar, + calldata_gas, }) } } @@ -204,8 +208,8 @@ impl L1BlockInfo { .saturating_add(TX_L1_COMMIT_EXTRA_COST) } else { U256::from(input.len()) - .saturating_mul(self.l1_blob_base_fee.unwrap_or_default()) - .saturating_mul(self.l1_blob_scalar.unwrap_or_default()) + .saturating_mul(self.l1_blob_base_fee) + .saturating_mul(self.l1_blob_scalar) } } @@ -225,7 +229,6 @@ impl L1BlockInfo { let blob_gas = self.data_gas(input, hardfork); self.calldata_gas - .unwrap_or_default() .saturating_add(blob_gas) .wrapping_div(TX_L1_FEE_PRECISION) } @@ -234,12 +237,17 @@ impl L1BlockInfo { /// /// This is the cost of posting the transaction data to L1 for data availability. /// The calculation method differs based on whether the Curie hardfork is active. + /// + /// The result is capped to `u64::MAX` for circuit compatibility, matching go-ethereum's + /// `CalculateL1DataFee` behavior in `rollup/fees/rollup_fee.go`. pub fn calculate_tx_l1_cost(&self, input: &[u8], hardfork: MorphHardfork) -> U256 { - if !hardfork.is_curie() { + let fee = if !hardfork.is_curie() { self.calculate_tx_l1_cost_pre_curie(input, hardfork) } else { self.calculate_tx_l1_cost_curie(input, hardfork) - } + }; + // Cap to u64::MAX for circuit compatibility (go-ethereum: rollup_fee.go:248-249) + fee.min(L1_FEE_CAP) } } @@ -253,10 +261,10 @@ mod tests { assert_eq!(info.l1_base_fee, U256::ZERO); assert_eq!(info.l1_fee_overhead, U256::ZERO); assert_eq!(info.l1_base_fee_scalar, U256::ZERO); - assert!(info.l1_blob_base_fee.is_none()); - assert!(info.l1_commit_scalar.is_none()); - assert!(info.l1_blob_scalar.is_none()); - assert!(info.calldata_gas.is_none()); + assert_eq!(info.l1_blob_base_fee, U256::ZERO); + assert_eq!(info.l1_commit_scalar, U256::ZERO); + assert_eq!(info.l1_blob_scalar, U256::ZERO); + assert_eq!(info.calldata_gas, U256::ZERO); } #[test] @@ -276,8 +284,8 @@ mod tests { #[test] fn test_data_gas_curie() { let info = L1BlockInfo { - l1_blob_base_fee: Some(U256::from(10)), - l1_blob_scalar: Some(U256::from(2)), + l1_blob_base_fee: U256::from(10), + l1_blob_scalar: U256::from(2), ..Default::default() }; diff --git a/crates/revm/src/lib.rs b/crates/revm/src/lib.rs index 75727d6..b8c0c1d 100644 --- a/crates/revm/src/lib.rs +++ b/crates/revm/src/lib.rs @@ -37,6 +37,8 @@ mod block; // Suppress unused_crate_dependencies warnings #[cfg(not(test))] use alloy_consensus as _; +#[cfg(not(test))] +use alloy_rlp as _; use alloy_sol_types as _; #[cfg(not(test))] use reth_ethereum_primitives as _; @@ -51,6 +53,7 @@ pub mod exec; pub mod handler; pub mod l1block; pub mod precompiles; +pub mod runtime; pub mod token_fee; mod tx; @@ -78,6 +81,7 @@ pub use l1block::{ L1BlockInfo, }; pub use precompiles::MorphPrecompiles; +pub use runtime::MorphTxRuntime; pub use token_fee::{ L2_TOKEN_REGISTRY_ADDRESS, TokenFeeInfo, compute_mapping_slot, compute_mapping_slot_for_address, encode_balance_of_calldata, query_erc20_balance, diff --git a/crates/revm/src/runtime.rs b/crates/revm/src/runtime.rs new file mode 100644 index 0000000..f4ee1fc --- /dev/null +++ b/crates/revm/src/runtime.rs @@ -0,0 +1,75 @@ +//! Morph transaction-scoped runtime state. +//! +//! This module stores per-transaction execution metadata that should not live in +//! the transaction input (`MorphTxEnv`) or fee-oracle data structures. + +use alloy_primitives::{Address, U256}; +use revm::{primitives::HashMap, state::EvmState}; + +/// Execution-scoped state for a single Morph transaction. +/// +/// The only tracked data today is the set of storage slots that Morph +/// intentionally re-marks cold after token-fee deduction. revm may later warm +/// these slots again and overwrite their `original_value`, so we retain the true +/// tx-original value here and restore it on every affected access path. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MorphTxRuntime { + forced_cold_slot_originals: HashMap<(Address, U256), U256>, +} + +impl MorphTxRuntime { + /// Clears all transaction-scoped state. + #[inline] + pub fn reset(&mut self) { + self.forced_cold_slot_originals.clear(); + } + + /// Records the true tx-original value for a slot that will be marked cold. + #[inline] + pub fn track_forced_cold_slot(&mut self, address: Address, slot: U256, original_value: U256) { + self.forced_cold_slot_originals + .entry((address, slot)) + .or_insert(original_value); + } + + /// Reads a slot's original value from the current journal state and tracks it. + #[inline] + pub fn track_forced_cold_slot_from_state( + &mut self, + state: &EvmState, + address: Address, + slot: U256, + ) -> Option { + let original_value = state + .get(&address) + .and_then(|account| account.storage.get(&slot)) + .map(|slot_state| slot_state.original_value)?; + + self.track_forced_cold_slot(address, slot, original_value); + Some(original_value) + } + + /// Returns the tracked tx-original value for a forced-cold slot, if any. + #[inline] + pub fn tracked_original_value(&self, address: Address, slot: U256) -> Option { + self.forced_cold_slot_originals + .get(&(address, slot)) + .copied() + } + + /// Restores a tracked tx-original value into journal state. + #[inline] + pub fn restore_tracked_original_value( + &self, + state: &mut EvmState, + address: Address, + slot: U256, + ) -> Option { + let original_value = self.tracked_original_value(address, slot)?; + let slot_state = state + .get_mut(&address) + .and_then(|account| account.storage.get_mut(&slot))?; + slot_state.original_value = original_value; + Some(original_value) + } +} diff --git a/crates/revm/src/tx.rs b/crates/revm/src/tx.rs index dbcabb5..74905ab 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -9,7 +9,6 @@ use alloy_eips::eip2718::Encodable2718; use alloy_eips::eip2930::AccessList; use alloy_eips::eip7702::RecoveredAuthority; use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256}; -use alloy_rlp::Decodable; use morph_primitives::{L1_TX_TYPE_ID, MORPH_TX_TYPE_ID, MorphTxEnvelope, TxMorph}; use reth_evm::{FromRecoveredTx, FromTxWithEncoded, ToTxEnv, TransactionEnv}; use revm::context::{Transaction, TxEnv}; @@ -49,17 +48,6 @@ pub struct MorphTxEnv { pub reference: Option, /// Memo field for arbitrary data. pub memo: Option, - /// Saved original DB values for storage slots modified by token fee deduction. - /// - /// In revm-state 9.0.0, `EvmStorageSlot::mark_warm_with_transaction_id` resets - /// `original_value = present_value` when a cold slot becomes warm. This breaks - /// EIP-2200 gas calculation for slots that were modified during pre-transaction - /// fee deduction and then marked cold. - /// - /// We save `(contract_address, storage_key, original_db_value)` here so that - /// the custom SLOAD instruction can restore `original_value` after the warm - /// transition, preserving the correct dirty/clean determination for SSTORE gas. - pub fee_slot_original_values: Vec<(Address, U256, U256)>, } impl MorphTxEnv { @@ -73,7 +61,6 @@ impl MorphTxEnv { fee_limit: None, reference: None, memo: None, - fee_slot_original_values: Vec::new(), } } @@ -153,7 +140,7 @@ impl MorphTxEnv { // If version is missing (e.g. from an older transaction type), we fallback to V1 // to safely overestimate the L1 fee, ensuring we don't underprice the transaction. tracing::debug!( - target: "morph::revm", + target: "morph::evm", "MorphTx version not set, falling back to V1 for L1 fee calculation to safely overestimate" ); morph_primitives::transaction::morph_transaction::MORPH_TX_VERSION_1 @@ -240,11 +227,20 @@ impl MorphTxEnv { fn from_tx_with_rlp_bytes(tx: &MorphTxEnvelope, signer: Address, rlp_bytes: Bytes) -> Self { let tx_type: u8 = tx.tx_type().into(); - // Extract MorphTx fields for TxMorph (type 0x7F) - let morph_tx_info = if tx_type == MORPH_TX_TYPE_ID { - extract_morph_tx_fields_from_rlp(&rlp_bytes) - } else { - None + // Extract MorphTx fields directly from the typed envelope — avoids an + // unnecessary encode→decode RLP roundtrip that the old helper performed. + let morph_tx_info = match tx { + MorphTxEnvelope::Morph(signed) => { + let morph = signed.tx(); + Some(DecodedMorphTxFields { + version: morph.version, + fee_token_id: morph.fee_token_id, + fee_limit: morph.fee_limit, + reference: morph.reference, + memo: morph.memo.clone(), + }) + } + _ => None, }; // Build TxEnv from the transaction @@ -309,28 +305,6 @@ struct DecodedMorphTxFields { memo: Option, } -/// Extract all MorphTx fields from RLP-encoded TxMorph bytes. -/// -/// The bytes should be EIP-2718 encoded (type byte + RLP payload). -/// Returns None if decoding fails. -fn extract_morph_tx_fields_from_rlp(rlp_bytes: &Bytes) -> Option { - if rlp_bytes.is_empty() { - return None; - } - - // Skip the type byte (0x7F) and decode the TxMorph - let payload = &rlp_bytes[1..]; - TxMorph::decode(&mut &payload[..]) - .map(|tx| DecodedMorphTxFields { - version: tx.version, - fee_token_id: tx.fee_token_id, - fee_limit: tx.fee_limit, - reference: tx.reference, - memo: tx.memo, - }) - .ok() -} - impl Deref for MorphTxEnv { type Target = TxEnv; From e685baa4d2779ab295784ff68c52970fbc7f8fd9 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Tue, 10 Mar 2026 23:14:35 +0800 Subject: [PATCH 3/4] fix(txpool): satisfy clippy clone-on-copy --- crates/txpool/src/validator.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/txpool/src/validator.rs b/crates/txpool/src/validator.rs index b53ac94..966b485 100644 --- a/crates/txpool/src/validator.rs +++ b/crates/txpool/src/validator.rs @@ -51,7 +51,7 @@ impl MorphL1BlockInfo { /// Returns the current L1 block info. pub fn l1_block_info(&self) -> L1BlockInfo { - self.l1_block_info.read().clone() + *self.l1_block_info.read() } /// Updates the L1 block info. @@ -254,7 +254,7 @@ where authorities, } = outcome { - let l1_block_info = self.block_info.l1_block_info.read().clone(); + let l1_block_info = *self.block_info.l1_block_info.read(); let hardfork = self .chain_spec() .morph_hardfork_at(self.block_number(), self.block_timestamp()); From b141eb602c5e3c040a6a41291a3afb2e6a8c6cc1 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Wed, 11 Mar 2026 16:53:05 +0800 Subject: [PATCH 4/4] fix(revm): rollback failed token fee evm helpers --- crates/revm/src/handler.rs | 139 +++++++++++++++++++++++++++++++++++-- crates/revm/src/runtime.rs | 17 ----- 2 files changed, 132 insertions(+), 24 deletions(-) diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index b9459b7..06c07bd 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -782,14 +782,12 @@ fn evm_call_balance_of(evm: &mut MorphEvm, token: Address, account where DB: alloy_evm::Database, { - // Record log count so we can discard any logs emitted during the call. - // go-ethereum uses evm.StaticCall() for balanceOf which cannot emit events; - // we truncate to match that read-only semantic. - let log_count_before = evm.ctx_mut().journal_mut().logs.len(); + // Snapshot the journal so this helper matches go-ethereum's StaticCall + // semantics even though we route through the normal CALL machinery. + let checkpoint = evm.ctx_mut().journal_mut().checkpoint(); let calldata = encode_balance_of_calldata(account); let result = evm_call(evm, Address::ZERO, token, calldata); - evm.ctx_mut().journal_mut().logs.truncate(log_count_before); - match result { + let balance = match result { Ok(ref result) if result.instruction_result().is_ok() => { let output = &result.interpreter_result().output; if output.len() >= 32 { @@ -799,7 +797,9 @@ where } } _ => U256::ZERO, - } + }; + evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); + balance } /// Matches go-ethereum's `transferAltTokenByEVM` validation: @@ -833,11 +833,13 @@ where Some(b) => b, None => evm_call_balance_of(evm, token_address, from), }; + let checkpoint = evm.ctx_mut().journal_mut().checkpoint(); let calldata = build_transfer_calldata(to, token_amount); let frame_result = match evm_call(evm, from, token_address, calldata) { Ok(result) => result, Err(e) => { + evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); evm.tx = tx_origin; return Err(MorphInvalidTransaction::TokenTransferFailed { reason: format!("Error: {e:?}"), @@ -847,6 +849,7 @@ where }; if !frame_result.instruction_result().is_ok() { + evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); evm.tx = tx_origin; return Err(MorphInvalidTransaction::TokenTransferFailed { reason: format!("{:?}", frame_result.interpreter_result()), @@ -860,6 +863,7 @@ where // - Otherwise: rejected let output = &frame_result.interpreter_result().output; if !output.is_empty() && (output.len() < 32 || output[31] != 1) { + evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); evm.tx = tx_origin; return Err(MorphInvalidTransaction::TokenTransferFailed { reason: "alt token transfer returned failure".to_string(), @@ -887,6 +891,7 @@ where }) })?; if from_balance_after != expected_balance { + evm.ctx_mut().journal_mut().checkpoint_revert(checkpoint); return Err(MorphInvalidTransaction::TokenTransferFailed { reason: format!( "sender balance mismatch: expected {expected_balance}, got {from_balance_after}" @@ -895,6 +900,7 @@ where .into()); } + evm.ctx_mut().journal_mut().checkpoint_commit(); Ok(()) } @@ -1002,6 +1008,26 @@ mod tests { Bytes::from(code) } + fn mutating_return_code(write_value: u8, return_value: u8) -> Bytes { + Bytes::from(vec![ + 0x60, + write_value, // PUSH1 write_value + 0x60, + 0x00, // PUSH1 slot 0 + 0x55, // SSTORE + 0x60, + return_value, // PUSH1 return_value + 0x60, + 0x00, // PUSH1 offset 0 + 0x52, // MSTORE + 0x60, + 0x20, // PUSH1 size 32 + 0x60, + 0x00, // PUSH1 offset 0 + 0xf3, // RETURN + ]) + } + #[test] fn forced_cold_slot_sstore_preserves_original_value() { let caller = address!("1000000000000000000000000000000000000001"); @@ -1187,4 +1213,103 @@ mod tests { beneficiary_balance + deducted - refunded ); } + + #[test] + fn transfer_erc20_with_evm_reverts_state_on_validation_failure() { + let from = address!("1000000000000000000000000000000000000001"); + let to = address!("2000000000000000000000000000000000000002"); + let token = address!("3000000000000000000000000000000000000003"); + let original_balance = U256::from(50); + let contract_code = mutating_return_code(1, 0); + + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info( + from, + AccountInfo { + balance: U256::from(1_000_000), + ..Default::default() + }, + ); + db.insert_account_info( + token, + AccountInfo { + code_hash: keccak256(contract_code.as_ref()), + code: Some(Bytecode::new_raw(contract_code)), + ..Default::default() + }, + ); + db.insert_account_storage(token, U256::ZERO, original_balance) + .unwrap(); + + let mut evm = MorphEvm::new( + MorphContext::new(db, MorphHardfork::default()), + NoOpInspector, + ); + evm.block = MorphBlockEnv { + inner: BlockEnv::default(), + }; + + let err = transfer_erc20_with_evm( + &mut evm, + from, + to, + token, + U256::from(4), + Some(original_balance), + ) + .unwrap_err(); + + assert!(matches!( + 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); + } + + #[test] + fn evm_call_balance_of_is_read_only() { + let token = address!("3000000000000000000000000000000000000003"); + let account = address!("1000000000000000000000000000000000000001"); + let original_balance = U256::from(50); + let contract_code = mutating_return_code(1, 42); + + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info( + token, + AccountInfo { + code_hash: keccak256(contract_code.as_ref()), + code: Some(Bytecode::new_raw(contract_code)), + ..Default::default() + }, + ); + db.insert_account_storage(token, U256::ZERO, original_balance) + .unwrap(); + + let mut evm = MorphEvm::new( + MorphContext::new(db, MorphHardfork::default()), + NoOpInspector, + ); + evm.block = MorphBlockEnv { + 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); + } } diff --git a/crates/revm/src/runtime.rs b/crates/revm/src/runtime.rs index f4ee1fc..77eb37d 100644 --- a/crates/revm/src/runtime.rs +++ b/crates/revm/src/runtime.rs @@ -32,23 +32,6 @@ impl MorphTxRuntime { .or_insert(original_value); } - /// Reads a slot's original value from the current journal state and tracks it. - #[inline] - pub fn track_forced_cold_slot_from_state( - &mut self, - state: &EvmState, - address: Address, - slot: U256, - ) -> Option { - let original_value = state - .get(&address) - .and_then(|account| account.storage.get(&slot)) - .map(|slot_state| slot_state.original_value)?; - - self.track_forced_cold_slot(address, slot, original_value); - Some(original_value) - } - /// Returns the tracked tx-original value for a forced-cold slot, if any. #[inline] pub fn tracked_original_value(&self, address: Address, slot: U256) -> Option {