Skip to main content

tempo_evm/
evm.rs

1use alloy_evm::{
2    Database, Evm, EvmEnv, EvmFactory, IntoTxEnv,
3    precompiles::PrecompilesMap,
4    revm::{
5        Context, ExecuteEvm, InspectEvm, Inspector, SystemCallEvm,
6        context::{
7            DBErrorMarker,
8            result::{EVMError, ResultAndState, ResultGas},
9        },
10        inspector::NoOpInspector,
11    },
12};
13use alloy_primitives::{Address, Bytes, TxKind};
14use reth_revm::{
15    InspectSystemCallEvm, MainContext,
16    context::{CfgEnv, result::ExecutionResult},
17};
18use std::ops::{Deref, DerefMut};
19use tempo_chainspec::hardfork::TempoHardfork;
20use tempo_precompiles::storage::{StorageAction, StorageActions};
21use tempo_revm::{
22    TempoHaltReason, TempoInvalidTransaction, TempoTxEnv, ValidationContext, evm::TempoContext,
23    handler::TempoEvmHandler,
24};
25
26use crate::TempoBlockEnv;
27
28#[derive(Debug, Default, Clone, Copy)]
29#[non_exhaustive]
30pub struct TempoEvmFactory;
31
32impl EvmFactory for TempoEvmFactory {
33    type Evm<DB: Database, I: Inspector<Self::Context<DB>>> = TempoEvm<DB, I>;
34    type Context<DB: Database> = TempoContext<DB>;
35    type Tx = TempoTxEnv;
36    type Error<DBError: DBErrorMarker> = EVMError<DBError, TempoInvalidTransaction>;
37    type HaltReason = TempoHaltReason;
38    type Spec = TempoHardfork;
39    type BlockEnv = TempoBlockEnv;
40    type Precompiles = PrecompilesMap;
41
42    fn create_evm<DB: Database>(
43        &self,
44        db: DB,
45        input: EvmEnv<Self::Spec, Self::BlockEnv>,
46    ) -> Self::Evm<DB, NoOpInspector> {
47        TempoEvm::new(db, input)
48    }
49
50    fn create_evm_with_inspector<DB: Database, I: Inspector<Self::Context<DB>>>(
51        &self,
52        db: DB,
53        input: EvmEnv<Self::Spec, Self::BlockEnv>,
54        inspector: I,
55    ) -> Self::Evm<DB, I> {
56        TempoEvm::new(db, input).with_inspector(inspector)
57    }
58}
59
60/// Tempo EVM implementation.
61///
62/// This is a wrapper type around the `revm` ethereum evm with optional [`Inspector`] (tracing)
63/// support. [`Inspector`] support is configurable at runtime because it's part of the underlying
64/// `RevmEvm` type.
65#[expect(missing_debug_implementations)]
66pub struct TempoEvm<DB: Database, I = NoOpInspector> {
67    inner: tempo_revm::TempoEvm<DB, I>,
68    /// Recorded storage actions.
69    actions: StorageActions,
70    inspect: bool,
71}
72
73impl<DB: Database> TempoEvm<DB> {
74    /// Create a new [`TempoEvm`] instance.
75    pub fn new(db: DB, input: EvmEnv<TempoHardfork, TempoBlockEnv>) -> Self {
76        // TIP-1016 (EIP-8037 state gas split) is gated by `cfg_env.enable_amsterdam_eip8037`
77        // and is independent of the T4 hardfork. The caller is responsible for setting the
78        // flag on the input `EvmEnv`; here we pass it through unchanged.
79        let ctx = Context::mainnet()
80            .with_db(db)
81            .with_block(input.block_env)
82            .with_cfg(input.cfg_env)
83            .with_tx(Default::default());
84
85        let actions = StorageActions::disabled();
86        Self {
87            inner: tempo_revm::TempoEvm::new_with_actions(ctx, NoOpInspector {}, actions.clone()),
88            actions,
89            inspect: false,
90        }
91    }
92}
93
94impl<DB: Database, I> TempoEvm<DB, I> {
95    /// Consumes this EVM wrapper and returns the inner [`tempo_revm::TempoEvm`].
96    pub fn into_inner(self) -> tempo_revm::TempoEvm<DB, I> {
97        self.inner
98    }
99
100    /// Provides a reference to the EVM context.
101    pub const fn ctx(&self) -> &TempoContext<DB> {
102        &self.inner.inner.ctx
103    }
104
105    /// Consumes this EVM wrapper and returns the EVM context.
106    pub fn into_ctx(self) -> TempoContext<DB> {
107        self.inner.inner.ctx
108    }
109
110    /// Returns the [`EvmEnv`] for the current block.
111    pub fn evm_env(&self) -> EvmEnv<TempoHardfork, TempoBlockEnv> {
112        EvmEnv {
113            cfg_env: self.ctx().cfg.clone(),
114            block_env: self.ctx().block.clone(),
115        }
116    }
117
118    /// Provides a mutable reference to the EVM context.
119    pub fn ctx_mut(&mut self) -> &mut TempoContext<DB> {
120        &mut self.inner.inner.ctx
121    }
122
123    /// Provides a mutable reference to the inner [`tempo_revm::TempoEvm`].
124    pub fn inner_mut(&mut self) -> &mut tempo_revm::TempoEvm<DB, I> {
125        &mut self.inner
126    }
127
128    /// Returns the validator-credited fee amount (post-feeAMM haircut) recorded by the most
129    /// recent `collectFeePostTx`. Reset per-tx in the handler's `validate_env`.
130    pub fn validator_fee(&self) -> alloy_primitives::U256 {
131        self.inner.validator_fee
132    }
133
134    /// Sets the inspector for the EVM.
135    pub fn with_inspector<OINSP>(self, inspector: OINSP) -> TempoEvm<DB, OINSP> {
136        TempoEvm {
137            inner: self.inner.with_inspector(inspector),
138            actions: self.actions,
139            inspect: true,
140        }
141    }
142
143    /// Runs the full transaction validation pipeline without executing the transaction.
144    ///
145    /// Returns a [`ValidationContext`] with context relevant for the transaction pool.
146    pub fn validate_transaction(
147        &mut self,
148        tx: impl IntoTxEnv<TempoTxEnv>,
149    ) -> Result<ValidationContext, EVMError<DB::Error, TempoInvalidTransaction>> {
150        self.inner.inner.ctx.tx = tx.into_tx_env();
151        let mut handler = TempoEvmHandler::new();
152        handler.validate_transaction(&mut self.inner)
153    }
154
155    /// Enables recording of storage actions.
156    pub fn with_actions(self) -> Self {
157        self.actions.enable();
158        self
159    }
160
161    /// Replaces the recorded storage actions with an empty buffer, returning the previous actions.
162    pub fn take_actions(&mut self) -> Option<Vec<StorageAction>> {
163        self.actions.take()
164    }
165
166    /// Replaces the recorded storage actions with the given ones, returning the previous actions.
167    pub fn replace_actions(&mut self, actions: Vec<StorageAction>) -> Option<Vec<StorageAction>> {
168        self.actions.replace(actions)
169    }
170}
171
172impl<DB: Database, I> Deref for TempoEvm<DB, I>
173where
174    DB: Database,
175    I: Inspector<TempoContext<DB>>,
176{
177    type Target = TempoContext<DB>;
178
179    #[inline]
180    fn deref(&self) -> &Self::Target {
181        self.ctx()
182    }
183}
184
185impl<DB: Database, I> DerefMut for TempoEvm<DB, I>
186where
187    DB: Database,
188    I: Inspector<TempoContext<DB>>,
189{
190    #[inline]
191    fn deref_mut(&mut self) -> &mut Self::Target {
192        self.ctx_mut()
193    }
194}
195
196impl<DB, I> Evm for TempoEvm<DB, I>
197where
198    DB: Database,
199    I: Inspector<TempoContext<DB>>,
200{
201    type DB = DB;
202    type Tx = TempoTxEnv;
203    type Error = EVMError<DB::Error, TempoInvalidTransaction>;
204    type HaltReason = TempoHaltReason;
205    type Spec = TempoHardfork;
206    type BlockEnv = TempoBlockEnv;
207    type Precompiles = PrecompilesMap;
208    type Inspector = I;
209
210    fn block(&self) -> &Self::BlockEnv {
211        &self.block
212    }
213
214    fn cfg_env(&self) -> &CfgEnv<Self::Spec> {
215        &self.cfg
216    }
217
218    fn chain_id(&self) -> u64 {
219        self.cfg.chain_id
220    }
221
222    fn transact_raw(
223        &mut self,
224        tx: Self::Tx,
225    ) -> Result<ResultAndState<Self::HaltReason>, Self::Error> {
226        if tx.is_system_tx {
227            let TxKind::Call(to) = tx.inner.kind else {
228                return Err(TempoInvalidTransaction::SystemTransactionMustBeCall.into());
229            };
230
231            let mut result = if self.inspect {
232                self.inner
233                    .inspect_system_call_with_caller(tx.inner.caller, to, tx.inner.data)?
234            } else {
235                self.inner
236                    .system_call_with_caller(tx.inner.caller, to, tx.inner.data)?
237            };
238
239            // system transactions should not consume any gas
240            let ExecutionResult::Success { gas, .. } = &mut result.result else {
241                return Err(
242                    TempoInvalidTransaction::SystemTransactionFailed(result.result.into()).into(),
243                );
244            };
245
246            *gas = ResultGas::default();
247
248            Ok(result)
249        } else if self.inspect {
250            self.inner.inspect_tx(tx)
251        } else {
252            self.inner.transact(tx)
253        }
254    }
255
256    fn transact_system_call(
257        &mut self,
258        caller: Address,
259        contract: Address,
260        data: Bytes,
261    ) -> Result<ResultAndState<Self::HaltReason>, Self::Error> {
262        self.inner.system_call_with_caller(caller, contract, data)
263    }
264
265    fn finish(self) -> (Self::DB, EvmEnv<Self::Spec, Self::BlockEnv>) {
266        let Context {
267            block: block_env,
268            cfg: cfg_env,
269            journaled_state,
270            ..
271        } = self.inner.inner.ctx;
272
273        (journaled_state.database, EvmEnv { block_env, cfg_env })
274    }
275
276    fn set_inspector_enabled(&mut self, enabled: bool) {
277        self.inspect = enabled;
278    }
279
280    fn components(&self) -> (&Self::DB, &Self::Inspector, &Self::Precompiles) {
281        (
282            &self.inner.inner.ctx.journaled_state.database,
283            &self.inner.inner.inspector,
284            &self.inner.inner.precompiles,
285        )
286    }
287
288    fn components_mut(&mut self) -> (&mut Self::DB, &mut Self::Inspector, &mut Self::Precompiles) {
289        (
290            &mut self.inner.inner.ctx.journaled_state.database,
291            &mut self.inner.inner.inspector,
292            &mut self.inner.inner.precompiles,
293        )
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use crate::test_utils::{test_evm, test_evm_with_basefee};
300    use alloy_primitives::U256;
301    use alloy_sol_types::SolCall;
302    use revm::{
303        DatabaseCommit,
304        context::{BlockEnv, CfgEnv, JournalTr, TxEnv},
305        database::{EmptyDB, in_memory_db::CacheDB},
306        state::EvmState,
307    };
308    use std::collections::BTreeMap;
309    use tempo_chainspec::hardfork::TempoHardfork;
310    use tempo_precompiles::{
311        PATH_USD_ADDRESS, STORAGE_CREDITS_ADDRESS, TIP_FEE_MANAGER_ADDRESS,
312        TIP403_REGISTRY_ADDRESS,
313        storage::{StorageAction, StorageCtx, StorageKey},
314        storage_credits::StorageCredits,
315        test_util::TIP20Setup,
316        tip_fee_manager::slots as fee_manager_slots,
317        tip20::{
318            ITIP20, USD_CURRENCY, rewards::__packing_user_reward_info as user_reward_info_slots,
319            slots as tip20_slots,
320        },
321        tip403_registry::slots as tip403_registry_slots,
322    };
323    use tempo_primitives::transaction::calc_gas_balance_spending;
324    use tempo_revm::gas_params::tempo_gas_params_with_amsterdam;
325
326    use super::*;
327
328    #[test]
329    fn can_execute_system_tx() {
330        let mut evm = test_evm(EmptyDB::default());
331        let result = evm
332            .transact(TempoTxEnv {
333                inner: TxEnv {
334                    caller: Address::ZERO,
335                    gas_price: 0,
336                    gas_limit: 21000,
337                    ..Default::default()
338                },
339                is_system_tx: true,
340                ..Default::default()
341            })
342            .unwrap();
343
344        assert!(result.result.is_success());
345    }
346
347    #[test]
348    fn test_transact_raw() {
349        let mut evm = test_evm_with_basefee(EmptyDB::default(), 0);
350
351        let tx = TempoTxEnv {
352            inner: TxEnv {
353                caller: Address::repeat_byte(0x01),
354                gas_price: 0,
355                gas_limit: 21000,
356                kind: TxKind::Call(Address::repeat_byte(0x02)),
357                ..Default::default()
358            },
359            is_system_tx: false,
360            fee_token: None,
361            ..Default::default()
362        };
363
364        let result = evm.transact_raw(tx);
365        assert!(result.is_ok());
366
367        let result = result.unwrap();
368        assert!(result.result.is_success());
369        assert_eq!(result.result.tx_gas_used(), 21000);
370    }
371
372    #[test]
373    fn test_transact_raw_system_tx() {
374        let mut evm = test_evm(EmptyDB::default());
375
376        // System transaction
377        let tx = TempoTxEnv {
378            inner: TxEnv {
379                caller: Address::ZERO,
380                gas_price: 0,
381                gas_limit: 21000,
382                kind: TxKind::Call(Address::repeat_byte(0x01)),
383                ..Default::default()
384            },
385            is_system_tx: true,
386            ..Default::default()
387        };
388
389        let result = evm.transact_raw(tx);
390        assert!(result.is_ok());
391
392        let result = result.unwrap();
393        assert!(result.result.is_success());
394        // System transactions should not consume gas
395        assert_eq!(result.result.tx_gas_used(), 0);
396    }
397
398    #[test]
399    fn test_transact_raw_system_tx_must_be_call() {
400        let mut evm = test_evm(EmptyDB::default());
401
402        // System transaction with Create kind
403        let tx = TempoTxEnv {
404            inner: TxEnv {
405                caller: Address::ZERO,
406                gas_price: 0,
407                gas_limit: 21000,
408                kind: TxKind::Create,
409                ..Default::default()
410            },
411            is_system_tx: true,
412            ..Default::default()
413        };
414
415        let result = evm.transact_raw(tx);
416        assert!(result.is_err());
417
418        let err = result.unwrap_err();
419        assert!(matches!(
420            err,
421            EVMError::Transaction(TempoInvalidTransaction::SystemTransactionMustBeCall)
422        ));
423    }
424
425    #[test]
426    fn test_transact_raw_system_tx_failed() {
427        let mut cache_db = CacheDB::new(EmptyDB::default());
428        // Deploy a contract that always reverts: PUSH1 0x00 PUSH1 0x00 REVERT (0x60006000fd)
429        let revert_code = Bytes::from_static(&[0x60, 0x00, 0x60, 0x00, 0xfd]);
430        let contract_addr = Address::repeat_byte(0xaa);
431
432        cache_db.insert_account_info(
433            contract_addr,
434            revm::state::AccountInfo {
435                code_hash: alloy_primitives::keccak256(&revert_code),
436                code: Some(revm::bytecode::Bytecode::new_raw(revert_code)),
437                ..Default::default()
438            },
439        );
440
441        let mut evm = test_evm(cache_db);
442
443        // System transaction that will fail with call to contract that reverts
444        let tx = TempoTxEnv {
445            inner: TxEnv {
446                caller: Address::ZERO,
447                gas_price: 0,
448                gas_limit: 1_000_000,
449                kind: TxKind::Call(contract_addr),
450                ..Default::default()
451            },
452            is_system_tx: true,
453            ..Default::default()
454        };
455
456        let result = evm.transact_raw(tx);
457        assert!(result.is_err());
458
459        let err = result.unwrap_err();
460        assert!(matches!(
461            err,
462            EVMError::Transaction(TempoInvalidTransaction::SystemTransactionFailed(_))
463        ));
464    }
465
466    #[test]
467    fn test_transact_system_call() {
468        let mut evm = test_evm(EmptyDB::default());
469
470        let caller = Address::repeat_byte(0x01);
471        let contract = Address::repeat_byte(0x02);
472        let data = Bytes::from_static(&[0x01, 0x02, 0x03]);
473
474        let result = evm.transact_system_call(caller, contract, data);
475        assert!(result.is_ok());
476
477        let result = result.unwrap();
478        assert!(result.result.is_success());
479    }
480
481    fn assert_storage_actions_reconstruct_evm_state(
482        actions: &[StorageAction],
483        state: &EvmState,
484        hardfork: TempoHardfork,
485    ) {
486        let mut original_values = BTreeMap::<(Address, U256), U256>::new();
487        for (address, account) in state {
488            for (slot, storage_slot) in &account.storage {
489                original_values.insert((*address, *slot), storage_slot.original_value());
490            }
491        }
492
493        let mut first_loads = BTreeMap::<(Address, U256), U256>::new();
494        let mut reconstructed = BTreeMap::<(Address, U256), U256>::new();
495
496        for action in actions {
497            match *action {
498                StorageAction::Sload(address, slot, value) => {
499                    let key = (address, slot);
500                    match reconstructed.get(&key) {
501                        Some(previous) => assert_eq!(
502                            *previous, value,
503                            "SLOAD must match reconstructed current value for {address:?}:{slot:?} on {hardfork:?}",
504                        ),
505                        None => {
506                            first_loads.insert(key, value);
507                            reconstructed.insert(key, value);
508                        }
509                    }
510                }
511                StorageAction::Sstore(address, slot, value) => {
512                    let key = (address, slot);
513                    assert!(
514                        reconstructed.contains_key(&key),
515                        "SSTORE without prior SLOAD for {address:?}:{slot:?} on {hardfork:?}",
516                    );
517                    reconstructed.insert(key, value);
518                }
519                StorageAction::Sinc(address, slot, delta) => {
520                    let key = (address, slot);
521                    let current = match reconstructed.get(&key) {
522                        Some(current) => *current,
523                        None => {
524                            let original = *original_values.get(&key).unwrap_or_else(|| {
525                                panic!(
526                                    "SINC without prior SLOAD for unknown EVM output storage cell {address:?}:{slot:?} on {hardfork:?}",
527                                )
528                            });
529                            first_loads.insert(key, original);
530                            reconstructed.insert(key, original);
531                            original
532                        }
533                    };
534                    let value = current.checked_add(delta).unwrap_or_else(|| {
535                        panic!("SINC overflow for {address:?}:{slot:?} on {hardfork:?}")
536                    });
537                    reconstructed.insert(key, value);
538                }
539                StorageAction::Sdec(address, slot, delta) => {
540                    let key = (address, slot);
541                    let current = match reconstructed.get(&key) {
542                        Some(current) => *current,
543                        None => {
544                            let original = *original_values.get(&key).unwrap_or_else(|| {
545                                panic!(
546                                    "SDEC without prior SLOAD for unknown EVM output storage cell {address:?}:{slot:?} on {hardfork:?}",
547                                )
548                            });
549                            first_loads.insert(key, original);
550                            reconstructed.insert(key, original);
551                            original
552                        }
553                    };
554                    let value = current.checked_sub(delta).unwrap_or_else(|| {
555                        panic!("SDEC underflow for {address:?}:{slot:?} on {hardfork:?}")
556                    });
557                    reconstructed.insert(key, value);
558                }
559            }
560        }
561
562        for (address, account) in state {
563            for (slot, storage_slot) in &account.storage {
564                let key = (*address, *slot);
565                let original_value = first_loads.get(&key).unwrap_or_else(|| {
566                    panic!(
567                        "EVM output storage cell {address:?}:{slot:?} was not loaded in StorageActions on {hardfork:?}",
568                    )
569                });
570                assert_eq!(
571                    *original_value,
572                    storage_slot.original_value(),
573                    "reconstructed original value mismatch for {address:?}:{slot:?} on {hardfork:?}",
574                );
575
576                let reconstructed_value = reconstructed.get(&key).unwrap_or_else(|| {
577                    panic!(
578                        "EVM output storage cell {address:?}:{slot:?} was not reconstructed from StorageActions on {hardfork:?}",
579                    )
580                });
581                assert_eq!(
582                    *reconstructed_value,
583                    storage_slot.present_value(),
584                    "reconstructed present value mismatch for {address:?}:{slot:?} on {hardfork:?}",
585                );
586            }
587        }
588    }
589
590    fn short_string_word(bytes: &[u8]) -> U256 {
591        assert!(bytes.len() <= 31);
592
593        let mut word = [0u8; 32];
594        word[..bytes.len()].copy_from_slice(bytes);
595        word[31] = (bytes.len() * 2) as u8;
596        U256::from_be_bytes(word)
597    }
598
599    fn hardforks_for_storage_action_recording() -> Vec<TempoHardfork> {
600        let current = current_mainnet_hardfork();
601        let latest = latest_available_hardfork();
602
603        if current == latest {
604            vec![current]
605        } else {
606            vec![current, latest]
607        }
608    }
609
610    fn current_mainnet_hardfork() -> TempoHardfork {
611        #[allow(clippy::disallowed_methods)]
612        let timestamp = std::time::SystemTime::now()
613            .duration_since(std::time::UNIX_EPOCH)
614            .expect("system clock should be after unix epoch")
615            .as_secs();
616
617        TempoHardfork::VARIANTS
618            .iter()
619            .rev()
620            .copied()
621            .find(|fork| {
622                fork.mainnet_activation_timestamp()
623                    .is_some_and(|activation| timestamp >= activation)
624            })
625            .unwrap_or(TempoHardfork::Genesis)
626    }
627
628    fn latest_available_hardfork() -> TempoHardfork {
629        *TempoHardfork::VARIANTS
630            .last()
631            .expect("TempoHardfork must have at least one variant")
632    }
633
634    #[test]
635    fn test_tip20_full_evm_records_storage_actions_with_fees() {
636        for hardfork in hardforks_for_storage_action_recording() {
637            let sender = Address::repeat_byte(0x01);
638            let recipient = Address::repeat_byte(0x02);
639            let beneficiary = Address::repeat_byte(0x03);
640            let starting_balance = U256::from(1_000_000);
641            let transfer_amount = U256::from(100);
642            let gas_limit = 1_000_000;
643            let gas_price = 1_000_000_000u64;
644
645            let mut cfg = CfgEnv::<TempoHardfork>::default();
646            cfg.set_spec_and_mainnet_gas_params(hardfork);
647
648            let mut evm = TempoEvm::new(
649                CacheDB::new(EmptyDB::default()),
650                EvmEnv {
651                    cfg_env: cfg,
652                    block_env: TempoBlockEnv {
653                        inner: BlockEnv {
654                            beneficiary,
655                            basefee: gas_price,
656                            gas_limit: 30_000_000,
657                            ..Default::default()
658                        },
659                        ..Default::default()
660                    },
661                },
662            );
663
664            StorageCtx::enter_ctx(evm.ctx_mut(), StorageActions::disabled(), || {
665                TIP20Setup::path_usd(sender)
666                    .with_issuer(sender)
667                    .with_mint(sender, starting_balance)
668                    .apply()
669            })
670            .expect("TIP20 setup should succeed");
671            let setup_state = evm.ctx_mut().journaled_state.finalize();
672            evm.db_mut().commit(setup_state);
673
674            let mut evm = evm.with_actions();
675            assert_eq!(evm.take_actions(), Some(vec![]));
676
677            let calldata: Bytes = ITIP20::transferCall {
678                to: recipient,
679                amount: transfer_amount,
680            }
681            .abi_encode()
682            .into();
683            let tx = TempoTxEnv {
684                inner: TxEnv {
685                    caller: sender,
686                    gas_price: u128::from(gas_price),
687                    gas_limit,
688                    kind: TxKind::Call(PATH_USD_ADDRESS),
689                    data: calldata.clone(),
690                    ..Default::default()
691                },
692                fee_token: Some(PATH_USD_ADDRESS),
693                ..Default::default()
694            };
695            let result = evm.transact_raw(tx).expect("transfer should execute");
696            assert!(result.result.is_success(), "hardfork: {hardfork:?}");
697
698            let max_fee_spending = calc_gas_balance_spending(gas_limit, u128::from(gas_price));
699            let actual_spending =
700                calc_gas_balance_spending(result.result.tx_gas_used(), u128::from(gas_price));
701            assert!(
702                !actual_spending.is_zero(),
703                "test must exercise post-tx fee settlement"
704            );
705            assert!(
706                max_fee_spending > actual_spending,
707                "test must exercise post-tx fee refund"
708            );
709
710            let refund_amount = max_fee_spending - actual_spending;
711            let sender_balance_slot = sender.mapping_slot(tip20_slots::BALANCES);
712            let fee_manager_balance_slot =
713                TIP_FEE_MANAGER_ADDRESS.mapping_slot(tip20_slots::BALANCES);
714            let recipient_balance_slot = recipient.mapping_slot(tip20_slots::BALANCES);
715            let sender_reward_info_slot = sender.mapping_slot(tip20_slots::USER_REWARD_INFO);
716            let recipient_reward_info_slot = recipient.mapping_slot(tip20_slots::USER_REWARD_INFO);
717            let reward_recipient_offset = user_reward_info_slots::REWARD_RECIPIENT;
718            let reward_per_token_offset = user_reward_info_slots::REWARD_PER_TOKEN;
719            let reward_balance_offset = user_reward_info_slots::REWARD_BALANCE;
720            let transfer_policy_id_word =
721                U256::from(1) << (tip20_slots::TRANSFER_POLICY_ID_OFFSET * 8);
722            let receive_policy_config_slot =
723                recipient.mapping_slot(tip403_registry_slots::RECEIVE_POLICIES);
724            let validator_token_slot =
725                beneficiary.mapping_slot(fee_manager_slots::VALIDATOR_TOKENS);
726            let collected_fees_slot = PATH_USD_ADDRESS
727                .mapping_slot(beneficiary.mapping_slot(fee_manager_slots::COLLECTED_FEES));
728            let path_usd_storage_credit_slot = StorageCredits::slot(PATH_USD_ADDRESS);
729            let currency_word = short_string_word(USD_CURRENCY.as_bytes());
730
731            let sender_after_fee = starting_balance - max_fee_spending;
732            let sender_after_transfer = sender_after_fee - transfer_amount;
733
734            let actions = evm
735                .take_actions()
736                .expect("storage action recording should be enabled");
737
738            let expected = if hardfork == latest_available_hardfork() {
739                vec![
740                    // SLOAD currency length: validate explicit PATH_USD fee token is USD.
741                    StorageAction::Sload(PATH_USD_ADDRESS, tip20_slots::CURRENCY, currency_word),
742                    // SLOAD currency value: read the short "USD" currency string.
743                    StorageAction::Sload(PATH_USD_ADDRESS, tip20_slots::CURRENCY, currency_word),
744                    // SLOAD validatorTokens[beneficiary]: pre-tx fee route uses default PATH_USD.
745                    StorageAction::Sload(TIP_FEE_MANAGER_ADDRESS, validator_token_slot, U256::ZERO),
746                    // SLOAD transferPolicyId: authorize fee escrow transfer to FeeManager.
747                    StorageAction::Sload(
748                        PATH_USD_ADDRESS,
749                        tip20_slots::TRANSFER_POLICY_ID,
750                        transfer_policy_id_word,
751                    ),
752                    // SLOAD paused: fee escrow respects token pause state.
753                    StorageAction::Sload(PATH_USD_ADDRESS, tip20_slots::PAUSED, U256::ZERO),
754                    // SDEC balances[sender]: debit max fee escrow.
755                    StorageAction::Sdec(PATH_USD_ADDRESS, sender_balance_slot, max_fee_spending),
756                    // SLOAD balances[FeeManager]: zero-to-nonzero escrow credit falls back to SLOAD+SSTORE.
757                    StorageAction::Sload(PATH_USD_ADDRESS, fee_manager_balance_slot, U256::ZERO),
758                    // SSTORE balances[FeeManager]: credit max fee escrow.
759                    StorageAction::Sstore(
760                        PATH_USD_ADDRESS,
761                        fee_manager_balance_slot,
762                        max_fee_spending,
763                    ),
764                    // SLOAD paused: user TIP20 transfer rejects paused tokens.
765                    StorageAction::Sload(PATH_USD_ADDRESS, tip20_slots::PAUSED, U256::ZERO),
766                    // SLOAD transferPolicyId: read policy word for user TIP20 transfer checks.
767                    StorageAction::Sload(
768                        PATH_USD_ADDRESS,
769                        tip20_slots::TRANSFER_POLICY_ID,
770                        transfer_policy_id_word,
771                    ),
772                    // SLOAD receivePolicies[recipient]: validate user TIP20 inbound policy.
773                    StorageAction::Sload(
774                        TIP403_REGISTRY_ADDRESS,
775                        receive_policy_config_slot,
776                        U256::ZERO,
777                    ),
778                    // SDEC balances[sender]: debit user transfer.
779                    StorageAction::Sdec(PATH_USD_ADDRESS, sender_balance_slot, transfer_amount),
780                    // SLOAD balances[recipient]: zero-to-nonzero transfer credit falls back to SLOAD+SSTORE.
781                    StorageAction::Sload(PATH_USD_ADDRESS, recipient_balance_slot, U256::ZERO),
782                    // SSTORE balances[recipient]: credit user transfer.
783                    StorageAction::Sstore(
784                        PATH_USD_ADDRESS,
785                        recipient_balance_slot,
786                        transfer_amount,
787                    ),
788                    // SLOAD storageCredits[PATH_USD]: TIP-1060 tracks the user transfer credit.
789                    StorageAction::Sload(
790                        STORAGE_CREDITS_ADDRESS,
791                        path_usd_storage_credit_slot,
792                        U256::ZERO,
793                    ),
794                    // SDEC balances[FeeManager]: refund unused fee from escrow.
795                    StorageAction::Sdec(PATH_USD_ADDRESS, fee_manager_balance_slot, refund_amount),
796                    // SINC balances[sender]: credit unused fee refund.
797                    StorageAction::Sinc(PATH_USD_ADDRESS, sender_balance_slot, refund_amount),
798                    // SLOAD validatorTokens[beneficiary]: post-tx fee route uses default PATH_USD.
799                    StorageAction::Sload(TIP_FEE_MANAGER_ADDRESS, validator_token_slot, U256::ZERO),
800                    // SLOAD collectedFees[beneficiary][PATH_USD]: zero accrual falls back to SLOAD+SSTORE.
801                    StorageAction::Sload(TIP_FEE_MANAGER_ADDRESS, collected_fees_slot, U256::ZERO),
802                    // SSTORE collectedFees[beneficiary][PATH_USD]: accrue actual PATH_USD spending.
803                    StorageAction::Sstore(
804                        TIP_FEE_MANAGER_ADDRESS,
805                        collected_fees_slot,
806                        actual_spending,
807                    ),
808                ]
809            } else {
810                vec![
811                    // SLOAD currency length: validate explicit PATH_USD fee token is USD.
812                    StorageAction::Sload(PATH_USD_ADDRESS, tip20_slots::CURRENCY, currency_word),
813                    // SLOAD currency value: read the short "USD" currency string.
814                    StorageAction::Sload(PATH_USD_ADDRESS, tip20_slots::CURRENCY, currency_word),
815                    // SLOAD validatorTokens[beneficiary]: pre-tx fee route uses default PATH_USD.
816                    StorageAction::Sload(TIP_FEE_MANAGER_ADDRESS, validator_token_slot, U256::ZERO),
817                    // SLOAD transferPolicyId: authorize fee escrow transfer to FeeManager.
818                    StorageAction::Sload(
819                        PATH_USD_ADDRESS,
820                        tip20_slots::TRANSFER_POLICY_ID,
821                        transfer_policy_id_word,
822                    ),
823                    // SLOAD paused: fee escrow respects token pause state.
824                    StorageAction::Sload(PATH_USD_ADDRESS, tip20_slots::PAUSED, U256::ZERO),
825                    // SLOAD userRewardInfo[sender].rewardRecipient: fee payer is opted out.
826                    StorageAction::Sload(
827                        PATH_USD_ADDRESS,
828                        sender_reward_info_slot + reward_recipient_offset,
829                        U256::ZERO,
830                    ),
831                    // SLOAD userRewardInfo[sender].rewardPerToken: load fee payer reward checkpoint.
832                    StorageAction::Sload(
833                        PATH_USD_ADDRESS,
834                        sender_reward_info_slot + reward_per_token_offset,
835                        U256::ZERO,
836                    ),
837                    // SLOAD userRewardInfo[sender].rewardBalance: load fee payer unclaimed rewards.
838                    StorageAction::Sload(
839                        PATH_USD_ADDRESS,
840                        sender_reward_info_slot + reward_balance_offset,
841                        U256::ZERO,
842                    ),
843                    // SLOAD globalRewardPerToken: compute fee payer reward delta.
844                    StorageAction::Sload(
845                        PATH_USD_ADDRESS,
846                        tip20_slots::GLOBAL_REWARD_PER_TOKEN,
847                        U256::ZERO,
848                    ),
849                    // SDEC balances[sender]: debit max fee escrow.
850                    StorageAction::Sdec(PATH_USD_ADDRESS, sender_balance_slot, max_fee_spending),
851                    // SLOAD balances[FeeManager]: zero-to-nonzero escrow credit falls back to SLOAD+SSTORE.
852                    StorageAction::Sload(PATH_USD_ADDRESS, fee_manager_balance_slot, U256::ZERO),
853                    // SSTORE balances[FeeManager]: credit max fee escrow.
854                    StorageAction::Sstore(
855                        PATH_USD_ADDRESS,
856                        fee_manager_balance_slot,
857                        max_fee_spending,
858                    ),
859                    // SLOAD paused: user TIP20 transfer rejects paused tokens.
860                    StorageAction::Sload(PATH_USD_ADDRESS, tip20_slots::PAUSED, U256::ZERO),
861                    // SLOAD transferPolicyId: read policy word for user TIP20 transfer checks.
862                    StorageAction::Sload(
863                        PATH_USD_ADDRESS,
864                        tip20_slots::TRANSFER_POLICY_ID,
865                        transfer_policy_id_word,
866                    ),
867                    // SLOAD balances[sender]: read post-escrow balance before user transfer debit.
868                    StorageAction::Sload(PATH_USD_ADDRESS, sender_balance_slot, sender_after_fee),
869                    // SLOAD userRewardInfo[sender].rewardRecipient: sender is opted out.
870                    StorageAction::Sload(
871                        PATH_USD_ADDRESS,
872                        sender_reward_info_slot + reward_recipient_offset,
873                        U256::ZERO,
874                    ),
875                    // SLOAD userRewardInfo[sender].rewardPerToken: load sender reward checkpoint.
876                    StorageAction::Sload(
877                        PATH_USD_ADDRESS,
878                        sender_reward_info_slot + reward_per_token_offset,
879                        U256::ZERO,
880                    ),
881                    // SLOAD userRewardInfo[sender].rewardBalance: load sender unclaimed rewards.
882                    StorageAction::Sload(
883                        PATH_USD_ADDRESS,
884                        sender_reward_info_slot + reward_balance_offset,
885                        U256::ZERO,
886                    ),
887                    // SLOAD globalRewardPerToken: compute sender reward delta.
888                    StorageAction::Sload(
889                        PATH_USD_ADDRESS,
890                        tip20_slots::GLOBAL_REWARD_PER_TOKEN,
891                        U256::ZERO,
892                    ),
893                    // SLOAD userRewardInfo[recipient].rewardRecipient: recipient is opted out.
894                    StorageAction::Sload(
895                        PATH_USD_ADDRESS,
896                        recipient_reward_info_slot + reward_recipient_offset,
897                        U256::ZERO,
898                    ),
899                    // SLOAD userRewardInfo[recipient].rewardPerToken: load recipient reward checkpoint.
900                    StorageAction::Sload(
901                        PATH_USD_ADDRESS,
902                        recipient_reward_info_slot + reward_per_token_offset,
903                        U256::ZERO,
904                    ),
905                    // SLOAD userRewardInfo[recipient].rewardBalance: load recipient unclaimed rewards.
906                    StorageAction::Sload(
907                        PATH_USD_ADDRESS,
908                        recipient_reward_info_slot + reward_balance_offset,
909                        U256::ZERO,
910                    ),
911                    // SLOAD globalRewardPerToken: compute recipient reward delta.
912                    StorageAction::Sload(
913                        PATH_USD_ADDRESS,
914                        tip20_slots::GLOBAL_REWARD_PER_TOKEN,
915                        U256::ZERO,
916                    ),
917                    // SSTORE balances[sender]: debit user transfer.
918                    StorageAction::Sstore(
919                        PATH_USD_ADDRESS,
920                        sender_balance_slot,
921                        sender_after_transfer,
922                    ),
923                    // SLOAD balances[recipient]: zero-to-nonzero transfer credit falls back to SLOAD+SSTORE.
924                    StorageAction::Sload(PATH_USD_ADDRESS, recipient_balance_slot, U256::ZERO),
925                    // SSTORE balances[recipient]: credit user transfer.
926                    StorageAction::Sstore(
927                        PATH_USD_ADDRESS,
928                        recipient_balance_slot,
929                        transfer_amount,
930                    ),
931                    // SLOAD userRewardInfo[sender].rewardRecipient: sender is opted out before fee refund.
932                    StorageAction::Sload(
933                        PATH_USD_ADDRESS,
934                        sender_reward_info_slot + reward_recipient_offset,
935                        U256::ZERO,
936                    ),
937                    // SLOAD userRewardInfo[sender].rewardPerToken: load sender checkpoint before refund.
938                    StorageAction::Sload(
939                        PATH_USD_ADDRESS,
940                        sender_reward_info_slot + reward_per_token_offset,
941                        U256::ZERO,
942                    ),
943                    // SLOAD userRewardInfo[sender].rewardBalance: load sender rewards before refund.
944                    StorageAction::Sload(
945                        PATH_USD_ADDRESS,
946                        sender_reward_info_slot + reward_balance_offset,
947                        U256::ZERO,
948                    ),
949                    // SLOAD globalRewardPerToken: compute sender reward delta before refund.
950                    StorageAction::Sload(
951                        PATH_USD_ADDRESS,
952                        tip20_slots::GLOBAL_REWARD_PER_TOKEN,
953                        U256::ZERO,
954                    ),
955                    // SDEC balances[FeeManager]: refund unused fee from escrow.
956                    StorageAction::Sdec(PATH_USD_ADDRESS, fee_manager_balance_slot, refund_amount),
957                    // SINC balances[sender]: credit unused fee refund.
958                    StorageAction::Sinc(PATH_USD_ADDRESS, sender_balance_slot, refund_amount),
959                    // SLOAD validatorTokens[beneficiary]: post-tx fee route uses default PATH_USD.
960                    StorageAction::Sload(TIP_FEE_MANAGER_ADDRESS, validator_token_slot, U256::ZERO),
961                    // SLOAD collectedFees[beneficiary][PATH_USD]: zero accrual falls back to SLOAD+SSTORE.
962                    StorageAction::Sload(TIP_FEE_MANAGER_ADDRESS, collected_fees_slot, U256::ZERO),
963                    // SSTORE collectedFees[beneficiary][PATH_USD]: accrue actual PATH_USD spending.
964                    StorageAction::Sstore(
965                        TIP_FEE_MANAGER_ADDRESS,
966                        collected_fees_slot,
967                        actual_spending,
968                    ),
969                ]
970            };
971
972            assert_eq!(actions, expected, "hardfork: {hardfork:?}");
973            assert_storage_actions_reconstruct_evm_state(&actions, &result.state, hardfork);
974            evm.db_mut().commit(result.state);
975
976            let tx = TempoTxEnv {
977                inner: TxEnv {
978                    caller: sender,
979                    gas_price: u128::from(gas_price),
980                    gas_limit,
981                    kind: TxKind::Call(PATH_USD_ADDRESS),
982                    data: calldata,
983                    nonce: 1,
984                    ..Default::default()
985                },
986                fee_token: Some(PATH_USD_ADDRESS),
987                ..Default::default()
988            };
989            let result = evm.transact_raw(tx).expect("transfer should execute");
990            assert!(result.result.is_success(), "hardfork: {hardfork:?}");
991            let second_actual_spending =
992                calc_gas_balance_spending(result.result.tx_gas_used(), u128::from(gas_price));
993            let second_refund_amount = max_fee_spending - second_actual_spending;
994            let sender_after_first_tx = sender_after_transfer + refund_amount;
995            let sender_after_second_fee = sender_after_first_tx - max_fee_spending;
996            let sender_after_second_transfer = sender_after_second_fee - transfer_amount;
997
998            let actions = evm
999                .take_actions()
1000                .expect("storage action recording should be enabled");
1001
1002            // The second transfer starts from the committed first transfer state, so balance
1003            // and fee accumulator slots created by the first transfer use SINC/SDEC.
1004            let expected = if hardfork == latest_available_hardfork() {
1005                vec![
1006                    // SLOAD currency length: validate explicit PATH_USD fee token is USD.
1007                    StorageAction::Sload(PATH_USD_ADDRESS, tip20_slots::CURRENCY, currency_word),
1008                    // SLOAD currency value: read the short "USD" currency string.
1009                    StorageAction::Sload(PATH_USD_ADDRESS, tip20_slots::CURRENCY, currency_word),
1010                    // SLOAD validatorTokens[beneficiary]: pre-tx fee route uses default PATH_USD.
1011                    StorageAction::Sload(TIP_FEE_MANAGER_ADDRESS, validator_token_slot, U256::ZERO),
1012                    // SLOAD transferPolicyId: authorize fee escrow transfer to FeeManager.
1013                    StorageAction::Sload(
1014                        PATH_USD_ADDRESS,
1015                        tip20_slots::TRANSFER_POLICY_ID,
1016                        transfer_policy_id_word,
1017                    ),
1018                    // SLOAD paused: fee escrow respects token pause state.
1019                    StorageAction::Sload(PATH_USD_ADDRESS, tip20_slots::PAUSED, U256::ZERO),
1020                    // SDEC balances[sender]: debit max fee escrow.
1021                    StorageAction::Sdec(PATH_USD_ADDRESS, sender_balance_slot, max_fee_spending),
1022                    // SINC balances[FeeManager]: credit max fee escrow into non-zero balance.
1023                    StorageAction::Sinc(
1024                        PATH_USD_ADDRESS,
1025                        fee_manager_balance_slot,
1026                        max_fee_spending,
1027                    ),
1028                    // SLOAD paused: user TIP20 transfer rejects paused tokens.
1029                    StorageAction::Sload(PATH_USD_ADDRESS, tip20_slots::PAUSED, U256::ZERO),
1030                    // SLOAD transferPolicyId: read policy word for user TIP20 transfer checks.
1031                    StorageAction::Sload(
1032                        PATH_USD_ADDRESS,
1033                        tip20_slots::TRANSFER_POLICY_ID,
1034                        transfer_policy_id_word,
1035                    ),
1036                    // SLOAD receivePolicies[recipient]: validate user TIP20 inbound policy.
1037                    StorageAction::Sload(
1038                        TIP403_REGISTRY_ADDRESS,
1039                        receive_policy_config_slot,
1040                        U256::ZERO,
1041                    ),
1042                    // SDEC balances[sender]: debit user transfer.
1043                    StorageAction::Sdec(PATH_USD_ADDRESS, sender_balance_slot, transfer_amount),
1044                    // SINC balances[recipient]: credit user transfer into non-zero balance.
1045                    StorageAction::Sinc(PATH_USD_ADDRESS, recipient_balance_slot, transfer_amount),
1046                    // SDEC balances[FeeManager]: refund unused fee from escrow.
1047                    StorageAction::Sdec(
1048                        PATH_USD_ADDRESS,
1049                        fee_manager_balance_slot,
1050                        second_refund_amount,
1051                    ),
1052                    // SINC balances[sender]: credit unused fee refund.
1053                    StorageAction::Sinc(
1054                        PATH_USD_ADDRESS,
1055                        sender_balance_slot,
1056                        second_refund_amount,
1057                    ),
1058                    // SLOAD validatorTokens[beneficiary]: post-tx fee route uses default PATH_USD.
1059                    StorageAction::Sload(TIP_FEE_MANAGER_ADDRESS, validator_token_slot, U256::ZERO),
1060                    // SINC collectedFees[beneficiary][PATH_USD]: accrue actual PATH_USD spending.
1061                    StorageAction::Sinc(
1062                        TIP_FEE_MANAGER_ADDRESS,
1063                        collected_fees_slot,
1064                        second_actual_spending,
1065                    ),
1066                ]
1067            } else {
1068                vec![
1069                    // SLOAD currency length: validate explicit PATH_USD fee token is USD.
1070                    StorageAction::Sload(PATH_USD_ADDRESS, tip20_slots::CURRENCY, currency_word),
1071                    // SLOAD currency value: read the short "USD" currency string.
1072                    StorageAction::Sload(PATH_USD_ADDRESS, tip20_slots::CURRENCY, currency_word),
1073                    // SLOAD validatorTokens[beneficiary]: pre-tx fee route uses default PATH_USD.
1074                    StorageAction::Sload(TIP_FEE_MANAGER_ADDRESS, validator_token_slot, U256::ZERO),
1075                    // SLOAD transferPolicyId: authorize fee escrow transfer to FeeManager.
1076                    StorageAction::Sload(
1077                        PATH_USD_ADDRESS,
1078                        tip20_slots::TRANSFER_POLICY_ID,
1079                        transfer_policy_id_word,
1080                    ),
1081                    // SLOAD paused: fee escrow respects token pause state.
1082                    StorageAction::Sload(PATH_USD_ADDRESS, tip20_slots::PAUSED, U256::ZERO),
1083                    // SLOAD userRewardInfo[sender].rewardRecipient: fee payer is opted out.
1084                    StorageAction::Sload(
1085                        PATH_USD_ADDRESS,
1086                        sender_reward_info_slot + reward_recipient_offset,
1087                        U256::ZERO,
1088                    ),
1089                    // SLOAD userRewardInfo[sender].rewardPerToken: load fee payer reward checkpoint.
1090                    StorageAction::Sload(
1091                        PATH_USD_ADDRESS,
1092                        sender_reward_info_slot + reward_per_token_offset,
1093                        U256::ZERO,
1094                    ),
1095                    // SLOAD userRewardInfo[sender].rewardBalance: load fee payer unclaimed rewards.
1096                    StorageAction::Sload(
1097                        PATH_USD_ADDRESS,
1098                        sender_reward_info_slot + reward_balance_offset,
1099                        U256::ZERO,
1100                    ),
1101                    // SLOAD globalRewardPerToken: compute fee payer reward delta.
1102                    StorageAction::Sload(
1103                        PATH_USD_ADDRESS,
1104                        tip20_slots::GLOBAL_REWARD_PER_TOKEN,
1105                        U256::ZERO,
1106                    ),
1107                    // SDEC balances[sender]: debit max fee escrow.
1108                    StorageAction::Sdec(PATH_USD_ADDRESS, sender_balance_slot, max_fee_spending),
1109                    // SINC balances[FeeManager]: credit max fee escrow into non-zero balance.
1110                    StorageAction::Sinc(
1111                        PATH_USD_ADDRESS,
1112                        fee_manager_balance_slot,
1113                        max_fee_spending,
1114                    ),
1115                    // SLOAD paused: user TIP20 transfer rejects paused tokens.
1116                    StorageAction::Sload(PATH_USD_ADDRESS, tip20_slots::PAUSED, U256::ZERO),
1117                    // SLOAD transferPolicyId: read policy word for user TIP20 transfer checks.
1118                    StorageAction::Sload(
1119                        PATH_USD_ADDRESS,
1120                        tip20_slots::TRANSFER_POLICY_ID,
1121                        transfer_policy_id_word,
1122                    ),
1123                    // SLOAD balances[sender]: read post-escrow balance before user transfer debit.
1124                    StorageAction::Sload(
1125                        PATH_USD_ADDRESS,
1126                        sender_balance_slot,
1127                        sender_after_second_fee,
1128                    ),
1129                    // SLOAD userRewardInfo[sender].rewardRecipient: sender is opted out.
1130                    StorageAction::Sload(
1131                        PATH_USD_ADDRESS,
1132                        sender_reward_info_slot + reward_recipient_offset,
1133                        U256::ZERO,
1134                    ),
1135                    // SLOAD userRewardInfo[sender].rewardPerToken: load sender reward checkpoint.
1136                    StorageAction::Sload(
1137                        PATH_USD_ADDRESS,
1138                        sender_reward_info_slot + reward_per_token_offset,
1139                        U256::ZERO,
1140                    ),
1141                    // SLOAD userRewardInfo[sender].rewardBalance: load sender unclaimed rewards.
1142                    StorageAction::Sload(
1143                        PATH_USD_ADDRESS,
1144                        sender_reward_info_slot + reward_balance_offset,
1145                        U256::ZERO,
1146                    ),
1147                    // SLOAD globalRewardPerToken: compute sender reward delta.
1148                    StorageAction::Sload(
1149                        PATH_USD_ADDRESS,
1150                        tip20_slots::GLOBAL_REWARD_PER_TOKEN,
1151                        U256::ZERO,
1152                    ),
1153                    // SLOAD userRewardInfo[recipient].rewardRecipient: recipient is opted out.
1154                    StorageAction::Sload(
1155                        PATH_USD_ADDRESS,
1156                        recipient_reward_info_slot + reward_recipient_offset,
1157                        U256::ZERO,
1158                    ),
1159                    // SLOAD userRewardInfo[recipient].rewardPerToken: load recipient reward checkpoint.
1160                    StorageAction::Sload(
1161                        PATH_USD_ADDRESS,
1162                        recipient_reward_info_slot + reward_per_token_offset,
1163                        U256::ZERO,
1164                    ),
1165                    // SLOAD userRewardInfo[recipient].rewardBalance: load recipient unclaimed rewards.
1166                    StorageAction::Sload(
1167                        PATH_USD_ADDRESS,
1168                        recipient_reward_info_slot + reward_balance_offset,
1169                        U256::ZERO,
1170                    ),
1171                    // SLOAD globalRewardPerToken: compute recipient reward delta.
1172                    StorageAction::Sload(
1173                        PATH_USD_ADDRESS,
1174                        tip20_slots::GLOBAL_REWARD_PER_TOKEN,
1175                        U256::ZERO,
1176                    ),
1177                    // SSTORE balances[sender]: debit user transfer.
1178                    StorageAction::Sstore(
1179                        PATH_USD_ADDRESS,
1180                        sender_balance_slot,
1181                        sender_after_second_transfer,
1182                    ),
1183                    // SINC balances[recipient]: credit user transfer into non-zero balance.
1184                    StorageAction::Sinc(PATH_USD_ADDRESS, recipient_balance_slot, transfer_amount),
1185                    // SLOAD userRewardInfo[sender].rewardRecipient: sender is opted out before fee refund.
1186                    StorageAction::Sload(
1187                        PATH_USD_ADDRESS,
1188                        sender_reward_info_slot + reward_recipient_offset,
1189                        U256::ZERO,
1190                    ),
1191                    // SLOAD userRewardInfo[sender].rewardPerToken: load sender checkpoint before refund.
1192                    StorageAction::Sload(
1193                        PATH_USD_ADDRESS,
1194                        sender_reward_info_slot + reward_per_token_offset,
1195                        U256::ZERO,
1196                    ),
1197                    // SLOAD userRewardInfo[sender].rewardBalance: load sender rewards before refund.
1198                    StorageAction::Sload(
1199                        PATH_USD_ADDRESS,
1200                        sender_reward_info_slot + reward_balance_offset,
1201                        U256::ZERO,
1202                    ),
1203                    // SLOAD globalRewardPerToken: compute sender reward delta before refund.
1204                    StorageAction::Sload(
1205                        PATH_USD_ADDRESS,
1206                        tip20_slots::GLOBAL_REWARD_PER_TOKEN,
1207                        U256::ZERO,
1208                    ),
1209                    // SDEC balances[FeeManager]: refund unused fee from escrow.
1210                    StorageAction::Sdec(
1211                        PATH_USD_ADDRESS,
1212                        fee_manager_balance_slot,
1213                        second_refund_amount,
1214                    ),
1215                    // SINC balances[sender]: credit unused fee refund.
1216                    StorageAction::Sinc(
1217                        PATH_USD_ADDRESS,
1218                        sender_balance_slot,
1219                        second_refund_amount,
1220                    ),
1221                    // SLOAD validatorTokens[beneficiary]: post-tx fee route uses default PATH_USD.
1222                    StorageAction::Sload(TIP_FEE_MANAGER_ADDRESS, validator_token_slot, U256::ZERO),
1223                    // SINC collectedFees[beneficiary][PATH_USD]: accrue actual PATH_USD spending.
1224                    StorageAction::Sinc(
1225                        TIP_FEE_MANAGER_ADDRESS,
1226                        collected_fees_slot,
1227                        second_actual_spending,
1228                    ),
1229                ]
1230            };
1231
1232            assert_eq!(actions, expected, "hardfork: {hardfork:?}");
1233            assert_storage_actions_reconstruct_evm_state(&actions, &result.state, hardfork);
1234        }
1235    }
1236
1237    // ==================== TIP-1000 EVM Configuration Tests ====================
1238
1239    /// Helper to create EvmEnv with a specific hardfork spec.
1240    fn evm_env_with_spec(
1241        spec: tempo_chainspec::hardfork::TempoHardfork,
1242    ) -> EvmEnv<tempo_chainspec::hardfork::TempoHardfork, TempoBlockEnv> {
1243        EvmEnv::<tempo_chainspec::hardfork::TempoHardfork, TempoBlockEnv>::new(
1244            CfgEnv::new_with_spec_and_gas_params(
1245                spec,
1246                tempo_gas_params_with_amsterdam(spec, false),
1247            ),
1248            TempoBlockEnv::default(),
1249        )
1250    }
1251
1252    /// Test that TempoEvm applies custom gas params via `tempo_gas_params()`.
1253    /// This verifies the [TIP-1000] gas parameter override mechanism.
1254    ///
1255    /// [TIP-1000]: <https://docs.tempo.xyz/protocol/tips/tip-1000>
1256    #[test]
1257    fn test_tempo_evm_applies_gas_params() {
1258        // Create EVM with T1 hardfork to get TIP-1000 gas params
1259        let evm = TempoEvm::new(EmptyDB::default(), evm_env_with_spec(TempoHardfork::T1));
1260
1261        // Verify gas params were applied (check a known T1 override)
1262        // T1 has tx_eip7702_per_empty_account_cost = 12,500
1263        let gas_params = &evm.ctx().cfg.gas_params;
1264        assert_eq!(
1265            gas_params.tx_eip7702_per_empty_account_cost(),
1266            12_500,
1267            "T1 should have EIP-7702 per empty account cost of 12,500"
1268        );
1269    }
1270
1271    /// Test that TempoEvm respects the gas limit cap passed in via EvmEnv.
1272    /// Note: The 30M [TIP-1000] gas cap is set in ConfigureEvm::evm_env(), not here.
1273    /// This test verifies that TempoEvm::new() preserves the cap from the input.
1274    ///
1275    /// [TIP-1000]: <https://docs.tempo.xyz/protocol/tips/tip-1000>
1276    #[test]
1277    fn test_tempo_evm_respects_gas_cap() {
1278        let mut env = evm_env_with_spec(TempoHardfork::T1);
1279        env.cfg_env.tx_gas_limit_cap = TempoHardfork::T1.tx_gas_limit_cap();
1280
1281        let evm = TempoEvm::new(EmptyDB::default(), env);
1282
1283        // Verify gas limit cap is preserved
1284        assert_eq!(
1285            evm.ctx().cfg.tx_gas_limit_cap,
1286            TempoHardfork::T1.tx_gas_limit_cap(),
1287            "TempoEvm should preserve the gas limit cap from input"
1288        );
1289    }
1290
1291    /// Test that gas params differ between T0 and T1 hardforks.
1292    #[test]
1293    fn test_tempo_evm_gas_params_differ_t0_vs_t1() {
1294        // Create T0 and T1 EVMs
1295        let t0_evm = TempoEvm::new(EmptyDB::default(), evm_env_with_spec(TempoHardfork::T0));
1296        let t1_evm = TempoEvm::new(EmptyDB::default(), evm_env_with_spec(TempoHardfork::T1));
1297
1298        // T0 should have default EIP-7702 cost (25,000)
1299        // T1 should have reduced cost (12,500)
1300        let t0_eip7702_cost = t0_evm
1301            .ctx()
1302            .cfg
1303            .gas_params
1304            .tx_eip7702_per_empty_account_cost();
1305        let t1_eip7702_cost = t1_evm
1306            .ctx()
1307            .cfg
1308            .gas_params
1309            .tx_eip7702_per_empty_account_cost();
1310
1311        assert_eq!(t0_eip7702_cost, 25_000, "T0 should have default 25,000");
1312        assert_eq!(t1_eip7702_cost, 12_500, "T1 should have reduced 12,500");
1313        assert_ne!(
1314            t0_eip7702_cost, t1_eip7702_cost,
1315            "Gas params should differ between T0 and T1"
1316        );
1317    }
1318
1319    /// Test that T1 has significantly higher state creation costs.
1320    #[test]
1321    fn test_tempo_evm_t1_state_creation_costs() {
1322        use revm::context_interface::cfg::GasId;
1323
1324        let evm = TempoEvm::new(EmptyDB::default(), evm_env_with_spec(TempoHardfork::T1));
1325        let gas_params = &evm.ctx().cfg.gas_params;
1326
1327        // Verify TIP-1000 state creation cost increases
1328        assert_eq!(
1329            gas_params.get(GasId::sstore_set_without_load_cost()),
1330            250_000,
1331            "T1 SSTORE set cost should be 250,000"
1332        );
1333        assert_eq!(
1334            gas_params.get(GasId::tx_create_cost()),
1335            500_000,
1336            "T1 TX create cost should be 500,000"
1337        );
1338        assert_eq!(
1339            gas_params.get(GasId::create()),
1340            500_000,
1341            "T1 CREATE opcode cost should be 500,000"
1342        );
1343        assert_eq!(
1344            gas_params.get(GasId::new_account_cost()),
1345            250_000,
1346            "T1 new account cost should be 250,000"
1347        );
1348        assert_eq!(
1349            gas_params.get(GasId::code_deposit_cost()),
1350            1_000,
1351            "T1 code deposit cost should be 1,000 per byte"
1352        );
1353    }
1354}