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