Skip to main content

tempo_revm/
common.rs

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