Skip to main content

tempo_revm/
common.rs

1use crate::{TempoInvalidTransaction, TempoTxEnv};
2use alloy_consensus::transaction::{Either, Recovered};
3use alloy_primitives::{Address, Bytes, LogData, TxKind, U256};
4use alloy_sol_types::SolCall;
5use core::marker::PhantomData;
6use revm::{
7    Database,
8    context::{JournalTr, result::EVMError},
9    state::{AccountInfo, Bytecode},
10};
11use tempo_chainspec::hardfork::TempoHardfork;
12use tempo_contracts::precompiles::{
13    DEFAULT_FEE_TOKEN, IFeeManager, IStablecoinDEX, STABLECOIN_DEX_ADDRESS,
14};
15use tempo_precompiles::{
16    TIP_FEE_MANAGER_ADDRESS,
17    error::{Result as TempoResult, TempoPrecompileError},
18    storage::{Handler, PrecompileStorageProvider, StorageCtx},
19    tip_fee_manager::TipFeeManager,
20    tip20::{ITIP20, TIP20Token},
21    tip403_registry::{AuthRole, TIP403Registry},
22};
23use tempo_primitives::{TempoAddressExt, TempoTxEnvelope};
24
25/// Returns true if the calldata is for a TIP-20 function that should trigger fee token inference.
26/// `transfer` and `transferWithMemo` always qualify. `distributeReward` qualifies only before T7,
27/// when the call still moves tokens.
28fn is_tip20_fee_inference_call(spec: TempoHardfork, input: &[u8]) -> bool {
29    input.first_chunk::<4>().is_some_and(|&s| {
30        matches!(
31            s,
32            ITIP20::transferCall::SELECTOR | ITIP20::transferWithMemoCall::SELECTOR
33        ) || (!spec.is_t7() && s == ITIP20::distributeRewardCall::SELECTOR)
34    })
35}
36
37/// Helper trait to abstract over different representations of Tempo transactions.
38#[auto_impl::auto_impl(&, Arc)]
39pub trait TempoTx {
40    /// Returns the transaction's `feeToken` field, if configured.
41    fn fee_token(&self) -> Option<Address>;
42
43    /// Returns true if this is an AA transaction.
44    fn is_aa(&self) -> bool;
45
46    /// Returns an iterator over the transaction's calls.
47    fn calls(&self) -> impl Iterator<Item = (TxKind, &Bytes)>;
48
49    /// Returns the transaction's caller address.
50    fn caller(&self) -> Address;
51}
52
53impl TempoTx for TempoTxEnv {
54    fn fee_token(&self) -> Option<Address> {
55        self.fee_token
56    }
57
58    fn is_aa(&self) -> bool {
59        self.tempo_tx_env.is_some()
60    }
61
62    fn calls(&self) -> impl Iterator<Item = (TxKind, &Bytes)> {
63        if let Some(aa) = self.tempo_tx_env.as_ref() {
64            Either::Left(aa.aa_calls.iter().map(|call| (call.to, &call.input)))
65        } else {
66            Either::Right(core::iter::once((self.inner.kind, &self.inner.data)))
67        }
68    }
69
70    fn caller(&self) -> Address {
71        self.inner.caller
72    }
73}
74
75impl TempoTx for Recovered<TempoTxEnvelope> {
76    fn fee_token(&self) -> Option<Address> {
77        self.inner().fee_token()
78    }
79
80    fn is_aa(&self) -> bool {
81        self.inner().is_aa()
82    }
83
84    fn calls(&self) -> impl Iterator<Item = (TxKind, &Bytes)> {
85        self.inner().calls()
86    }
87
88    fn caller(&self) -> Address {
89        self.signer()
90    }
91}
92
93/// Helper trait to perform Tempo-specific operations on top of different state providers.
94///
95/// We provide blanket implementations for revm database, journal and reth state provider.
96///
97/// The generic marker is used as a workaround to avoid conflicting implementations.
98pub trait TempoStateAccess<M = ()> {
99    /// Error type returned by storage operations.
100    type Error: core::fmt::Display;
101
102    /// Returns [`AccountInfo`] for the given address.
103    fn basic(&mut self, address: Address) -> Result<AccountInfo, Self::Error>;
104
105    /// Returns the storage value for the given address and key.
106    fn sload(&mut self, address: Address, key: U256) -> Result<U256, Self::Error>;
107
108    /// Returns a read-only storage provider for the given spec.
109    fn with_read_only_storage_ctx<R>(&mut self, spec: TempoHardfork, f: impl FnOnce() -> R) -> R
110    where
111        Self: Sized,
112    {
113        StorageCtx::enter(&mut ReadOnlyStorageProvider::new(self, spec), f)
114    }
115
116    /// Resolves user-level or transaction-level fee token preference.
117    fn get_fee_token(
118        &mut self,
119        tx: impl TempoTx,
120        fee_payer: Address,
121        spec: TempoHardfork,
122    ) -> TempoResult<Address>
123    where
124        Self: Sized,
125    {
126        // If there is a fee token explicitly set on the tx type, use that.
127        if let Some(fee_token) = tx.fee_token() {
128            return Ok(fee_token);
129        }
130
131        // If the fee payer is also the msg.sender and the transaction is calling FeeManager to set a
132        // new preference, the newly set preference should be used immediately instead of the
133        // previously stored one
134        if !tx.is_aa()
135            && fee_payer == tx.caller()
136            && let Some((kind, input)) = tx.calls().next()
137            && kind.to() == Some(&TIP_FEE_MANAGER_ADDRESS)
138            && let Ok(call) = IFeeManager::setUserTokenCall::abi_decode(input)
139        {
140            return Ok(call.token);
141        }
142
143        // Check stored user token preference
144        let user_token = self.with_read_only_storage_ctx(spec, || {
145            // ensure TIP_FEE_MANAGER_ADDRESS is loaded
146            TipFeeManager::new().user_tokens[fee_payer].read()
147        })?;
148
149        if !user_token.is_zero() {
150            return Ok(user_token);
151        }
152
153        // Check if the fee can be inferred from the TIP20 token being called
154        if let Some(to) = tx.calls().next().and_then(|(kind, _)| kind.to().copied()) {
155            let can_infer_tip20 =
156                // AA txs only when fee_payer == tx.origin.
157                if tx.is_aa() && fee_payer != tx.caller() {
158                    false
159                }
160                // Otherwise, restricted to TIP-20 calls that move the called token.
161                else {
162                    tx.calls().all(|(kind, input)| {
163                        kind.to() == Some(&to) && is_tip20_fee_inference_call(spec, input)
164                    })
165                }
166            ;
167
168            if can_infer_tip20 && self.is_valid_fee_token(spec, to)? {
169                return Ok(to);
170            }
171        }
172
173        // If calling swapExactAmountOut() or swapExactAmountIn() on the Stablecoin DEX,
174        // use the input token as the fee token (the token that will be pulled from the user).
175        // For AA transactions, this only applies if there's exactly one call.
176        let mut calls = tx.calls();
177        if let Some((kind, input)) = calls.next()
178            && kind.to() == Some(&STABLECOIN_DEX_ADDRESS)
179            && (!tx.is_aa() || calls.next().is_none())
180        {
181            if let Ok(call) = IStablecoinDEX::swapExactAmountInCall::abi_decode(input)
182                && self.is_valid_fee_token(spec, call.tokenIn)?
183            {
184                return Ok(call.tokenIn);
185            } else if let Ok(call) = IStablecoinDEX::swapExactAmountOutCall::abi_decode(input)
186                && self.is_valid_fee_token(spec, call.tokenIn)?
187            {
188                return Ok(call.tokenIn);
189            }
190        }
191
192        // If no fee token is found, default to the first deployed TIP20
193        Ok(DEFAULT_FEE_TOKEN)
194    }
195
196    /// Checks if the given TIP20 token has USD currency.
197    ///
198    /// IMPORTANT: Caller must ensure `fee_token` has a valid TIP20 prefix.
199    fn is_tip20_usd(&mut self, spec: TempoHardfork, fee_token: Address) -> TempoResult<bool>
200    where
201        Self: Sized,
202    {
203        self.with_read_only_storage_ctx(spec, || {
204            // SAFETY: caller must ensure prefix is already checked
205            let token = TIP20Token::from_address_unchecked(fee_token);
206            Ok(token.currency.len()? == 3 && token.currency.read()?.as_str() == "USD")
207        })
208    }
209
210    /// Ensures the given TIP20 token uses USD currency.
211    ///
212    /// IMPORTANT: Caller must ensure `fee_token` has a valid TIP20 prefix.
213    fn ensure_tip20_usd(
214        &mut self,
215        spec: TempoHardfork,
216        fee_token: Address,
217    ) -> Result<(), EVMError<Self::Error, TempoInvalidTransaction>>
218    where
219        Self: Sized,
220    {
221        self.with_read_only_storage_ctx(spec, || {
222            // SAFETY: caller must ensure prefix is already checked
223            let token = TIP20Token::from_address_unchecked(fee_token);
224            let len = token.currency.len()?;
225
226            let currency = if len > 31 {
227                format!("<{len} bytes>")
228            } else {
229                token.currency.read()?
230            };
231
232            if currency.as_str() != "USD" {
233                return Ok(Err(EVMError::Transaction(
234                    TempoInvalidTransaction::FeeTokenNotUsdCurrency {
235                        address: fee_token,
236                        currency,
237                    },
238                )));
239            }
240
241            Ok(Ok(()))
242        })
243        .map_err(|err: TempoPrecompileError| EVMError::Custom(err.to_string()))?
244    }
245
246    /// Checks if the given token can be used as a fee token.
247    fn is_valid_fee_token(&mut self, spec: TempoHardfork, fee_token: Address) -> TempoResult<bool>
248    where
249        Self: Sized,
250    {
251        // Must have TIP20 prefix to be a valid fee token
252        if !fee_token.is_tip20() {
253            return Ok(false);
254        }
255
256        // Ensure the currency is USD
257        self.is_tip20_usd(spec, fee_token)
258    }
259
260    /// Checks if a fee token is paused.
261    fn is_fee_token_paused(&mut self, spec: TempoHardfork, fee_token: Address) -> TempoResult<bool>
262    where
263        Self: Sized,
264    {
265        self.with_read_only_storage_ctx(spec, || {
266            let token = TIP20Token::from_address(fee_token)?;
267            token.paused()
268        })
269    }
270
271    /// Checks if the fee payer can transfer the fee token to the fee manager.
272    fn can_fee_payer_transfer(
273        &mut self,
274        fee_token: Address,
275        fee_payer: Address,
276        spec: TempoHardfork,
277    ) -> TempoResult<bool>
278    where
279        Self: Sized,
280    {
281        self.with_read_only_storage_ctx(spec, || {
282            let token = TIP20Token::from_address(fee_token)?;
283            if spec.is_t1c() {
284                // Check both the fee payer and the fee manager is authorized
285                token.is_transfer_authorized(fee_payer, TIP_FEE_MANAGER_ADDRESS)
286            } else {
287                let policy_id = token.transfer_policy_id.read()?;
288                TIP403Registry::new().is_authorized_as(policy_id, fee_payer, AuthRole::sender())
289            }
290        })
291    }
292
293    /// Returns the balance of the given token for the given account.
294    ///
295    /// IMPORTANT: the caller must ensure `token` is a valid TIP20Token address.
296    fn get_token_balance(
297        &mut self,
298        token: Address,
299        account: Address,
300        spec: TempoHardfork,
301    ) -> TempoResult<U256>
302    where
303        Self: Sized,
304    {
305        self.with_read_only_storage_ctx(spec, || {
306            // Load the token balance for the given account.
307            TIP20Token::from_address(token)?.balances[account].read()
308        })
309    }
310}
311
312impl<DB: Database> TempoStateAccess<()> for DB {
313    type Error = DB::Error;
314
315    fn basic(&mut self, address: Address) -> Result<AccountInfo, Self::Error> {
316        self.basic(address).map(Option::unwrap_or_default)
317    }
318
319    fn sload(&mut self, address: Address, key: U256) -> Result<U256, Self::Error> {
320        self.storage(address, key)
321    }
322}
323
324impl<T: JournalTr> TempoStateAccess<((), ())> for T {
325    type Error = <T::Database as Database>::Error;
326
327    fn basic(&mut self, address: Address) -> Result<AccountInfo, Self::Error> {
328        self.load_account(address).map(|s| s.data.info.clone())
329    }
330
331    fn sload(&mut self, address: Address, key: U256) -> Result<U256, Self::Error> {
332        JournalTr::sload(self, address, key).map(|s| s.data)
333    }
334}
335
336#[cfg(feature = "reth")]
337impl<T: reth_storage_api::StateProvider> TempoStateAccess<((), (), ())> for T {
338    type Error = reth_evm::execute::ProviderError;
339
340    fn basic(&mut self, address: Address) -> Result<AccountInfo, Self::Error> {
341        self.basic_account(&address)
342            .map(Option::unwrap_or_default)
343            .map(Into::into)
344    }
345
346    fn sload(&mut self, address: Address, key: U256) -> Result<U256, Self::Error> {
347        self.storage(address, key.into())
348            .map(Option::unwrap_or_default)
349    }
350}
351
352/// Read-only storage provider that wraps a `TempoStateAccess`.
353///
354/// Implements `PrecompileStorageProvider` by delegating read operations to the backend
355/// and returning errors for write operations.
356///
357/// The marker generic `M` selects which `TempoStateAccess<M>` impl to use for the backend.
358struct ReadOnlyStorageProvider<'a, S, M = ()> {
359    state: &'a mut S,
360    spec: TempoHardfork,
361    _marker: PhantomData<M>,
362}
363
364impl<'a, S, M> ReadOnlyStorageProvider<'a, S, M>
365where
366    S: TempoStateAccess<M>,
367{
368    /// Creates a new read-only storage provider.
369    fn new(state: &'a mut S, spec: TempoHardfork) -> Self {
370        Self {
371            state,
372            spec,
373            _marker: PhantomData,
374        }
375    }
376}
377
378impl<S, M> PrecompileStorageProvider for ReadOnlyStorageProvider<'_, S, M>
379where
380    S: TempoStateAccess<M>,
381{
382    fn spec(&self) -> TempoHardfork {
383        self.spec
384    }
385
386    fn amsterdam_eip8037_enabled(&self) -> bool {
387        // Read-only context never executes TIP-1016 state gas paths (set_code, fill_state_gas);
388        // the flag is not propagated through `with_read_only_storage_ctx`, so default to `false`.
389        false
390    }
391
392    fn gas_limit(&self) -> u64 {
393        0
394    }
395
396    fn is_static(&self) -> bool {
397        // read-only operations should always be static
398        true
399    }
400
401    fn sload(&mut self, address: Address, key: U256) -> TempoResult<U256> {
402        let _ = self
403            .state
404            .basic(address)
405            .map_err(|e| TempoPrecompileError::Fatal(e.to_string()))?;
406        self.state
407            .sload(address, key)
408            .map_err(|e| TempoPrecompileError::Fatal(e.to_string()))
409    }
410
411    fn with_account_info(
412        &mut self,
413        address: Address,
414        f: &mut dyn FnMut(&AccountInfo),
415    ) -> TempoResult<()> {
416        let info = self
417            .state
418            .basic(address)
419            .map_err(|e| TempoPrecompileError::Fatal(e.to_string()))?;
420        f(&info);
421        Ok(())
422    }
423
424    // No-op methods are unimplemented in read-only context.
425    fn chain_id(&self) -> u64 {
426        unreachable!("'chain_id' not implemented in read-only context yet")
427    }
428
429    fn timestamp(&self) -> U256 {
430        unreachable!("'timestamp' not implemented in read-only context yet")
431    }
432
433    fn beneficiary(&self) -> Address {
434        unreachable!("'beneficiary' not implemented in read-only context yet")
435    }
436
437    fn block_number(&self) -> u64 {
438        unreachable!("'block_number' not implemented in read-only context yet")
439    }
440
441    fn tload(&mut self, _: Address, _: U256) -> TempoResult<U256> {
442        unreachable!("'tload' not implemented in read-only context yet")
443    }
444
445    fn gas_used(&self) -> u64 {
446        unreachable!("'gas_used' not implemented in read-only context yet")
447    }
448
449    fn state_gas_used(&self) -> u64 {
450        unreachable!("'state_gas_used' not implemented in read-only context yet")
451    }
452
453    fn gas_refunded(&self) -> i64 {
454        unreachable!("'gas_refunded' not implemented in read-only context yet")
455    }
456
457    fn reservoir(&self) -> u64 {
458        unreachable!("'reservoir' not implemented in read-only context yet")
459    }
460
461    // Write operations are not supported in read-only context
462    fn sstore(&mut self, _: Address, _: U256, _: U256) -> TempoResult<()> {
463        unreachable!("'sstore' not supported in read-only context")
464    }
465
466    fn set_code(&mut self, _: Address, _: Bytecode) -> TempoResult<()> {
467        unreachable!("'set_code' not supported in read-only context")
468    }
469
470    fn emit_event(&mut self, _: Address, _: LogData) -> TempoResult<()> {
471        unreachable!("'emit_event' not supported in read-only context")
472    }
473
474    fn tstore(&mut self, _: Address, _: U256, _: U256) -> TempoResult<()> {
475        unreachable!("'tstore' not supported in read-only context")
476    }
477
478    fn deduct_gas(&mut self, _: u64) -> TempoResult<()> {
479        unreachable!("'deduct_gas' not supported in read-only context")
480    }
481
482    fn refund_gas(&mut self, _: i64) {
483        unreachable!("'refund_gas' not supported in read-only context")
484    }
485
486    fn checkpoint(&mut self) -> revm::context::journaled_state::JournalCheckpoint {
487        unreachable!("'checkpoint' not supported in read-only context")
488    }
489
490    fn checkpoint_commit(&mut self, _: revm::context::journaled_state::JournalCheckpoint) {
491        unreachable!("'checkpoint_commit' not supported in read-only context")
492    }
493
494    fn checkpoint_revert(&mut self, _: revm::context::journaled_state::JournalCheckpoint) {
495        unreachable!("'checkpoint_revert' not supported in read-only context")
496    }
497
498    fn set_tip1060_storage_credits(&mut self, _enabled: bool) {
499        // Read-only storage never runs TIP-1060 accounting.
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506    use crate::{TempoBlockEnv, TempoEvm};
507    use alloy_primitives::{address, uint};
508    use reth_evm::EvmInternals;
509    use revm::{
510        Context, MainContext, context::TxEnv, database::EmptyDB,
511        interpreter::instructions::utility::IntoU256,
512    };
513    use tempo_precompiles::{
514        PATH_USD_ADDRESS,
515        storage::{StorageCtx, evm::EvmPrecompileStorageProvider},
516        test_util::TIP20Setup,
517        tip20::{IRolesAuth::*, ITIP20::*, TIP20Token, slots as tip20_slots},
518        tip403_registry::{ITIP403Registry, TIP403Registry},
519    };
520
521    #[test]
522    fn test_get_fee_token_fee_token_set() -> eyre::Result<()> {
523        let caller = Address::random();
524        let fee_token = Address::random();
525
526        let tx_env = TxEnv {
527            data: Bytes::new(),
528            caller,
529            ..Default::default()
530        };
531        let tx = TempoTxEnv {
532            inner: tx_env,
533            fee_token: Some(fee_token),
534            ..Default::default()
535        };
536
537        let mut db = EmptyDB::default();
538        let token = db.get_fee_token(tx, caller, TempoHardfork::Genesis)?;
539        assert_eq!(token, fee_token);
540        Ok(())
541    }
542
543    #[test]
544    fn test_get_fee_token_fee_manager() -> eyre::Result<()> {
545        let caller = Address::random();
546        let token = Address::random();
547
548        let call = IFeeManager::setUserTokenCall { token };
549        let tx_env = TxEnv {
550            data: call.abi_encode().into(),
551            kind: TxKind::Call(TIP_FEE_MANAGER_ADDRESS),
552            caller,
553            ..Default::default()
554        };
555        let tx = TempoTxEnv {
556            inner: tx_env,
557            ..Default::default()
558        };
559
560        let mut db = EmptyDB::default();
561        let result_token = db.get_fee_token(tx, caller, TempoHardfork::Genesis)?;
562        assert_eq!(result_token, token);
563        Ok(())
564    }
565
566    #[test]
567    fn test_get_fee_token_user_token_set() -> eyre::Result<()> {
568        let caller = Address::random();
569        let user_token = Address::random();
570
571        // Set user stored token preference in the FeeManager
572        let mut db = revm::database::CacheDB::new(EmptyDB::default());
573        let user_slot = TipFeeManager::new().user_tokens[caller].slot();
574        db.insert_account_storage(TIP_FEE_MANAGER_ADDRESS, user_slot, user_token.into_u256())
575            .unwrap();
576
577        let result_token =
578            db.get_fee_token(TempoTxEnv::default(), caller, TempoHardfork::Genesis)?;
579        assert_eq!(result_token, user_token);
580        Ok(())
581    }
582
583    #[test]
584    fn test_get_fee_token_tip20() -> eyre::Result<()> {
585        let caller = Address::random();
586        let tip20_token = Address::random();
587
588        let tx_env = TxEnv {
589            data: Bytes::from_static(b"transfer_data"),
590            kind: TxKind::Call(tip20_token),
591            caller,
592            ..Default::default()
593        };
594        let tx = TempoTxEnv {
595            inner: tx_env,
596            ..Default::default()
597        };
598
599        let mut db = EmptyDB::default();
600        let result_token = db.get_fee_token(tx, caller, TempoHardfork::Genesis)?;
601        assert_eq!(result_token, DEFAULT_FEE_TOKEN);
602        Ok(())
603    }
604
605    #[test]
606    fn test_get_fee_token_fallback() -> eyre::Result<()> {
607        let caller = Address::random();
608        let tx_env = TxEnv {
609            caller,
610            ..Default::default()
611        };
612        let tx = TempoTxEnv {
613            inner: tx_env,
614            ..Default::default()
615        };
616
617        let mut db = EmptyDB::default();
618        let result_token = db.get_fee_token(tx, caller, TempoHardfork::Genesis)?;
619        // Should fallback to DEFAULT_FEE_TOKEN when no preferences are found
620        assert_eq!(result_token, DEFAULT_FEE_TOKEN);
621        Ok(())
622    }
623
624    #[test]
625    fn test_get_fee_token_stablecoin_dex() -> eyre::Result<()> {
626        let caller = Address::random();
627        // Use pathUSD as token_in since it's a known valid USD fee token
628        let token_in = DEFAULT_FEE_TOKEN;
629        let token_out = address!("0x20C0000000000000000000000000000000000001");
630
631        // Test swapExactAmountIn
632        let call = IStablecoinDEX::swapExactAmountInCall {
633            tokenIn: token_in,
634            tokenOut: token_out,
635            amountIn: 1000,
636            minAmountOut: 900,
637        };
638
639        let tx_env = TxEnv {
640            data: call.abi_encode().into(),
641            kind: TxKind::Call(STABLECOIN_DEX_ADDRESS),
642            caller,
643            ..Default::default()
644        };
645        let tx = TempoTxEnv {
646            inner: tx_env,
647            ..Default::default()
648        };
649
650        let mut db = EmptyDB::default();
651        let token = db.get_fee_token(tx, caller, TempoHardfork::Genesis)?;
652        assert_eq!(token, token_in);
653
654        // Test swapExactAmountOut
655        let call = IStablecoinDEX::swapExactAmountOutCall {
656            tokenIn: token_in,
657            tokenOut: token_out,
658            amountOut: 900,
659            maxAmountIn: 1000,
660        };
661
662        let tx_env = TxEnv {
663            data: call.abi_encode().into(),
664            kind: TxKind::Call(STABLECOIN_DEX_ADDRESS),
665            caller,
666            ..Default::default()
667        };
668
669        let tx = TempoTxEnv {
670            inner: tx_env,
671            ..Default::default()
672        };
673
674        let token = db.get_fee_token(tx, caller, TempoHardfork::Genesis)?;
675        assert_eq!(token, token_in);
676
677        Ok(())
678    }
679
680    #[test]
681    fn test_read_token_balance_typed_storage() -> eyre::Result<()> {
682        let token_address = PATH_USD_ADDRESS;
683        let account = Address::random();
684        let expected_balance = U256::from(1000u64);
685
686        // Set up CacheDB with balance
687        let mut db = revm::database::CacheDB::new(EmptyDB::default());
688        let balance_slot = TIP20Token::from_address(token_address)?.balances[account].slot();
689        db.insert_account_storage(token_address, balance_slot, expected_balance)?;
690
691        // Read balance using typed storage
692        let balance = db.get_token_balance(token_address, account, TempoHardfork::Genesis)?;
693        assert_eq!(balance, expected_balance);
694
695        Ok(())
696    }
697
698    #[test]
699    fn test_is_tip20_fee_inference_call() {
700        for spec in [(TempoHardfork::T6), (TempoHardfork::T7)] {
701            // Allowed selectors
702            assert!(is_tip20_fee_inference_call(spec, &transferCall::SELECTOR));
703            assert!(is_tip20_fee_inference_call(
704                spec,
705                &transferWithMemoCall::SELECTOR
706            ));
707            // Only allowed pre-T7
708            assert_eq!(
709                is_tip20_fee_inference_call(spec, &distributeRewardCall::SELECTOR),
710                !spec.is_t7()
711            );
712
713            // Disallowed selectors
714            assert!(!is_tip20_fee_inference_call(spec, &grantRoleCall::SELECTOR));
715            assert!(!is_tip20_fee_inference_call(spec, &mintCall::SELECTOR));
716            assert!(!is_tip20_fee_inference_call(spec, &approveCall::SELECTOR));
717
718            // Edge cases
719            assert!(!is_tip20_fee_inference_call(spec, &[]));
720            assert!(!is_tip20_fee_inference_call(spec, &[0x00, 0x01, 0x02]));
721        }
722    }
723
724    #[test]
725    fn test_is_fee_token_paused() -> eyre::Result<()> {
726        let token_address = PATH_USD_ADDRESS;
727        let mut db = revm::database::CacheDB::new(EmptyDB::default());
728
729        // Default (unpaused) returns false
730        assert!(!db.is_fee_token_paused(TempoHardfork::Genesis, token_address)?);
731
732        // Set paused=true
733        db.insert_account_storage(token_address, tip20_slots::PAUSED, U256::from(1))?;
734        assert!(db.is_fee_token_paused(TempoHardfork::Genesis, token_address)?);
735
736        Ok(())
737    }
738
739    #[test]
740    fn test_is_tip20_usd() -> eyre::Result<()> {
741        let fee_token = PATH_USD_ADDRESS;
742
743        // Short string encoding: left-aligned data + length*2 in LSB
744        let cases: &[(U256, bool, &str)] = &[
745            // "USD" = 0x555344, len=3, LSB=6 -> true
746            (
747                uint!(0x5553440000000000000000000000000000000000000000000000000000000006_U256),
748                true,
749                "USD",
750            ),
751            // "EUR" = 0x455552, len=3, LSB=6 -> false (wrong content)
752            (
753                uint!(0x4555520000000000000000000000000000000000000000000000000000000006_U256),
754                false,
755                "EUR",
756            ),
757            // "US" = 0x5553, len=2, LSB=4 -> false (wrong length)
758            (
759                uint!(0x5553000000000000000000000000000000000000000000000000000000000004_U256),
760                false,
761                "US",
762            ),
763            // empty -> false
764            (U256::ZERO, false, "empty"),
765        ];
766
767        for (currency_value, expected, label) in cases {
768            let mut db = revm::database::CacheDB::new(EmptyDB::default());
769            db.insert_account_storage(fee_token, tip20_slots::CURRENCY, *currency_value)?;
770
771            let is_usd = db.is_tip20_usd(TempoHardfork::Genesis, fee_token)?;
772            assert_eq!(is_usd, *expected, "currency '{label}' failed");
773        }
774
775        Ok(())
776    }
777
778    #[test]
779    fn test_tip20_currency_for_error_does_not_read_long_currency() -> eyre::Result<()> {
780        let fee_token = PATH_USD_ADDRESS;
781        let mut db = revm::database::CacheDB::new(EmptyDB::default());
782        let len = 1024usize;
783
784        db.insert_account_storage(fee_token, tip20_slots::CURRENCY, U256::from(len * 2 + 1))?;
785
786        let err = db
787            .ensure_tip20_usd(TempoHardfork::Genesis, fee_token)
788            .expect_err("long non-USD currency returns an EVM error");
789        assert!(matches!(
790            err,
791            EVMError::Transaction(
792                TempoInvalidTransaction::FeeTokenNotUsdCurrency {
793                    currency,
794                    ..
795                }
796            ) if currency == "<1024 bytes>"
797        ));
798
799        Ok(())
800    }
801
802    #[test]
803    fn test_can_fee_payer_transfer_t1c() -> eyre::Result<()> {
804        let admin = Address::random();
805        let fee_payer = Address::random();
806        let db = revm::database::CacheDB::new(EmptyDB::new());
807        let mut evm = TempoEvm::new(
808            Context::mainnet()
809                .with_db(db)
810                .with_block(TempoBlockEnv::default())
811                .with_cfg(Default::default())
812                .with_tx(Default::default()),
813            (),
814        );
815
816        // Set up token with whitelist policy
817        let policy_id = {
818            let ctx = &mut evm.ctx;
819            let internals =
820                EvmInternals::new(&mut ctx.journaled_state, &ctx.block, &ctx.cfg, &ctx.tx);
821            let mut provider = EvmPrecompileStorageProvider::new_max_gas(internals, &ctx.cfg);
822            StorageCtx::enter(&mut provider, || -> eyre::Result<u64> {
823                TIP20Setup::path_usd(admin).apply()?;
824                let mut registry = TIP403Registry::new();
825                registry.initialize()?;
826
827                let policy_id = registry.create_policy(
828                    admin,
829                    ITIP403Registry::createPolicyCall {
830                        admin,
831                        policyType: ITIP403Registry::PolicyType::WHITELIST,
832                    },
833                )?;
834                TIP20Token::from_address(PATH_USD_ADDRESS)?.change_transfer_policy_id(
835                    admin,
836                    ITIP20::changeTransferPolicyIdCall {
837                        newPolicyId: policy_id,
838                    },
839                )?;
840                registry.modify_policy_whitelist(
841                    admin,
842                    ITIP403Registry::modifyPolicyWhitelistCall {
843                        policyId: policy_id,
844                        account: fee_payer,
845                        allowed: true,
846                    },
847                )?;
848                Ok(policy_id)
849            })?
850        };
851
852        assert!(evm.ctx.journaled_state.can_fee_payer_transfer(
853            PATH_USD_ADDRESS,
854            fee_payer,
855            TempoHardfork::T1B
856        )?);
857
858        // Post T1C fails if fee payer not authorized
859        assert!(!evm.ctx.journaled_state.can_fee_payer_transfer(
860            PATH_USD_ADDRESS,
861            fee_payer,
862            TempoHardfork::T1C
863        )?);
864
865        // Whitelist FeeManager
866        {
867            let ctx = &mut evm.ctx;
868            let internals =
869                EvmInternals::new(&mut ctx.journaled_state, &ctx.block, &ctx.cfg, &ctx.tx);
870            let mut provider = EvmPrecompileStorageProvider::new_max_gas(internals, &ctx.cfg);
871            StorageCtx::enter(&mut provider, || {
872                TIP403Registry::new().modify_policy_whitelist(
873                    admin,
874                    ITIP403Registry::modifyPolicyWhitelistCall {
875                        policyId: policy_id,
876                        account: TIP_FEE_MANAGER_ADDRESS,
877                        allowed: true,
878                    },
879                )
880            })?;
881        }
882
883        assert!(evm.ctx.journaled_state.can_fee_payer_transfer(
884            PATH_USD_ADDRESS,
885            fee_payer,
886            TempoHardfork::T1B
887        )?);
888
889        assert!(evm.ctx.journaled_state.can_fee_payer_transfer(
890            PATH_USD_ADDRESS,
891            fee_payer,
892            TempoHardfork::T1C
893        )?);
894
895        Ok(())
896    }
897}