diff --git a/bin/morph-statetest/src/runner.rs b/bin/morph-statetest/src/runner.rs index 1fed0dc..cb1e8b2 100644 --- a/bin/morph-statetest/src/runner.rs +++ b/bin/morph-statetest/src/runner.rs @@ -151,7 +151,7 @@ fn execute_case( .with_inspector(TracerEip3155::buffered(stderr()).without_summary()); evm.enable_inspector(); let exec_result = evm.transact_commit(tx); - let receipt_logs = collect_receipt_logs(&mut evm, &exec_result); + let receipt_logs = result_logs(&exec_result); return Ok(build_outcome( name, fork_name, @@ -165,7 +165,7 @@ fn execute_case( let mut evm = MorphEvm::new(&mut state, env); let exec_result = evm.transact_commit(tx); - let receipt_logs = collect_receipt_logs(&mut evm, &exec_result); + let receipt_logs = result_logs(&exec_result); Ok(build_outcome( name, fork_name, @@ -220,20 +220,15 @@ where } } -fn collect_receipt_logs( - evm: &mut MorphEvm, +/// A token-fee transaction's deduction and refund Transfers are part of the result's logs, +/// including when the main frame reverted or halted. +fn result_logs( exec_result: &Result, E>, -) -> Vec -where - DB: alloy_evm::Database, - I: revm::Inspector>, -{ - let mut logs = evm.take_pre_fee_logs(); - if let Ok(result) = exec_result { - logs.extend(result.logs().iter().cloned()); - } - logs.extend(evm.take_post_fee_logs()); - logs +) -> Vec { + exec_result + .as_ref() + .map(|result| result.logs().to_vec()) + .unwrap_or_default() } fn validation_error( diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index fbdecf0..ba20c89 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -22,7 +22,7 @@ use alloy_evm::{ BlockExecutionError, BlockExecutionResult, BlockExecutor, ExecutableTx, GasOutput, TxResult, }, }; -use alloy_primitives::{Address, Log, U256}; +use alloy_primitives::{Address, U256}; use morph_primitives::{MorphReceipt, MorphTxEnvelope}; use morph_revm::{L1_GAS_PRICE_ORACLE_ADDRESS, MorphHaltReason, TokenFeeInfo, evm::MorphContext}; use reth_primitives_traits::Recovered; @@ -40,10 +40,6 @@ pub struct MorphTxResult { pub recovered: Recovered, /// L1 data fee read from the handler cache immediately after execution. pub l1_fee: U256, - /// Token-fee deduction Transfer logs (survive main-tx revert). - pub pre_fee_logs: Vec, - /// Token-fee reimbursement Transfer logs (survive main-tx revert). - pub post_fee_logs: Vec, } impl TxResult for MorphTxResult { @@ -242,15 +238,11 @@ where // Read caches from the EVM immediately after execution, before the next tx resets them. let l1_fee = self.evm.cached_l1_data_fee(); - let pre_fee_logs = self.evm.take_pre_fee_logs(); - let post_fee_logs = self.evm.take_post_fee_logs(); Ok(MorphTxResult { result, recovered: Recovered::new_unchecked(consensus_tx, signer), l1_fee, - pre_fee_logs, - post_fee_logs, }) } @@ -259,8 +251,6 @@ where result: ResultAndState { result, state }, recovered, l1_fee, - pre_fee_logs, - post_fee_logs, } = output; // EIP-8037 separates regular and state gas; pre-Amsterdam morph treats @@ -300,8 +290,6 @@ where cumulative_gas_used: self.gas_used, l1_fee, morph_tx_fields, - pre_fee_logs, - post_fee_logs, }; self.receipts.push(self.receipt_builder.build_receipt(ctx)); diff --git a/crates/evm/src/block/receipt.rs b/crates/evm/src/block/receipt.rs index 4d791b3..4192dbb 100644 --- a/crates/evm/src/block/receipt.rs +++ b/crates/evm/src/block/receipt.rs @@ -28,7 +28,7 @@ use alloy_consensus::Receipt; use alloy_consensus::transaction::TxHashRef; use alloy_evm::Evm; -use alloy_primitives::{B256, Bytes, Log, U256}; +use alloy_primitives::{B256, Bytes, U256}; use morph_primitives::{MorphReceipt, MorphTransactionReceipt, MorphTxEnvelope, MorphTxType}; use revm::context::result::ExecutionResult; use tracing::warn; @@ -45,8 +45,6 @@ use tracing::warn; /// - `cumulative_gas_used`: Running total of gas used in the block /// - `l1_fee`: Pre-calculated L1 data fee for this transaction /// - `morph_tx_fields`: MorphTx-specific fields (token fee info, version, reference, memo) -/// - `pre_fee_logs`: Transfer event logs from token fee deduction (survives tx revert) -/// - `post_fee_logs`: Transfer event logs from token fee reimbursement #[derive(Debug)] pub(crate) struct MorphReceiptBuilderCtx<'a, E: Evm> { /// The executed transaction @@ -59,11 +57,6 @@ pub(crate) struct MorphReceiptBuilderCtx<'a, E: Evm> { pub l1_fee: U256, /// MorphTx-specific fields (token fee info, version, reference, memo) pub morph_tx_fields: Option, - /// Transfer event logs from token fee deduction (before main tx execution). - /// Managed separately from the handler pipeline to survive main tx revert. - pub pre_fee_logs: Vec, - /// Transfer event logs from token fee reimbursement (after main tx execution). - pub post_fee_logs: Vec, } /// MorphTx (0x7F) specific fields for receipts. @@ -155,25 +148,16 @@ impl MorphReceiptBuilder for DefaultMorphReceiptBuilder { cumulative_gas_used, l1_fee, morph_tx_fields, - pre_fee_logs, - post_fee_logs, } = ctx; - // Assemble logs in chronological order matching go-ethereum: - // [deduct Transfer] + [main tx logs] + [refund Transfer] - // 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. + // For a token-fee MorphTx `result` already holds the logs in go-ethereum's order: + // [deduct Transfer] + [main tx logs, on success] + [refund Transfer] + // The handler leaves both fee transfers' logs in the journal and the main frame + // reverts only back to its own checkpoint, so they survive a failed main frame, + // as they do in go-ethereum, whose `StateDB.logs` sit outside the snapshot/revert + // mechanism. revm returns the journal's logs for successes, reverts and halts alike. let is_success = result.is_success(); - let main_logs = result.into_logs(); - let mut logs = - Vec::with_capacity(pre_fee_logs.len() + main_logs.len() + post_fee_logs.len()); - logs.extend(pre_fee_logs); - logs.extend(main_logs); - logs.extend(post_fee_logs); + let logs = result.into_logs(); let inner = Receipt { status: is_success.into(), @@ -361,8 +345,6 @@ mod tests { cumulative_gas_used: 21000, l1_fee, morph_tx_fields: None, - pre_fee_logs: vec![], - post_fee_logs: vec![], }; let receipt = builder.build_receipt(ctx); @@ -384,8 +366,6 @@ mod tests { cumulative_gas_used: 42000, l1_fee, morph_tx_fields: None, - pre_fee_logs: vec![], - post_fee_logs: vec![], }; let receipt = builder.build_receipt(ctx); @@ -407,8 +387,6 @@ mod tests { // L1 message gas is prepaid on L1, so no L1 fee should appear in the receipt. l1_fee: U256::from(999_999), morph_tx_fields: None, - pre_fee_logs: vec![], - post_fee_logs: vec![], }; let receipt = builder.build_receipt(ctx); @@ -439,8 +417,6 @@ mod tests { cumulative_gas_used: 21000, l1_fee, morph_tx_fields: Some(fields), - pre_fee_logs: vec![], - post_fee_logs: vec![], }; let receipt = builder.build_receipt(ctx); @@ -475,8 +451,6 @@ mod tests { cumulative_gas_used: 21000, l1_fee, morph_tx_fields: None, - pre_fee_logs: vec![], - post_fee_logs: vec![], }; let receipt = builder.build_receipt(ctx); @@ -508,8 +482,6 @@ mod tests { cumulative_gas_used: 15000, l1_fee: U256::from(100u64), morph_tx_fields: None, - pre_fee_logs: vec![], - post_fee_logs: vec![], }; let receipt = builder.build_receipt(ctx); @@ -536,8 +508,6 @@ mod tests { cumulative_gas_used: 21000, l1_fee: U256::ZERO, morph_tx_fields: None, - pre_fee_logs: vec![], - post_fee_logs: vec![], }; let receipt = builder.build_receipt(ctx); @@ -553,28 +523,28 @@ mod tests { .unwrap() } - /// Fee Transfer logs (pre/post) survive when the main transaction reverts. - /// - /// go-ethereum's StateDB.logs is independent of snapshot/revert — fee logs - /// are always included. revm's ExecutionResult::Revert carries no logs field, - /// so morph-reth caches fee logs in pre_fee_logs/post_fee_logs and merges - /// them unconditionally in the receipt builder. + /// A reverted or halted `ExecutionResult` still carries the logs emitted before the + /// main frame failed, which is where the handler leaves a token-fee transaction's + /// deduction and refund Transfers (go-ethereum keeps them in `StateDB.logs`, outside + /// the snapshot/revert mechanism). The receipt must keep them, in order. #[test] - fn test_fee_logs_survive_main_tx_revert() { + fn test_reverted_receipt_keeps_fee_logs() { let builder = DefaultMorphReceiptBuilder; - let tx = create_legacy_tx(); + let tx = create_morph_tx(); - let pre_log = make_fee_log(0xAA); // fee deduction Transfer - let post_log = make_fee_log(0xBB); // fee refund Transfer + let deduct_log = make_fee_log(0xAA); + let refund_log = make_fee_log(0xBB); let ctx = MorphReceiptBuilderCtx:: { tx: &tx, - result: make_revert_result(20_000), + result: ExecutionResult::Revert { + gas: result_gas(20_000), + logs: vec![deduct_log.clone(), refund_log.clone()], + output: alloy_primitives::Bytes::new(), + }, cumulative_gas_used: 20_000, l1_fee: U256::ZERO, morph_tx_fields: None, - pre_fee_logs: vec![pre_log.clone()], - post_fee_logs: vec![post_log.clone()], }; let receipt = builder.build_receipt(ctx); @@ -583,92 +553,28 @@ mod tests { !TxReceipt::status(&receipt), "reverted tx must have status=false" ); - - let logs = TxReceipt::logs(&receipt); - // Main tx logs are absent (revert), but fee logs must still be present. - assert_eq!( - logs.len(), - 2, - "pre_fee_log + post_fee_log must appear despite revert" - ); - assert_eq!( - logs[0].address, pre_log.address, - "first log must be pre_fee_log" - ); - assert_eq!( - logs[1].address, post_log.address, - "second log must be post_fee_log" - ); + assert_eq!(TxReceipt::logs(&receipt), &[deduct_log, refund_log]); } - /// Log ordering on successful tx: [pre_fee_log, main_tx_log, post_fee_log]. - /// - /// Matches go-ethereum's receipt log ordering where fee deduction comes - /// first (before main tx), and fee refund comes last (after main tx). + /// On success the result's logs, `[deduct] + [main] + [refund]`, reach the receipt + /// unchanged. #[test] - fn test_fee_log_ordering_on_success() { + fn test_successful_receipt_keeps_log_order() { let builder = DefaultMorphReceiptBuilder; - let tx = create_legacy_tx(); + let tx = create_morph_tx(); - let pre_log = make_fee_log(0xAA); - let main_log = make_fee_log(0xCC); - let post_log = make_fee_log(0xBB); + let logs = vec![make_fee_log(0xAA), make_fee_log(0xCC), make_fee_log(0xBB)]; let ctx = MorphReceiptBuilderCtx:: { tx: &tx, - result: make_success_with_logs(21_000, vec![main_log.clone()]), + result: make_success_with_logs(21_000, logs.clone()), cumulative_gas_used: 21_000, l1_fee: U256::ZERO, morph_tx_fields: None, - pre_fee_logs: vec![pre_log.clone()], - post_fee_logs: vec![post_log.clone()], }; let receipt = builder.build_receipt(ctx); assert!(TxReceipt::status(&receipt)); - - let logs = TxReceipt::logs(&receipt); - assert_eq!(logs.len(), 3, "pre_fee + main + post_fee = 3 logs"); - assert_eq!( - logs[0].address, pre_log.address, - "pre_fee_log must be first" - ); - assert_eq!( - logs[1].address, main_log.address, - "main_tx_log must be second" - ); - assert_eq!( - logs[2].address, post_log.address, - "post_fee_log must be last" - ); - } - - /// Fee logs without refund: only pre_fee_log when no gas is refunded. - /// - /// If all gas is consumed exactly (no unused gas), the post_fee_log - /// may be empty. But the pre_fee_log must always appear. - #[test] - fn test_pre_fee_log_only_no_post_fee() { - let builder = DefaultMorphReceiptBuilder; - let tx = create_legacy_tx(); - - let pre_log = make_fee_log(0xAA); - - let ctx = MorphReceiptBuilderCtx:: { - tx: &tx, - result: make_revert_result(21_000), - cumulative_gas_used: 21_000, - l1_fee: U256::ZERO, - morph_tx_fields: None, - pre_fee_logs: vec![pre_log.clone()], - post_fee_logs: vec![], // no refund - }; - - let receipt = builder.build_receipt(ctx); - assert!(!TxReceipt::status(&receipt)); - - let logs = TxReceipt::logs(&receipt); - assert_eq!(logs.len(), 1, "only pre_fee_log when there is no refund"); - assert_eq!(logs[0].address, pre_log.address); + assert_eq!(TxReceipt::logs(&receipt), logs.as_slice()); } } diff --git a/crates/evm/src/evm.rs b/crates/evm/src/evm.rs index 368f605..6f13ecc 100644 --- a/crates/evm/src/evm.rs +++ b/crates/evm/src/evm.rs @@ -113,18 +113,6 @@ impl MorphEvm { 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() - } } impl Deref for MorphEvm diff --git a/crates/node/tests/it/morph_tx.rs b/crates/node/tests/it/morph_tx.rs index 7fe7d58..2f6bdfb 100644 --- a/crates/node/tests/it/morph_tx.rs +++ b/crates/node/tests/it/morph_tx.rs @@ -639,9 +639,10 @@ const RUNTIME_REVERT_INIT: &[u8] = &[ /// /// 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`). +/// mechanism, so the deduction's `Transfer` survives a main-frame revert. +/// morph-reth gets the same result by leaving the fee logs in the journal: the +/// main frame's checkpoint is taken after the deduction, so its revert drops only +/// its own logs, and revm returns the rest with the reverted result. /// 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 diff --git a/crates/revm/src/evm.rs b/crates/revm/src/evm.rs index eda44e6..02056be 100644 --- a/crates/revm/src/evm.rs +++ b/crates/revm/src/evm.rs @@ -103,16 +103,6 @@ pub struct MorphEvm { /// 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` - /// 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 { @@ -185,8 +175,6 @@ impl MorphEvm { 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(), } } } @@ -226,18 +214,6 @@ impl MorphEvm { 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_post_fee_logs(&mut self) -> Vec { - std::mem::take(&mut self.post_fee_logs) - } } impl EvmTr for MorphEvm diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index c2b04d8..6a236f7 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -2,7 +2,6 @@ use alloy_primitives::{Address, Bytes, U256}; use revm::{ - ExecuteEvm, context::{ Cfg, ContextTr, JournalTr, Transaction, result::{EVMError, ExecutionResult, InvalidTransaction}, @@ -106,8 +105,6 @@ where 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(); let (_, tx, _, journal, _, _) = evm.ctx().all_mut(); @@ -451,25 +448,17 @@ where ) .map(|_| ()) } else { - // 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( + // The refund's Transfer logs stay in the journal, after the main frame's, which is + // where go-ethereum's `StateDB.logs` records them too. + transfer_erc20_with_evm( evm, beneficiary, 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.map(|_| ()) + ) + .map(|_| ()) }; if let Err(err) = refund_result { @@ -642,27 +631,15 @@ where 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 - // 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. - // 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 + // Fee deduction ran real EVM frames, so their state writes must survive while the + // frames' 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. + // (core/state/statedb.go:1066). Those are the only two things done here. // - // `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: + // `mark_cold` below only has to *drop* the warmth these frames 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 @@ -670,35 +647,32 @@ where // 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 - // 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. + // `mark_cold` leaves each slot's transaction id alone, and revm-state re-baselines + // the EIP-2200 `original_value` only when that id changes (bluealloy/revm#3746), so + // the main frame still prices its SSTOREs against the value committed before this + // transaction, as go-ethereum's `GetCommittedState` does. Re-baselining the slot + // the deduction just cleared would turn the main frame's restoring SSTORE into a + // create and drop the `SubRefund` that cancels the deduction's `+4800`: measured on + // `main_restores_cleared_slot`, 23_291 gas becomes 38_391. Nothing here depends on + // the journal's own id either, so batched execution (`ExecuteEvm::transact_many`) + // prices the same as one `transact` per transaction. // - // 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(); - acc.storage.iter_mut().for_each(|(_, slot)| { - slot.mark_cold(); - }); - }); - evm.ctx_mut().journal_mut().state.extend(state); + // The deduction's Transfer logs and undo history stay in the journal. The main + // frame's checkpoint is taken after this point, so a revert or halt there truncates + // only its own logs, and `post_execution::output` returns + // `[deduction] + [main frame, on success] + [refund]` for every outcome: the order + // go-ethereum's `StateDB.logs` produces, since those logs sit outside its + // snapshot/revert mechanism. The undo history lets `catch_error` still roll the + // deduction back. + let journal = evm.ctx_mut().journal_mut(); + journal.transient_storage.clear(); + for account in journal.state.values_mut() { + account.mark_cold(); + account + .storage + .values_mut() + .for_each(|slot| slot.mark_cold()); + } } // CREATE nonce is bumped later in make_create_frame @@ -1160,6 +1134,7 @@ mod tests { use morph_chainspec::hardfork::MorphHardfork; use morph_primitives::MORPH_TX_TYPE_ID; use revm::{ + ExecuteEvm, context::{BlockEnv, TxEnv}, context_interface::{cfg::gas_params::GasId, result::InvalidTransaction}, database::{CacheDB, EmptyDB}, @@ -2023,7 +1998,13 @@ mod tests { } fn fee_refund_evm(payer_token_balance: U256) -> MorphEvm, NoOpInspector> { - let code = fee_refund_slotless_erc20_code(); + fee_refund_evm_with_token_code(fee_refund_slotless_erc20_code(), payer_token_balance) + } + + fn fee_refund_evm_with_token_code( + code: Bytes, + payer_token_balance: U256, + ) -> MorphEvm, NoOpInspector> { let mut db = CacheDB::new(EmptyDB::default()); db.insert_account_info(FEE_REFUND_CALLER, AccountInfo::default()); db.insert_account_info( @@ -2124,6 +2105,174 @@ mod tests { ) } + /// `fee_refund_slotless_erc20_code` whose `transfer` also emits + /// `Transfer(msg.sender, to, amount)`, so fee frames leave logs behind. + fn fee_refund_logging_erc20_code() -> Bytes { + let mut code = 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 + 0x60, 0x00, // PUSH1 0 + 0x52, // MSTORE -> memory[0..32] = amount + 0x60, 0x04, // PUSH1 4 + 0x35, // CALLDATALOAD -> to + 0x33, // CALLER -> caller to + 0x7f, // PUSH32 Transfer topic + ]; + code.extend_from_slice(keccak256("Transfer(address,address,uint256)").as_slice()); + code.extend_from_slice(&[ + 0x60, 0x20, // PUSH1 32 + 0x60, 0x00, // PUSH1 0 + 0xa3, // LOG3 -> Transfer(caller, to, amount) + 0x60, 0x01, // PUSH1 1 + 0x60, 0x00, // PUSH1 0 + 0x52, // MSTORE + 0x60, 0x20, // PUSH1 32 + 0x60, 0x00, // PUSH1 0 + 0xf3, // RETURN + ]); + Bytes::from(code) + } + + fn fee_log_tx(nonce: u64, to: Address, data: Bytes) -> MorphTxEnv { + 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(to), + data, + nonce, + ..Default::default() + }, + fee_token_id: Some(FEE_REFUND_TOKEN_ID), + ..Default::default() + } + } + + /// Asserts `log` is the logging token's `Transfer(from, to, ..)`. + fn assert_fee_transfer(log: &alloy_primitives::Log, from: Address, to: Address) { + assert_eq!(log.address, FEE_REFUND_TOKEN); + assert_eq!( + log.topics(), + &[ + keccak256("Transfer(address,address,uint256)"), + from.into_word(), + to.into_word() + ] + ); + } + + /// go-ethereum keeps the fee Transfers in `StateDB.logs`, outside the snapshot the main + /// frame reverts, so they reach the receipt even when the transaction fails. revm returns + /// the journal's logs for every outcome, and the main frame's checkpoint is taken after the + /// deduction, so the same two logs come back on a revert, deduction first. + #[test] + fn fee_transfer_logs_survive_a_reverting_main_frame() { + const REVERTER: Address = address!("5000000000000000000000000000000000000005"); + let mut evm = fee_refund_evm_with_token_code( + fee_refund_logging_erc20_code(), + U256::from(10).pow(U256::from(18)), + ); + let code = Bytes::from_static(&[0x60, 0x00, 0x60, 0x00, 0xfd]); // REVERT(0, 0) + evm.ctx_mut().db_mut().insert_account_info( + REVERTER, + AccountInfo { + code_hash: keccak256(code.as_ref()), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + + let result = evm + .transact_one(fee_log_tx(0, REVERTER, Bytes::new())) + .expect("a reverting main frame is still a valid transaction"); + assert!( + matches!(result, ExecutionResult::Revert { .. }), + "{result:?}" + ); + let logs = result.into_logs(); + assert_eq!(logs.len(), 2, "deduction and refund Transfers: {logs:?}"); + assert_fee_transfer(&logs[0], FEE_REFUND_CALLER, FEE_REFUND_BENEFICIARY); + assert_fee_transfer(&logs[1], FEE_REFUND_BENEFICIARY, FEE_REFUND_CALLER); + } + + /// Batched execution (`transact_many`, no `finalize` between transactions) must price and + /// log exactly like one `transact` per transaction. The second transaction pays in the + /// token and then moves it in its main frame, touching the balance slot the deduction just + /// wrote: were the slot's EIP-2200 original value re-baselined between the two, that + /// SSTORE would be charged as a reset (2900) instead of a dirty write (100). + #[test] + fn batched_transactions_match_individual_execution() { + let balance = U256::from(10).pow(U256::from(18)); + let txs = [ + fee_log_tx(0, FEE_REFUND_TARGET, Bytes::new()), + fee_log_tx( + 1, + FEE_REFUND_TOKEN, + build_transfer_calldata(FEE_REFUND_TARGET, U256::from(1)), + ), + ]; + + let mut individual = + fee_refund_evm_with_token_code(fee_refund_logging_erc20_code(), balance); + let individual_results: Vec<_> = txs + .iter() + .map(|tx| { + revm::ExecuteCommitEvm::transact_commit(&mut individual, tx.clone()) + .expect("token-fee MorphTx must execute") + }) + .collect(); + + let mut batched = fee_refund_evm_with_token_code(fee_refund_logging_erc20_code(), balance); + let batched_results = batched + .transact_many(txs.into_iter()) + .expect("token-fee MorphTx must execute"); + + for (individual, batched) in individual_results.iter().zip(&batched_results) { + assert!(individual.is_success(), "{individual:?}"); + assert_eq!(individual.tx_gas_used(), batched.tx_gas_used()); + assert_eq!(individual.logs(), batched.logs()); + } + + // The second transaction's logs: deduction, the main frame's own transfer, refund. + let logs = individual_results[1].logs(); + assert_eq!(logs.len(), 3, "{logs:?}"); + assert_fee_transfer(&logs[0], FEE_REFUND_CALLER, FEE_REFUND_BENEFICIARY); + assert_fee_transfer(&logs[1], FEE_REFUND_CALLER, FEE_REFUND_TARGET); + assert_fee_transfer(&logs[2], FEE_REFUND_BENEFICIARY, FEE_REFUND_CALLER); + } + #[test] fn deduction_sstore_refund_reaches_transaction_gas() { let fee = U256::from(FEE_REFUND_TOKEN_FEE);