Skip to main content

tempo_precompiles/
test_util.rs

1//! Test utilities for precompile dispatch testing
2
3#[cfg(any(test, feature = "test-utils"))]
4use crate::error::TempoPrecompileError;
5use crate::{
6    PATH_USD_ADDRESS, Precompile, Result,
7    storage::{ContractStorage, StorageCtx, hashmap::HashMapStorageProvider},
8    tip20::{self, ITIP20, TIP20Token},
9    tip20_factory::{self, TIP20Factory},
10};
11use alloy::{
12    primitives::{Address, B256, U256},
13    sol_types::SolError,
14};
15use revm::precompile::PrecompileError;
16#[cfg(any(test, feature = "test-utils"))]
17use tempo_contracts::precompiles::TIP20Error;
18use tempo_contracts::precompiles::{TIP20_FACTORY_ADDRESS, UnknownFunctionSelector};
19
20/// Checks that all selectors in an interface have dispatch handlers.
21///
22/// Calls each selector with dummy parameters and checks for "Unknown function selector" errors.
23/// Returns unsupported selectors as `(selector_bytes, function_name)` tuples.
24pub fn check_selector_coverage<P: Precompile>(
25    precompile: &mut P,
26    selectors: &[[u8; 4]],
27    interface_name: &str,
28    name_lookup: impl Fn([u8; 4]) -> Option<&'static str>,
29) -> Vec<([u8; 4], &'static str)> {
30    let mut unsupported_selectors = Vec::new();
31
32    for selector in selectors.iter() {
33        let mut calldata = selector.to_vec();
34        // Add some dummy data for functions that require parameters
35        calldata.extend_from_slice(&[0u8; 32]);
36
37        let result = precompile.call(&calldata, Address::ZERO);
38
39        // Check if we got "Unknown function selector" error (old format)
40        let is_unsupported_old = matches!(&result,
41            Err(PrecompileError::Other(msg)) if msg.contains("Unknown function selector")
42        );
43
44        // Check if we got "Unknown function selector" error (new format - ABI-encoded)
45        let is_unsupported_new = if let Ok(output) = &result {
46            output.reverted && UnknownFunctionSelector::abi_decode(&output.bytes).is_ok()
47        } else {
48            false
49        };
50
51        if (is_unsupported_old || is_unsupported_new)
52            && let Some(name) = name_lookup(*selector)
53        {
54            unsupported_selectors.push((*selector, name));
55        }
56    }
57
58    // Print unsupported selectors for visibility
59    if !unsupported_selectors.is_empty() {
60        eprintln!("Unsupported {interface_name} selectors:");
61        for (selector, name) in &unsupported_selectors {
62            eprintln!("  - {name} ({selector:?})");
63        }
64    }
65
66    unsupported_selectors
67}
68
69/// Asserts that multiple selector coverage checks all pass (no unsupported selectors).
70///
71/// Takes an iterator of unsupported selector results and panics if any are found.
72pub fn assert_full_coverage(results: impl IntoIterator<Item = Vec<([u8; 4], &'static str)>>) {
73    let all_unsupported: Vec<_> = results
74        .into_iter()
75        .flat_map(|r| r.into_iter())
76        .map(|(_, name)| name)
77        .collect();
78
79    assert!(
80        all_unsupported.is_empty(),
81        "Found {} unsupported selectors: {:?}",
82        all_unsupported.len(),
83        all_unsupported
84    );
85}
86
87/// Creates a test [`HashMapStorageProvider`] (chain ID 1) paired with a random address.
88pub fn setup_storage() -> (HashMapStorageProvider, Address) {
89    (HashMapStorageProvider::new(1), Address::random())
90}
91
92/// Setup mode - determines how the token is obtained.
93#[derive(Default, Clone)]
94#[cfg(any(test, feature = "test-utils"))]
95enum Action {
96    #[default]
97    /// Ensure pathUSD (token 0) is deployed and configure it.
98    PathUSD,
99
100    /// Create and configure a new token using the TIP20Factory.
101    CreateToken {
102        name: &'static str,
103        symbol: &'static str,
104        currency: String,
105    },
106    /// Configure an existing token at the given address
107    ConfigureToken { address: Address },
108}
109
110/// Helper for TIP20 token setup in tests.
111///
112/// Supports creating new tokens, configuring pathUSD, or modifying existing tokens.
113/// Uses a chainable API for role grants, minting, approvals, and rewards.
114///
115/// # Examples
116///
117/// ```ignore
118/// // Initialize and configure pathUSD
119/// TIP20Setup::path_usd(admin)
120///     .with_issuer(admin)
121///     .apply()?;
122///
123/// // Create a new token
124/// let token = TIP20Setup::new("MyToken", "MTK", admin)
125///     .with_mint(user, amount)
126///     .apply()?;
127///
128/// // Configure an existing token
129/// TIP20Setup::from_address(token_address, admin)
130///     .with_mint(user, amount)
131///     .apply()?;
132/// ```
133#[derive(Default)]
134#[cfg(any(test, feature = "test-utils"))]
135pub struct TIP20Setup {
136    action: Action,
137    quote_token: Option<Address>,
138    admin: Option<Address>,
139    salt: Option<B256>,
140    roles: Vec<(Address, B256)>,
141    mints: Vec<(Address, U256)>,
142    approvals: Vec<(Address, Address, U256)>,
143    reward_opt_ins: Vec<Address>,
144    distribute_rewards: Vec<U256>,
145    clear_events: bool,
146}
147
148#[cfg(any(test, feature = "test-utils"))]
149impl TIP20Setup {
150    /// Configure pathUSD (token 0).
151    pub fn path_usd(admin: Address) -> Self {
152        Self {
153            action: Action::PathUSD,
154            admin: Some(admin),
155            ..Default::default()
156        }
157    }
158
159    /// Create a new token via factory. Ensures that `pathUSD` and `TIP20Factory` are initialized.
160    ///
161    /// Defaults to `currency: "USD"`, `quote_token: pathUSD`
162    pub fn create(name: &'static str, symbol: &'static str, admin: Address) -> Self {
163        Self {
164            action: Action::CreateToken {
165                name,
166                symbol,
167                currency: "USD".into(),
168            },
169            admin: Some(admin),
170            ..Default::default()
171        }
172    }
173
174    /// Configure an existing token at the given address.
175    pub fn config(address: Address) -> Self {
176        Self {
177            action: Action::ConfigureToken { address },
178            ..Default::default()
179        }
180    }
181
182    /// Clears emitted events for the token after applying the configuration.
183    ///
184    /// SAFETY: the caller must ensure the test uses `HashMapStorageProvider`.
185    pub fn clear_events(mut self) -> Self {
186        self.clear_events = true;
187        self
188    }
189
190    /// Set the token currency (default: "USD"). Only applies to new tokens.
191    pub fn currency(mut self, currency: impl Into<String>) -> Self {
192        if let Action::CreateToken {
193            currency: ref mut c,
194            ..
195        } = self.action
196        {
197            *c = currency.into();
198        }
199        self
200    }
201
202    /// Set a custom quote token (default: pathUSD).
203    pub fn quote_token(mut self, token: Address) -> Self {
204        self.quote_token = Some(token);
205        self
206    }
207
208    /// Set a custom salt for token address derivation (default: random).
209    pub fn with_salt(mut self, salt: B256) -> Self {
210        self.salt = Some(salt);
211        self
212    }
213
214    /// Set the admin address explicitly. Required for `config()` when using `with_mint()`.
215    pub fn with_admin(mut self, admin: Address) -> Self {
216        self.admin = Some(admin);
217        self
218    }
219
220    /// Grant ISSUER_ROLE to an account.
221    pub fn with_issuer(self, account: Address) -> Self {
222        self.with_role(account, *tip20::ISSUER_ROLE)
223    }
224
225    /// Grant an arbitrary role to an account.
226    pub fn with_role(mut self, account: Address, role: B256) -> Self {
227        self.roles.push((account, role));
228        self
229    }
230
231    /// Mint tokens to an address after creation.
232    ///
233    /// Note: Requires ISSUER_ROLE on admin (use `with_issuer(admin)`).
234    pub fn with_mint(mut self, to: Address, amount: U256) -> Self {
235        self.mints.push((to, amount));
236        self
237    }
238
239    /// Set an approval from owner to spender.
240    pub fn with_approval(mut self, owner: Address, spender: Address, amount: U256) -> Self {
241        self.approvals.push((owner, spender, amount));
242        self
243    }
244
245    /// Opt a user into rewards (sets reward recipient to themselves).
246    pub fn with_reward_opt_in(mut self, user: Address) -> Self {
247        self.reward_opt_ins.push(user);
248        self
249    }
250
251    /// Distribute rewards (requires tokens minted to admin first).
252    pub fn with_reward(mut self, amount: U256) -> Self {
253        self.distribute_rewards.push(amount);
254        self
255    }
256
257    /// Initialize pathUSD if needed and return it.
258    fn path_usd_inner(&self) -> Result<TIP20Token> {
259        if is_initialized(PATH_USD_ADDRESS)? {
260            return TIP20Token::from_address(PATH_USD_ADDRESS);
261        }
262
263        let admin = self
264            .admin
265            .expect("pathUSD is uninitialized and requires an admin");
266
267        Self::factory()?.create_token_reserved_address(
268            PATH_USD_ADDRESS,
269            "pathUSD",
270            "pathUSD",
271            "USD",
272            Address::ZERO,
273            admin,
274        )?;
275
276        TIP20Token::from_address(PATH_USD_ADDRESS)
277    }
278
279    /// Returns the [`TIP20Factory`], initializing it if not yet deployed.
280    pub fn factory() -> Result<TIP20Factory> {
281        let mut factory = TIP20Factory::new();
282        if !is_initialized(TIP20_FACTORY_ADDRESS)? {
283            factory.initialize()?;
284        }
285        Ok(factory)
286    }
287
288    /// Applies the configuration and returns the fully configured [`TIP20Token`].
289    pub fn apply(self) -> Result<TIP20Token> {
290        let mut token = match self.action.clone() {
291            Action::PathUSD => self.path_usd_inner()?,
292            Action::CreateToken {
293                name,
294                symbol,
295                currency,
296            } => {
297                let mut factory = Self::factory()?;
298                self.path_usd_inner()?;
299
300                let admin = self.admin.expect("initializing a token requires an admin");
301                let quote = self.quote_token.unwrap_or(PATH_USD_ADDRESS);
302                let salt = self.salt.unwrap_or_else(B256::random);
303                let token_address = factory.create_token(
304                    admin,
305                    tip20_factory::ITIP20Factory::createTokenCall {
306                        name: name.to_string(),
307                        symbol: symbol.to_string(),
308                        currency,
309                        quoteToken: quote,
310                        admin,
311                        salt,
312                    },
313                )?;
314                TIP20Token::from_address(token_address)?
315            }
316            Action::ConfigureToken { address } => {
317                assert!(
318                    is_initialized(address)?,
319                    "token not initialized, use `fn create` instead"
320                );
321                TIP20Token::from_address(address)?
322            }
323        };
324
325        // Apply roles
326        for (account, role) in self.roles {
327            token.grant_role_internal(account, role)?;
328        }
329
330        // Apply mints
331        for (to, amount) in self.mints {
332            let admin = self.admin.unwrap_or_else(|| {
333                get_tip20_admin(token.address()).expect("unable to get token admin")
334            });
335            token.mint(admin, ITIP20::mintCall { to, amount })?;
336        }
337
338        // Apply approvals
339        for (owner, spender, amount) in self.approvals {
340            token.approve(owner, ITIP20::approveCall { spender, amount })?;
341        }
342
343        // Apply reward opt-ins
344        for user in self.reward_opt_ins {
345            token.set_reward_recipient(user, ITIP20::setRewardRecipientCall { recipient: user })?;
346        }
347
348        // Distribute rewards
349        for amount in self.distribute_rewards {
350            let admin = self.admin.unwrap_or_else(|| {
351                get_tip20_admin(token.address()).expect("unable to get token admin")
352            });
353            token.distribute_reward(admin, ITIP20::distributeRewardCall { amount })?;
354        }
355
356        if self.clear_events {
357            token.clear_emitted_events();
358        }
359
360        Ok(token)
361    }
362
363    /// Applies the configuration and asserts it fails with `expected`.
364    pub fn expect_err(self, expected: TempoPrecompileError) {
365        let result = self.apply();
366        assert!(result.is_err_and(|err| err == expected));
367    }
368
369    /// Applies the configuration and asserts it fails with the given [`TIP20Error`].
370    pub fn expect_tip20_err(self, expected: TIP20Error) {
371        let result = self.apply();
372        assert!(result.is_err_and(|err| err == TempoPrecompileError::TIP20(expected)));
373    }
374}
375
376/// Checks if a contract at the given address has bytecode deployed.
377#[cfg(any(test, feature = "test-utils"))]
378fn is_initialized(address: Address) -> Result<bool> {
379    crate::storage::StorageCtx.has_bytecode(address)
380}
381
382/// Looks up the admin of a TIP-20 token by scanning `TokenCreated` events from the factory.
383#[cfg(any(test, feature = "test-utils"))]
384fn get_tip20_admin(token: Address) -> Option<Address> {
385    use alloy::{primitives::Log, sol_types::SolEvent};
386    use tempo_contracts::precompiles::ITIP20Factory;
387
388    let events = StorageCtx.get_events(TIP20_FACTORY_ADDRESS);
389    for log_data in events {
390        let log = Log::new_unchecked(
391            TIP20_FACTORY_ADDRESS,
392            log_data.topics().to_vec(),
393            log_data.data.clone(),
394        );
395        if let Ok(event) = ITIP20Factory::TokenCreated::decode_log(&log)
396            && event.token == token
397        {
398            return Some(event.admin);
399        }
400    }
401
402    None
403}
404
405/// Test helper function for constructing EVM words from hex string literals.
406///
407/// Takes an array of hex strings (with or without "0x" prefix), concatenates
408/// them left-to-right, left-pads with zeros to 32 bytes, and returns a U256.
409///
410/// # Example
411/// ```ignore
412/// let word = gen_word_from(&[
413///     "0x2a",                                        // 1 byte
414///     "0x1111111111111111111111111111111111111111",  // 20 bytes
415///     "0x01",                                        // 1 byte
416/// ]);
417/// // Produces: [10 zeros] [0x2a] [20 bytes of 0x11] [0x01]
418/// ```
419pub fn gen_word_from(values: &[&str]) -> U256 {
420    let mut bytes = Vec::new();
421
422    for value in values {
423        let hex_str = value.strip_prefix("0x").unwrap_or(value);
424
425        // Parse hex string to bytes
426        assert!(
427            hex_str.len() % 2 == 0,
428            "Hex string '{value}' has odd length"
429        );
430
431        for i in (0..hex_str.len()).step_by(2) {
432            let byte_str = &hex_str[i..i + 2];
433            let byte = u8::from_str_radix(byte_str, 16)
434                .unwrap_or_else(|e| panic!("Invalid hex in '{value}': {e}"));
435            bytes.push(byte);
436        }
437    }
438
439    assert!(
440        bytes.len() <= 32,
441        "Total bytes ({}) exceed 32-byte slot limit",
442        bytes.len()
443    );
444
445    // Left-pad with zeros to 32 bytes
446    let mut slot_bytes = [0u8; 32];
447    let start_idx = 32 - bytes.len();
448    slot_bytes[start_idx..].copy_from_slice(&bytes);
449
450    U256::from_be_bytes(slot_bytes)
451}