tempo_revm/
common.rs

1use crate::TempoTxEnv;
2use alloy_consensus::transaction::{Either, Recovered};
3use alloy_primitives::{Address, Bytes, TxKind, U256, uint};
4use alloy_sol_types::SolCall;
5use revm::{
6    Database, context::JournalTr, interpreter::instructions::utility::IntoAddress,
7    state::AccountInfo,
8};
9use tempo_chainspec::hardfork::TempoHardfork;
10use tempo_contracts::precompiles::{
11    DEFAULT_FEE_TOKEN_POST_ALLEGRETTO, DEFAULT_FEE_TOKEN_PRE_ALLEGRETTO, IFeeManager,
12    IStablecoinExchange, ITIP403Registry, PATH_USD_ADDRESS, STABLECOIN_EXCHANGE_ADDRESS,
13};
14use tempo_precompiles::{
15    TIP_FEE_MANAGER_ADDRESS, TIP403_REGISTRY_ADDRESS,
16    storage::{self, Storable, StorableType, double_mapping_slot, slots::mapping_slot},
17    tip_fee_manager,
18    tip20::{self, is_tip20_prefix},
19    tip403_registry,
20};
21use tempo_primitives::TempoTxEnvelope;
22
23/// Value of [`tip20::slots::CURRENCY`] when configured currency is USD.
24const USD_CURRENCY_SLOT_VALUE: U256 =
25    uint!(0x5553440000000000000000000000000000000000000000000000000000000006_U256);
26
27/// Helper trait to abstract over different representations of Tempo transactions.
28#[auto_impl::auto_impl(&)]
29pub trait TempoTx {
30    /// Returns the transaction's `feeToken` field, if configured.
31    fn fee_token(&self) -> Option<Address>;
32
33    /// Returns true if this is an AA transaction.
34    fn is_aa(&self) -> bool;
35
36    /// Returns an iterator over the transaction's calls.
37    fn calls(&self) -> impl Iterator<Item = (TxKind, &Bytes)>;
38
39    /// Returns the transaction's caller address.
40    fn caller(&self) -> Address;
41}
42
43impl TempoTx for TempoTxEnv {
44    fn fee_token(&self) -> Option<Address> {
45        self.fee_token
46    }
47
48    fn is_aa(&self) -> bool {
49        self.tempo_tx_env.is_some()
50    }
51
52    fn calls(&self) -> impl Iterator<Item = (TxKind, &Bytes)> {
53        if let Some(aa) = self.tempo_tx_env.as_ref() {
54            Either::Left(aa.aa_calls.iter().map(|call| (call.to, &call.input)))
55        } else {
56            Either::Right(core::iter::once((self.inner.kind, &self.inner.data)))
57        }
58    }
59
60    fn caller(&self) -> Address {
61        self.inner.caller
62    }
63}
64
65impl TempoTx for Recovered<TempoTxEnvelope> {
66    fn fee_token(&self) -> Option<Address> {
67        self.inner().fee_token()
68    }
69
70    fn is_aa(&self) -> bool {
71        self.inner().is_aa()
72    }
73
74    fn calls(&self) -> impl Iterator<Item = (TxKind, &Bytes)> {
75        self.inner().calls()
76    }
77
78    fn caller(&self) -> Address {
79        self.signer()
80    }
81}
82
83/// Helper trait to perform Tempo-specific operations on top of different state providers.
84///
85/// We provide blanket implementations for revm database, journal and reth state provider.
86///
87/// Generic parameter is used as a workaround to avoid conflicting implementations.
88pub trait TempoStateAccess<T> {
89    type Error;
90
91    /// Returns [`AccountInfo`] for the given address.
92    fn basic(&mut self, address: Address) -> Result<AccountInfo, Self::Error>;
93
94    /// Returns the storage value for the given address and key.
95    fn sload(&mut self, address: Address, key: U256) -> Result<U256, Self::Error>;
96
97    /// Resolves user-level of transaction-level fee token preference.
98    fn get_fee_token(
99        &mut self,
100        tx: impl TempoTx,
101        validator: Address,
102        fee_payer: Address,
103        spec: TempoHardfork,
104    ) -> Result<Address, Self::Error> {
105        // If there is a fee token explicitly set on the tx type, use that.
106        if let Some(fee_token) = tx.fee_token() {
107            return Ok(fee_token);
108        }
109
110        // If the fee payer is also the msg.sender and the transaction is calling FeeManager to set a
111        // new preference, the newly set preference should be used immediately instead of the
112        // previously stored one
113        if !tx.is_aa()
114            && fee_payer == tx.caller()
115            && let Some((kind, input)) = tx.calls().next()
116            && kind.to() == Some(&TIP_FEE_MANAGER_ADDRESS)
117            && let Ok(call) = IFeeManager::setUserTokenCall::abi_decode(input)
118        {
119            return Ok(call.token);
120        }
121
122        let user_slot = mapping_slot(fee_payer, tip_fee_manager::slots::USER_TOKENS);
123        // ensure TIP_FEE_MANAGER_ADDRESS is loaded
124        self.basic(TIP_FEE_MANAGER_ADDRESS)?;
125        let stored_user_token = self
126            .sload(TIP_FEE_MANAGER_ADDRESS, user_slot)?
127            .into_address();
128
129        if !stored_user_token.is_zero() {
130            return Ok(stored_user_token);
131        }
132
133        // If tx.to() is a TIP-20 token, use that token as the fee token
134        if let Some(to) = tx.calls().next().and_then(|(kind, _)| kind.to().copied())
135            && tx.calls().all(|(kind, _)| kind.to() == Some(&to))
136            && self.is_valid_fee_token(to, spec)?
137        {
138            return Ok(to);
139        }
140
141        // If calling swapExactAmountOut() or swapExactAmountIn() on the Stablecoin Exchange,
142        // use the input token as the fee token (the token that will be pulled from the user).
143        // For AA transactions, this only applies if there's exactly one call.
144        if spec.is_allegretto() {
145            let mut calls = tx.calls();
146            if let Some((kind, input)) = calls.next()
147                && kind.to() == Some(&STABLECOIN_EXCHANGE_ADDRESS)
148                && (!tx.is_aa() || calls.next().is_none())
149            {
150                if let Ok(call) = IStablecoinExchange::swapExactAmountInCall::abi_decode(input)
151                    && self.is_valid_fee_token(call.tokenIn, spec)?
152                {
153                    return Ok(call.tokenIn);
154                } else if let Ok(call) =
155                    IStablecoinExchange::swapExactAmountOutCall::abi_decode(input)
156                    && self.is_valid_fee_token(call.tokenIn, spec)?
157                {
158                    return Ok(call.tokenIn);
159                }
160            }
161        }
162
163        // Post-allegretto, if no fee token is found, default to the first deployed TIP20
164        if spec.is_allegretto() {
165            Ok(DEFAULT_FEE_TOKEN_POST_ALLEGRETTO)
166        } else {
167            // Pre-allegretto fall back to the validator fee token preference or the default to the
168            // first TIP20 deployed after PathUSD
169            let validator_slot = mapping_slot(validator, tip_fee_manager::slots::VALIDATOR_TOKENS);
170            let validator_fee_token = self
171                .sload(TIP_FEE_MANAGER_ADDRESS, validator_slot)?
172                .into_address();
173
174            if validator_fee_token.is_zero() {
175                Ok(DEFAULT_FEE_TOKEN_PRE_ALLEGRETTO)
176            } else {
177                Ok(validator_fee_token)
178            }
179        }
180    }
181
182    /// Checks if the given token can be used as a fee token.
183    fn is_valid_fee_token(
184        &mut self,
185        fee_token: Address,
186        spec: TempoHardfork,
187    ) -> Result<bool, Self::Error> {
188        // Ensure it's a TIP20
189        if !is_tip20_prefix(fee_token) {
190            return Ok(false);
191        }
192
193        // Pre-Allegretto: PathUSD cannot be used as fee token
194        if !spec.is_allegretto() && fee_token == PATH_USD_ADDRESS {
195            return Ok(false);
196        }
197
198        // Ensure the currency is USD
199        // load fee token account to ensure that we can load storage for it.
200        self.basic(fee_token)?;
201        Ok(self.sload(fee_token, tip20::slots::CURRENCY)? == USD_CURRENCY_SLOT_VALUE)
202    }
203
204    /// Checks if the fee payer can transfer a given token (is not blacklisted).
205    fn can_fee_payer_transfer(
206        &mut self,
207        fee_token: Address,
208        fee_payer: Address,
209    ) -> Result<bool, Self::Error> {
210        // Ensure it's a TIP20
211        if !is_tip20_prefix(fee_token) {
212            return Ok(false);
213        }
214
215        // Ensure the fee payer is not blacklisted
216        let Ok(transfer_policy_id) = storage::packing::extract_packed_value::<1, u64>(
217            self.sload(fee_token, tip20::slots::TRANSFER_POLICY_ID)?,
218            tip20::slots::TRANSFER_POLICY_ID_OFFSET,
219            <u64 as StorableType>::BYTES,
220        ) else {
221            // Should be infallible, but if unable to extract packed value, assume blacklisted.
222            tracing::warn!(%fee_token, "failed to extract transfer_policy_id from packed value");
223            return Ok(false);
224        };
225
226        // NOTE: must be synced with `fn is_authorized_internal` @crates/precompiles/src/tip403_registry/mod.rs
227        let auth = {
228            // Special case for always-allow and always-reject policies
229            if transfer_policy_id < 2 {
230                // policyId == 0 is the "always-reject" policy
231                // policyId == 1 is the "always-allow" policy
232                return Ok(transfer_policy_id == 1);
233            }
234
235            let policy_data_word = self.sload(
236                TIP403_REGISTRY_ADDRESS,
237                mapping_slot(
238                    transfer_policy_id.to_be_bytes(),
239                    tip403_registry::slots::POLICY_DATA,
240                ),
241            )?;
242            let Ok(data) = tip403_registry::PolicyData::from_evm_words([policy_data_word]) else {
243                tracing::warn!(
244                    transfer_policy_id,
245                    "failed to parse PolicyData from storage"
246                );
247                return Ok(false);
248            };
249            let Ok(policy_type) = data.policy_type.try_into() else {
250                tracing::warn!(transfer_policy_id, policy_type = ?data.policy_type, "invalid policy type");
251                return Ok(false);
252            };
253
254            let is_in_set = self
255                .sload(
256                    TIP403_REGISTRY_ADDRESS,
257                    double_mapping_slot(
258                        transfer_policy_id.to_be_bytes(),
259                        fee_payer,
260                        tip403_registry::slots::POLICY_SET,
261                    ),
262                )?
263                .to::<bool>();
264
265            match policy_type {
266                ITIP403Registry::PolicyType::WHITELIST => is_in_set,
267                ITIP403Registry::PolicyType::BLACKLIST => !is_in_set,
268                ITIP403Registry::PolicyType::__Invalid => false,
269            }
270        };
271
272        Ok(auth)
273    }
274
275    /// Returns the balance of the given token for the given account.
276    fn get_token_balance(&mut self, token: Address, account: Address) -> Result<U256, Self::Error> {
277        // Query the user's balance in the determined fee token's TIP20 contract
278        let balance_slot = mapping_slot(account, tip20::slots::BALANCES);
279        // Load fee token account to ensure that we can load storage for it.
280        self.basic(token)?;
281        self.sload(token, balance_slot)
282    }
283}
284
285impl<DB: Database> TempoStateAccess<()> for DB {
286    type Error = DB::Error;
287
288    fn basic(&mut self, address: Address) -> Result<AccountInfo, Self::Error> {
289        self.basic(address).map(Option::unwrap_or_default)
290    }
291
292    fn sload(&mut self, address: Address, key: U256) -> Result<U256, Self::Error> {
293        self.storage(address, key)
294    }
295}
296
297impl<T: JournalTr> TempoStateAccess<((), ())> for T {
298    type Error = <T::Database as Database>::Error;
299
300    fn basic(&mut self, address: Address) -> Result<AccountInfo, Self::Error> {
301        self.load_account(address).map(|s| s.data.info.clone())
302    }
303
304    fn sload(&mut self, address: Address, key: U256) -> Result<U256, Self::Error> {
305        JournalTr::sload(self, address, key).map(|s| s.data)
306    }
307}
308
309#[cfg(feature = "reth")]
310impl<T: reth_storage_api::StateProvider> TempoStateAccess<((), (), ())> for T {
311    type Error = reth_evm::execute::ProviderError;
312
313    fn basic(&mut self, address: Address) -> Result<AccountInfo, Self::Error> {
314        self.basic_account(&address)
315            .map(Option::unwrap_or_default)
316            .map(Into::into)
317    }
318
319    fn sload(&mut self, address: Address, key: U256) -> Result<U256, Self::Error> {
320        self.storage(address, key.into())
321            .map(Option::unwrap_or_default)
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use revm::{context::TxEnv, database::EmptyDB, interpreter::instructions::utility::IntoU256};
329
330    #[test]
331    fn test_get_fee_token_fee_token_set() -> eyre::Result<()> {
332        let caller = Address::random();
333        let fee_token = Address::random();
334
335        let tx_env = TxEnv {
336            data: Bytes::new(),
337            caller,
338            ..Default::default()
339        };
340        let tx = TempoTxEnv {
341            inner: tx_env,
342            fee_token: Some(fee_token),
343            ..Default::default()
344        };
345
346        let mut db = EmptyDB::default();
347        let token = db.get_fee_token(tx, Address::ZERO, caller, TempoHardfork::default())?;
348        assert_eq!(token, fee_token);
349        Ok(())
350    }
351
352    #[test]
353    fn test_get_fee_token_fee_manager() -> eyre::Result<()> {
354        let caller = Address::random();
355        let token = Address::random();
356
357        let call = IFeeManager::setUserTokenCall { token };
358        let tx_env = TxEnv {
359            data: call.abi_encode().into(),
360            kind: TxKind::Call(TIP_FEE_MANAGER_ADDRESS),
361            caller,
362            ..Default::default()
363        };
364        let tx = TempoTxEnv {
365            inner: tx_env,
366            ..Default::default()
367        };
368
369        let mut db = EmptyDB::default();
370        let result_token =
371            db.get_fee_token(tx, Address::ZERO, caller, TempoHardfork::Allegretto)?;
372        assert_eq!(result_token, token);
373        Ok(())
374    }
375
376    #[test]
377    fn test_get_fee_token_user_token_set() -> eyre::Result<()> {
378        let caller = Address::random();
379        let user_token = Address::random();
380
381        // Set user stored token preference in the FeeManager
382        let mut db = revm::database::CacheDB::new(EmptyDB::default());
383        let user_slot = mapping_slot(caller, tip_fee_manager::slots::USER_TOKENS);
384        db.insert_account_storage(TIP_FEE_MANAGER_ADDRESS, user_slot, user_token.into_u256())
385            .unwrap();
386
387        let result_token = db.get_fee_token(
388            TempoTxEnv::default(),
389            Address::ZERO,
390            caller,
391            TempoHardfork::default(),
392        )?;
393        assert_eq!(result_token, user_token);
394        Ok(())
395    }
396
397    #[test]
398    fn test_get_fee_token_tip20() -> eyre::Result<()> {
399        let caller = Address::random();
400        let tip20_token = Address::random();
401
402        let tx_env = TxEnv {
403            data: Bytes::from_static(b"transfer_data"),
404            kind: TxKind::Call(tip20_token),
405            caller,
406            ..Default::default()
407        };
408        let tx = TempoTxEnv {
409            inner: tx_env,
410            ..Default::default()
411        };
412
413        let mut db = EmptyDB::default();
414        let result_token =
415            db.get_fee_token(tx, Address::ZERO, caller, TempoHardfork::Allegretto)?;
416        assert_eq!(result_token, DEFAULT_FEE_TOKEN_POST_ALLEGRETTO);
417        Ok(())
418    }
419
420    #[test]
421    fn test_get_fee_token_fallback_pre_allegretto() -> eyre::Result<()> {
422        let caller = Address::random();
423        let validator = Address::random();
424        let validator_token = Address::random();
425
426        let tx_env = TxEnv {
427            caller,
428            ..Default::default()
429        };
430        let tx = TempoTxEnv {
431            inner: tx_env,
432            ..Default::default()
433        };
434
435        // Validator has a token preference set
436        let mut db = revm::database::CacheDB::new(EmptyDB::default());
437        let validator_slot = mapping_slot(validator, tip_fee_manager::slots::VALIDATOR_TOKENS);
438        db.insert_account_storage(
439            TIP_FEE_MANAGER_ADDRESS,
440            validator_slot,
441            validator_token.into_u256(),
442        )
443        .unwrap();
444
445        let result_token =
446            db.get_fee_token(tx.clone(), validator, caller, TempoHardfork::Adagio)?;
447        assert_eq!(result_token, validator_token);
448
449        // Validator token is not set
450        let mut db2 = EmptyDB::default();
451        let result_token2 = db2.get_fee_token(tx, Address::ZERO, caller, TempoHardfork::Adagio)?;
452        assert_eq!(result_token2, DEFAULT_FEE_TOKEN_PRE_ALLEGRETTO);
453
454        Ok(())
455    }
456
457    #[test]
458    fn test_get_fee_token_fallback_post_allegretto() -> eyre::Result<()> {
459        let caller = Address::random();
460        let tx_env = TxEnv {
461            caller,
462            ..Default::default()
463        };
464        let tx = TempoTxEnv {
465            inner: tx_env,
466            ..Default::default()
467        };
468
469        let mut db = EmptyDB::default();
470        let result_token =
471            db.get_fee_token(tx, Address::ZERO, caller, TempoHardfork::Allegretto)?;
472        // Should fallback to DEFAULT_FEE_TOKEN when no preferences are found
473        assert_eq!(result_token, DEFAULT_FEE_TOKEN_POST_ALLEGRETTO);
474        Ok(())
475    }
476
477    #[test]
478    fn test_get_fee_token_stablecoin_exchange_post_allegretto() -> eyre::Result<()> {
479        let caller = Address::random();
480        // Use PathUSD as token_in since it's a known valid USD fee token
481        let token_in = DEFAULT_FEE_TOKEN_POST_ALLEGRETTO;
482        let token_out = DEFAULT_FEE_TOKEN_PRE_ALLEGRETTO;
483
484        // Test swapExactAmountIn
485        let call = IStablecoinExchange::swapExactAmountInCall {
486            tokenIn: token_in,
487            tokenOut: token_out,
488            amountIn: 1000,
489            minAmountOut: 900,
490        };
491
492        let tx_env = TxEnv {
493            data: call.abi_encode().into(),
494            kind: TxKind::Call(STABLECOIN_EXCHANGE_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        // Stablecoin exchange fee token inference requires Allegretto hardfork
505        let token = db.get_fee_token(tx, Address::ZERO, caller, TempoHardfork::Allegretto)?;
506        assert_eq!(token, token_in);
507
508        // Test swapExactAmountOut
509        let call = IStablecoinExchange::swapExactAmountOutCall {
510            tokenIn: token_in,
511            tokenOut: token_out,
512            amountOut: 900,
513            maxAmountIn: 1000,
514        };
515
516        let tx_env = TxEnv {
517            data: call.abi_encode().into(),
518            kind: TxKind::Call(STABLECOIN_EXCHANGE_ADDRESS),
519            caller,
520            ..Default::default()
521        };
522
523        let tx = TempoTxEnv {
524            inner: tx_env,
525            ..Default::default()
526        };
527
528        let token = db.get_fee_token(tx, Address::ZERO, caller, TempoHardfork::Allegretto)?;
529        assert_eq!(token, token_in);
530
531        Ok(())
532    }
533}