Skip to main content

tempo_precompiles/tip20/
mod.rs

1//! [TIP-20] token standard — Tempo's native fungible token implementation.
2//!
3//! Provides ERC-20-like balances, allowances, and transfers with Tempo extensions:
4//! role-based access control, pausability, supply caps, transfer policies ([TIP-403]), opt-in
5//! staking rewards, EIP-2612 permits (T2+), quote-token graphs, and virtual addresses ([TIP-1022]).
6//!
7//! [TIP-20]: <https://docs.tempo.xyz/protocol/tip20>
8//! [TIP-403]: <https://docs.tempo.xyz/protocol/tip403>
9//! [TIP-1022]: <https://docs.tempo.xyz/protocol/tip1022>
10
11pub mod dispatch;
12pub mod rewards;
13pub mod roles;
14
15use tempo_contracts::precompiles::STABLECOIN_DEX_ADDRESS;
16pub use tempo_contracts::precompiles::{
17    IRolesAuth, ITIP20, RolesAuthError, RolesAuthEvent, TIP20Error, TIP20Event, USD_CURRENCY,
18};
19
20// Re-export the generated slots module for external access to storage slot constants
21pub use slots as tip20_slots;
22
23use crate::{
24    PATH_USD_ADDRESS, TIP_FEE_MANAGER_ADDRESS,
25    account_keychain::AccountKeychain,
26    address_registry::AddressRegistry,
27    error::{Result, TempoPrecompileError},
28    storage::{Handler, Mapping},
29    tip20::{rewards::UserRewardInfo, roles::DEFAULT_ADMIN_ROLE},
30    tip20_factory::TIP20Factory,
31    tip403_registry::{AuthRole, ITIP403Registry, TIP403Registry},
32};
33use alloy::{
34    primitives::{Address, B256, U256, keccak256, uint},
35    sol_types::SolValue,
36};
37use std::sync::LazyLock;
38use tempo_precompiles_macros::contract;
39use tempo_primitives::TempoAddressExt;
40pub use tempo_primitives::is_tip20_prefix;
41use tracing::trace;
42
43/// u128::MAX as U256
44pub const U128_MAX: U256 = uint!(0xffffffffffffffffffffffffffffffff_U256);
45
46use tempo_contracts::precompiles::DECIMALS as TIP20_DECIMALS;
47
48/// Validates that the given token's currency is `"USD"`.
49///
50/// # Errors
51/// - `InvalidToken` — address does not have the TIP-20 prefix
52/// - `InvalidCurrency` — token currency is not `"USD"`
53pub fn validate_usd_currency(token: Address) -> Result<()> {
54    if TIP20Token::from_address(token)?.currency()? != USD_CURRENCY {
55        return Err(TIP20Error::invalid_currency().into());
56    }
57    Ok(())
58}
59
60/// TIP-20 token contract — the native token standard on Tempo.
61///
62/// Implements ERC-20-like functionality (balances, allowances, transfers) with additional
63/// features: role-based access control, pausability, supply caps, transfer policies ([TIP-403]),
64/// virtual addresses ([TIP-1022]), and opt-in staking rewards.
65///
66/// [TIP-403]: <https://docs.tempo.xyz/protocol/tip403>
67/// [TIP-1022]: <https://docs.tempo.xyz/protocol/tip1022>
68///
69/// Each token lives at a deterministic address with the `0x20C0` prefix.
70///
71/// The struct fields define the on-chain storage layout; the `#[contract]` macro generates the
72/// storage handlers which provide an ergonomic way to interact with the EVM state.
73#[contract]
74pub struct TIP20Token {
75    // RolesAuth
76    roles: Mapping<Address, Mapping<B256, bool>>,
77    role_admins: Mapping<B256, B256>,
78
79    // TIP20 Metadata
80    name: String,
81    symbol: String,
82    currency: String,
83    // Unused slot, kept for storage layout compatibility
84    _domain_separator: B256,
85    quote_token: Address,
86    next_quote_token: Address,
87    transfer_policy_id: u64,
88
89    // TIP20 Token
90    total_supply: U256,
91    balances: Mapping<Address, U256>,
92    allowances: Mapping<Address, Mapping<Address, U256>>,
93    permit_nonces: Mapping<Address, U256>,
94    paused: bool,
95    supply_cap: U256,
96    // Unused slot, kept for storage layout compatibility
97    _salts: Mapping<B256, bool>,
98
99    // TIP20 Rewards
100    global_reward_per_token: U256,
101    opted_in_supply: u128,
102    user_reward_info: Mapping<Address, UserRewardInfo>,
103}
104
105/// EIP-712 Permit typehash: keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")
106pub static PERMIT_TYPEHASH: LazyLock<B256> = LazyLock::new(|| {
107    keccak256(b"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")
108});
109
110/// EIP-712 domain separator typehash
111pub static EIP712_DOMAIN_TYPEHASH: LazyLock<B256> = LazyLock::new(|| {
112    keccak256(b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
113});
114
115/// EIP-712 version hash: keccak256("1")
116pub static VERSION_HASH: LazyLock<B256> = LazyLock::new(|| keccak256(b"1"));
117
118/// Role hash for pausing token transfers.
119pub static PAUSE_ROLE: LazyLock<B256> = LazyLock::new(|| keccak256(b"PAUSE_ROLE"));
120/// Role hash for unpausing token transfers.
121pub static UNPAUSE_ROLE: LazyLock<B256> = LazyLock::new(|| keccak256(b"UNPAUSE_ROLE"));
122/// Role hash for minting new tokens.
123pub static ISSUER_ROLE: LazyLock<B256> = LazyLock::new(|| keccak256(b"ISSUER_ROLE"));
124/// Role hash that authorizes burning tokens from blocked accounts.
125pub static BURN_BLOCKED_ROLE: LazyLock<B256> = LazyLock::new(|| keccak256(b"BURN_BLOCKED_ROLE"));
126
127impl TIP20Token {
128    /// Returns the token name.
129    pub fn name(&self) -> Result<String> {
130        self.name.read()
131    }
132
133    /// Returns the token symbol.
134    pub fn symbol(&self) -> Result<String> {
135        self.symbol.read()
136    }
137
138    /// Returns the token decimals (always 6 for TIP-20).
139    pub fn decimals(&self) -> Result<u8> {
140        Ok(TIP20_DECIMALS)
141    }
142
143    /// Returns the token's currency denomination (e.g. `"USD"`).
144    pub fn currency(&self) -> Result<String> {
145        self.currency.read()
146    }
147
148    /// Returns the current total supply.
149    pub fn total_supply(&self) -> Result<U256> {
150        self.total_supply.read()
151    }
152
153    /// Returns the active quote token address used for pricing.
154    pub fn quote_token(&self) -> Result<Address> {
155        self.quote_token.read()
156    }
157
158    /// Returns the pending next quote token address (set but not yet finalized).
159    pub fn next_quote_token(&self) -> Result<Address> {
160        self.next_quote_token.read()
161    }
162
163    /// Returns the maximum mintable supply.
164    pub fn supply_cap(&self) -> Result<U256> {
165        self.supply_cap.read()
166    }
167
168    /// Returns whether the token is currently paused.
169    pub fn paused(&self) -> Result<bool> {
170        self.paused.read()
171    }
172
173    /// Returns the TIP-403 transfer policy ID governing this token's transfers.
174    pub fn transfer_policy_id(&self) -> Result<u64> {
175        self.transfer_policy_id.read()
176    }
177
178    /// Returns the PAUSE_ROLE constant
179    ///
180    /// This role identifier grants permission to pause the token contract.
181    /// The role is computed as `keccak256("PAUSE_ROLE")`.
182    pub fn pause_role() -> B256 {
183        *PAUSE_ROLE
184    }
185
186    /// Returns the UNPAUSE_ROLE constant
187    ///
188    /// This role identifier grants permission to unpause the token contract.
189    /// The role is computed as `keccak256("UNPAUSE_ROLE")`.
190    pub fn unpause_role() -> B256 {
191        *UNPAUSE_ROLE
192    }
193
194    /// Returns the ISSUER_ROLE constant
195    ///
196    /// This role identifier grants permission to mint and burn tokens.
197    /// The role is computed as `keccak256("ISSUER_ROLE")`.
198    pub fn issuer_role() -> B256 {
199        *ISSUER_ROLE
200    }
201
202    /// Returns the BURN_BLOCKED_ROLE constant
203    ///
204    /// This role identifier grants permission to burn tokens from blocked accounts.
205    /// The role is computed as `keccak256("BURN_BLOCKED_ROLE")`.
206    pub fn burn_blocked_role() -> B256 {
207        *BURN_BLOCKED_ROLE
208    }
209
210    /// Returns the token balance of `account`.
211    pub fn balance_of(&self, call: ITIP20::balanceOfCall) -> Result<U256> {
212        self.balances[call.account].read()
213    }
214
215    /// Returns the remaining allowance that `spender` can transfer on behalf of `owner`.
216    pub fn allowance(&self, call: ITIP20::allowanceCall) -> Result<U256> {
217        self.allowances[call.owner][call.spender].read()
218    }
219
220    /// Updates the [`TIP403Registry`] transfer policy governing this token's transfers.
221    ///
222    /// # Errors
223    /// - `Unauthorized` — caller does not hold `DEFAULT_ADMIN_ROLE`
224    /// - `InvalidTransferPolicyId` — policy does not exist in the [`TIP403Registry`]
225    pub fn change_transfer_policy_id(
226        &mut self,
227        msg_sender: Address,
228        call: ITIP20::changeTransferPolicyIdCall,
229    ) -> Result<()> {
230        self.check_role(msg_sender, DEFAULT_ADMIN_ROLE)?;
231
232        // Validate that the policy exists
233        if !TIP403Registry::new().policy_exists(ITIP403Registry::policyExistsCall {
234            policyId: call.newPolicyId,
235        })? {
236            return Err(TIP20Error::invalid_transfer_policy_id().into());
237        }
238
239        self.transfer_policy_id.write(call.newPolicyId)?;
240
241        self.emit_event(TIP20Event::TransferPolicyUpdate(
242            ITIP20::TransferPolicyUpdate {
243                updater: msg_sender,
244                newPolicyId: call.newPolicyId,
245            },
246        ))
247    }
248
249    /// Sets a new supply cap. Must be ≥ current total supply and ≤ [`U128_MAX`].
250    ///
251    /// # Errors
252    /// - `Unauthorized` — caller does not hold `DEFAULT_ADMIN_ROLE`
253    /// - `InvalidSupplyCap` — new cap is below current total supply
254    /// - `SupplyCapExceeded` — new cap exceeds [`U128_MAX`]
255    pub fn set_supply_cap(
256        &mut self,
257        msg_sender: Address,
258        call: ITIP20::setSupplyCapCall,
259    ) -> Result<()> {
260        self.check_role(msg_sender, DEFAULT_ADMIN_ROLE)?;
261        if call.newSupplyCap < self.total_supply()? {
262            return Err(TIP20Error::invalid_supply_cap().into());
263        }
264
265        if call.newSupplyCap > U128_MAX {
266            return Err(TIP20Error::supply_cap_exceeded().into());
267        }
268
269        self.supply_cap.write(call.newSupplyCap)?;
270
271        self.emit_event(TIP20Event::SupplyCapUpdate(ITIP20::SupplyCapUpdate {
272            updater: msg_sender,
273            newSupplyCap: call.newSupplyCap,
274        }))
275    }
276
277    /// Pauses all token transfers.
278    ///
279    /// # Errors
280    /// - `Unauthorized` — caller does not hold `PAUSE_ROLE`
281    pub fn pause(&mut self, msg_sender: Address, _call: ITIP20::pauseCall) -> Result<()> {
282        self.check_role(msg_sender, *PAUSE_ROLE)?;
283        self.paused.write(true)?;
284
285        self.emit_event(TIP20Event::PauseStateUpdate(ITIP20::PauseStateUpdate {
286            updater: msg_sender,
287            isPaused: true,
288        }))
289    }
290
291    /// Unpauses token transfers.
292    ///
293    /// # Errors
294    /// - `Unauthorized` — caller does not hold `UNPAUSE_ROLE`
295    pub fn unpause(&mut self, msg_sender: Address, _call: ITIP20::unpauseCall) -> Result<()> {
296        self.check_role(msg_sender, *UNPAUSE_ROLE)?;
297        self.paused.write(false)?;
298
299        self.emit_event(TIP20Event::PauseStateUpdate(ITIP20::PauseStateUpdate {
300            updater: msg_sender,
301            isPaused: false,
302        }))
303    }
304
305    /// Stages a new quote token. Must be finalized via [`Self::complete_quote_token_update`].
306    /// Validates that the candidate is a deployed TIP-20 token (via [`TIP20Factory`]) and, for
307    /// USD-denominated tokens, that the candidate is also USD-denominated.
308    ///
309    /// # Errors
310    /// - `Unauthorized` — caller does not hold `DEFAULT_ADMIN_ROLE`
311    /// - `InvalidQuoteToken` — token is pathUSD, candidate is not a deployed TIP-20, or
312    ///   USD currency mismatch
313    pub fn set_next_quote_token(
314        &mut self,
315        msg_sender: Address,
316        call: ITIP20::setNextQuoteTokenCall,
317    ) -> Result<()> {
318        self.check_role(msg_sender, DEFAULT_ADMIN_ROLE)?;
319
320        if self.address == PATH_USD_ADDRESS {
321            return Err(TIP20Error::invalid_quote_token().into());
322        }
323
324        // Verify the new quote token is a valid TIP20 token that has been deployed
325        // use factory's `is_tip20()` which checks both prefix and counter
326        if !TIP20Factory::new().is_tip20(call.newQuoteToken)? {
327            return Err(TIP20Error::invalid_quote_token().into());
328        }
329
330        // Check if the currency is USD, if so then the quote token's currency MUST also be USD
331        let currency = self.currency()?;
332        if currency == USD_CURRENCY {
333            let quote_token_currency = Self::from_address(call.newQuoteToken)?.currency()?;
334            if quote_token_currency != USD_CURRENCY {
335                return Err(TIP20Error::invalid_quote_token().into());
336            }
337        }
338
339        self.next_quote_token.write(call.newQuoteToken)?;
340
341        self.emit_event(TIP20Event::NextQuoteTokenSet(ITIP20::NextQuoteTokenSet {
342            updater: msg_sender,
343            nextQuoteToken: call.newQuoteToken,
344        }))
345    }
346
347    /// Finalizes the staged quote token update. Walks the quote-token chain to detect cycles
348    /// before committing the change.
349    ///
350    /// # Errors
351    /// - `Unauthorized` — caller does not hold `DEFAULT_ADMIN_ROLE`
352    /// - `InvalidQuoteToken` — update would create a cycle in the quote-token graph
353    pub fn complete_quote_token_update(
354        &mut self,
355        msg_sender: Address,
356        _call: ITIP20::completeQuoteTokenUpdateCall,
357    ) -> Result<()> {
358        self.check_role(msg_sender, DEFAULT_ADMIN_ROLE)?;
359
360        let next_quote_token = self.next_quote_token()?;
361
362        // Check that this does not create a loop
363        // Loop through quote tokens until we reach the root (pathUSD)
364        let mut current = next_quote_token;
365        while current != PATH_USD_ADDRESS {
366            if current == self.address {
367                return Err(TIP20Error::invalid_quote_token().into());
368            }
369
370            current = Self::from_address(current)?.quote_token()?;
371        }
372
373        // Update the quote token
374        self.quote_token.write(next_quote_token)?;
375
376        self.emit_event(TIP20Event::QuoteTokenUpdate(ITIP20::QuoteTokenUpdate {
377            updater: msg_sender,
378            newQuoteToken: next_quote_token,
379        }))
380    }
381
382    // Token operations
383
384    /// Mints `amount` tokens to the resolved target `to` address:
385    /// - Enforces mint-recipient compliance via [`TIP403Registry`] and validates against supply cap
386    /// - Resolves `to` via the [`AddressRegistry`]. If `to` is a virtual address, credits the
387    ///   resolved master and emits a two-hop `Transfer` and `Mint(virtual, amount)` events
388    ///
389    /// # Errors
390    /// - `Unauthorized` — caller does not hold the `ISSUER_ROLE` role
391    /// - `ContractPaused` — (+T3) token is paused
392    /// - `InvalidRecipient` — (+T3) recipient is zero or a TIP-20 prefix address
393    /// - `PolicyForbids` — TIP-403 policy rejects the mint recipient
394    /// - `SupplyCapExceeded` — minting would push total supply above the cap
395    pub fn mint(&mut self, msg_sender: Address, call: ITIP20::mintCall) -> Result<()> {
396        let to = Recipient::resolve(call.to)?;
397        self._mint(msg_sender, &to, call.amount)?;
398
399        self.emit_event(TIP20Event::Mint(ITIP20::Mint {
400            to: call.to,
401            amount: call.amount,
402        }))?;
403        if let Some(hop) = to.build_virtual_transfer_event(call.amount) {
404            self.emit_event(hop)?;
405        }
406
407        Ok(())
408    }
409
410    /// Like [`Self::mint`], but attaches a 32-byte memo.
411    pub fn mint_with_memo(
412        &mut self,
413        msg_sender: Address,
414        call: ITIP20::mintWithMemoCall,
415    ) -> Result<()> {
416        let to = Recipient::resolve(call.to)?;
417        self._mint(msg_sender, &to, call.amount)?;
418
419        self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo {
420            from: Address::ZERO,
421            to: call.to,
422            amount: call.amount,
423            memo: call.memo,
424        }))?;
425        self.emit_event(TIP20Event::Mint(ITIP20::Mint {
426            to: call.to,
427            amount: call.amount,
428        }))?;
429        if let Some(hop) = to.build_virtual_transfer_event(call.amount) {
430            self.emit_event(hop)?;
431        }
432        Ok(())
433    }
434
435    /// Internal helper to mint new tokens and update balances.
436    fn _mint(&mut self, msg_sender: Address, to: &Recipient, amount: U256) -> Result<()> {
437        self.check_role(msg_sender, *ISSUER_ROLE)?;
438        let total_supply = self.total_supply()?;
439
440        // Check if the resolved target address is authorized to receive minted tokens
441        self.validate_mint(to)?;
442
443        let new_supply = total_supply
444            .checked_add(amount)
445            .ok_or(TempoPrecompileError::under_overflow())?;
446
447        let supply_cap = self.supply_cap()?;
448        if new_supply > supply_cap {
449            return Err(TIP20Error::supply_cap_exceeded().into());
450        }
451
452        self.handle_rewards_on_mint(to.target, amount)?;
453
454        self.set_total_supply(new_supply)?;
455        let to_balance = self.get_balance(to.target)?;
456        let new_to_balance: alloy::primitives::Uint<256, 4> = to_balance
457            .checked_add(amount)
458            .ok_or(TempoPrecompileError::under_overflow())?;
459        self.set_balance(to.target, new_to_balance)?;
460
461        self.emit_event(to.build_transfer_event(Address::ZERO, amount))
462    }
463
464    /// Burns `amount` from the caller's balance and reduces total supply.
465    ///
466    /// # Errors
467    /// - `ContractPaused` — (+T3) token is paused
468    /// - `Unauthorized` — caller does not hold the `ISSUER_ROLE` role
469    /// - `InsufficientBalance` — caller balance lower than burn amount
470    pub fn burn(&mut self, msg_sender: Address, call: ITIP20::burnCall) -> Result<()> {
471        self._burn(msg_sender, call.amount)?;
472        self.emit_event(TIP20Event::Burn(ITIP20::Burn {
473            from: msg_sender,
474            amount: call.amount,
475        }))
476    }
477
478    /// Like [`Self::burn`], but attaches a 32-byte memo.
479    pub fn burn_with_memo(
480        &mut self,
481        msg_sender: Address,
482        call: ITIP20::burnWithMemoCall,
483    ) -> Result<()> {
484        self._burn(msg_sender, call.amount)?;
485
486        self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo {
487            from: msg_sender,
488            to: Address::ZERO,
489            amount: call.amount,
490            memo: call.memo,
491        }))?;
492        self.emit_event(TIP20Event::Burn(ITIP20::Burn {
493            from: msg_sender,
494            amount: call.amount,
495        }))
496    }
497
498    /// Burns tokens from addresses blocked by [`TIP403Registry`] policy.
499    ///
500    /// # Errors
501    /// - `ContractPaused` — (+T3) token is paused
502    /// - `Unauthorized` — caller does not hold `BURN_BLOCKED_ROLE`
503    /// - `PolicyForbids` — target address is not blocked by policy
504    /// - `ProtectedAddress` — cannot burn from fee manager or stablecoin DEX addresses
505    pub fn burn_blocked(
506        &mut self,
507        msg_sender: Address,
508        call: ITIP20::burnBlockedCall,
509    ) -> Result<()> {
510        // Validate burner role and (+T3) ensure token is not paused
511        if self.storage.spec().is_t3() {
512            self.check_not_paused()?;
513        }
514        self.check_role(msg_sender, *BURN_BLOCKED_ROLE)?;
515
516        // Prevent burning from `FeeManager` and `StablecoinDEX` to protect accounting invariants
517        if matches!(call.from, TIP_FEE_MANAGER_ADDRESS | STABLECOIN_DEX_ADDRESS) {
518            return Err(TIP20Error::protected_address().into());
519        }
520
521        // Check if the address is blocked from transferring (sender authorization)
522        let policy_id = self.transfer_policy_id()?;
523        if TIP403Registry::new().is_authorized_as(policy_id, call.from, AuthRole::sender())? {
524            // Only allow burning from addresses that are blocked from transferring
525            return Err(TIP20Error::policy_forbids().into());
526        }
527
528        self._transfer(call.from, &Recipient::direct(Address::ZERO), call.amount)?;
529
530        let total_supply = self.total_supply()?;
531        let new_supply =
532            total_supply
533                .checked_sub(call.amount)
534                .ok_or(TIP20Error::insufficient_balance(
535                    total_supply,
536                    call.amount,
537                    self.address,
538                ))?;
539        self.set_total_supply(new_supply)?;
540
541        self.emit_event(TIP20Event::BurnBlocked(ITIP20::BurnBlocked {
542            from: call.from,
543            amount: call.amount,
544        }))
545    }
546
547    fn _burn(&mut self, msg_sender: Address, amount: U256) -> Result<()> {
548        // Validate issuer role and (+T3) ensure token is not paused
549        if self.storage.spec().is_t3() {
550            self.check_not_paused()?;
551        }
552        self.check_role(msg_sender, *ISSUER_ROLE)?;
553
554        self._transfer(msg_sender, &Recipient::direct(Address::ZERO), amount)?;
555
556        let total_supply = self.total_supply()?;
557        let new_supply =
558            total_supply
559                .checked_sub(amount)
560                .ok_or(TIP20Error::insufficient_balance(
561                    total_supply,
562                    amount,
563                    self.address,
564                ))?;
565        self.set_total_supply(new_supply)
566    }
567
568    /// Sets `spender`'s allowance to `amount` for the caller's tokens.
569    /// Deducts from the caller's [`AccountKeychain`] spending limit
570    /// when the new allowance exceeds the previous one.
571    ///
572    /// # Errors
573    /// - `SpendingLimitExceeded` — new allowance exceeds access key spending limit
574    pub fn approve(&mut self, msg_sender: Address, call: ITIP20::approveCall) -> Result<bool> {
575        // Check and update spending limits for access keys
576        AccountKeychain::new().authorize_approve(
577            msg_sender,
578            self.address,
579            self.get_allowance(msg_sender, call.spender)?,
580            call.amount,
581        )?;
582
583        // Set the new allowance
584        self.set_allowance(msg_sender, call.spender, call.amount)?;
585
586        self.emit_event(TIP20Event::Approval(ITIP20::Approval {
587            owner: msg_sender,
588            spender: call.spender,
589            amount: call.amount,
590        }))?;
591
592        Ok(true)
593    }
594
595    // EIP-2612 Permit
596
597    /// Returns the current nonce for an address (EIP-2612)
598    pub fn nonces(&self, call: ITIP20::noncesCall) -> Result<U256> {
599        self.permit_nonces[call.owner].read()
600    }
601
602    /// Returns the EIP-712 domain separator, computed dynamically from the token name and chain ID.
603    pub fn domain_separator(&self) -> Result<B256> {
604        let name = self.name()?;
605        let name_hash = self.storage.keccak256(name.as_bytes())?;
606        let chain_id = U256::from(self.storage.chain_id());
607
608        let encoded = (
609            *EIP712_DOMAIN_TYPEHASH,
610            name_hash,
611            *VERSION_HASH,
612            chain_id,
613            self.address,
614        )
615            .abi_encode();
616
617        self.storage.keccak256(&encoded)
618    }
619
620    /// Sets allowance via a signed [EIP-2612] permit. Validates the ECDSA signature, checks the
621    /// deadline, and increments the nonce. Allowed even when the token is paused.
622    ///
623    /// [EIP-2612]: https://eips.ethereum.org/EIPS/eip-2612
624    ///
625    /// # Errors
626    /// - `PermitExpired` — current timestamp exceeds permit deadline
627    /// - `InvalidSignature` — ECDSA recovery failed or recovered signer ≠ owner
628    pub fn permit(&mut self, call: ITIP20::permitCall) -> Result<()> {
629        // 1. Check deadline
630        if self.storage.timestamp() > call.deadline {
631            return Err(TIP20Error::permit_expired().into());
632        }
633
634        // 2. Construct EIP-712 struct hash
635        let nonce = self.permit_nonces[call.owner].read()?;
636        let struct_hash = self.storage.keccak256(
637            &(
638                *PERMIT_TYPEHASH,
639                call.owner,
640                call.spender,
641                call.value,
642                nonce,
643                call.deadline,
644            )
645                .abi_encode(),
646        )?;
647
648        // 3. Construct EIP-712 digest
649        let domain_separator = self.domain_separator()?;
650        let digest = self.storage.keccak256(
651            &[
652                &[0x19, 0x01],
653                domain_separator.as_slice(),
654                struct_hash.as_slice(),
655            ]
656            .concat(),
657        )?;
658
659        // 4. Validate ECDSA signature
660        // Only v=27/28 is accepted; v=0/1 is intentionally NOT normalized (see TIP-1004 spec).
661        let recovered = self
662            .storage
663            .recover_signer(digest, call.v, call.r, call.s)?
664            .ok_or(TIP20Error::invalid_signature())?;
665        if recovered != call.owner {
666            return Err(TIP20Error::invalid_signature().into());
667        }
668
669        // 5. Increment nonce
670        self.permit_nonces[call.owner].write(
671            nonce
672                .checked_add(U256::from(1))
673                .ok_or(TempoPrecompileError::under_overflow())?,
674        )?;
675
676        // 6. Set allowance
677        self.set_allowance(call.owner, call.spender, call.value)?;
678
679        // 7. Emit Approval event
680        self.emit_event(TIP20Event::Approval(ITIP20::Approval {
681            owner: call.owner,
682            spender: call.spender,
683            amount: call.value,
684        }))
685    }
686
687    /// Transfers `amount` tokens from the caller to `to`. Enforces compliance via the
688    /// [`TIP403Registry`] and deducts from the caller's [`AccountKeychain`] spending limit.
689    ///
690    /// # Errors
691    /// - `Paused` — token transfers are currently paused
692    /// - `InvalidRecipient` — recipient address is zero
693    /// - `PolicyForbids` — TIP-403 policy rejects sender or recipient
694    /// - `SpendingLimitExceeded` — access key spending limit exceeded
695    /// - `InsufficientBalance` — sender balance lower than transfer amount
696    pub fn transfer(&mut self, msg_sender: Address, call: ITIP20::transferCall) -> Result<bool> {
697        trace!(%msg_sender, ?call, "transferring TIP20");
698        let to = Recipient::resolve(call.to)?;
699        self.validate_transfer(msg_sender, &to)?;
700        self.check_and_update_spending_limit(msg_sender, call.amount)?;
701
702        self._transfer(msg_sender, &to, call.amount)?;
703        if let Some(hop) = to.build_virtual_transfer_event(call.amount) {
704            self.emit_event(hop)?;
705        }
706        Ok(true)
707    }
708
709    /// Transfers `amount` on behalf of `from` using the caller's allowance.
710    /// Enforces compliance via the [`TIP403Registry`].
711    ///
712    /// # Errors
713    /// - `Paused` — token transfers are currently paused
714    /// - `InvalidRecipient` — recipient address is zero
715    /// - `PolicyForbids` — TIP-403 policy rejects sender or recipient
716    /// - `InsufficientAllowance` — caller allowance lower than transfer amount
717    /// - `InsufficientBalance` — `from` balance lower than transfer amount
718    pub fn transfer_from(
719        &mut self,
720        msg_sender: Address,
721        call: ITIP20::transferFromCall,
722    ) -> Result<bool> {
723        let to = Recipient::resolve(call.to)?;
724        self._transfer_from(msg_sender, call.from, &to, call.amount)?;
725        if let Some(hop) = to.build_virtual_transfer_event(call.amount) {
726            self.emit_event(hop)?;
727        }
728        Ok(true)
729    }
730
731    /// Like [`Self::transfer_from`], but attaches a 32-byte memo.
732    pub fn transfer_from_with_memo(
733        &mut self,
734        msg_sender: Address,
735        call: ITIP20::transferFromWithMemoCall,
736    ) -> Result<bool> {
737        let to = Recipient::resolve(call.to)?;
738        self._transfer_from(msg_sender, call.from, &to, call.amount)?;
739
740        self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo {
741            from: call.from,
742            to: call.to,
743            amount: call.amount,
744            memo: call.memo,
745        }))?;
746        if let Some(hop) = to.build_virtual_transfer_event(call.amount) {
747            self.emit_event(hop)?;
748        }
749        Ok(true)
750    }
751
752    /// Transfers `amount` from `from` to `to` without approval, for use
753    /// by other precompiles only (not exposed via ABI). Enforces
754    /// compliance via the [`TIP403Registry`] and [`AccountKeychain`].
755    ///
756    /// # Errors
757    /// - `Paused` — token transfers are currently paused
758    /// - `InvalidRecipient` — recipient address is zero
759    /// - `PolicyForbids` — TIP-403 policy rejects sender or recipient
760    /// - `SpendingLimitExceeded` — access key spending limit exceeded
761    /// - `InsufficientBalance` — `from` balance lower than transfer amount
762    pub fn system_transfer_from(
763        &mut self,
764        from: Address,
765        to: Address,
766        amount: U256,
767    ) -> Result<bool> {
768        let to = Recipient::resolve(to)?;
769        self.validate_transfer(from, &to)?;
770        self.check_and_update_spending_limit(from, amount)?;
771
772        self._transfer(from, &to, amount)?;
773        if let Some(hop) = to.build_virtual_transfer_event(amount) {
774            self.emit_event(hop)?;
775        }
776
777        Ok(true)
778    }
779
780    fn _transfer_from(
781        &mut self,
782        msg_sender: Address,
783        from: Address,
784        to: &Recipient,
785        amount: U256,
786    ) -> Result<bool> {
787        self.validate_transfer(from, to)?;
788
789        let allowed = self.get_allowance(from, msg_sender)?;
790        if amount > allowed {
791            return Err(TIP20Error::insufficient_allowance().into());
792        }
793
794        if allowed != U256::MAX {
795            let new_allowance = allowed
796                .checked_sub(amount)
797                .ok_or(TIP20Error::insufficient_allowance())?;
798            self.set_allowance(from, msg_sender, new_allowance)?;
799        }
800
801        self._transfer(from, to, amount)?;
802
803        Ok(true)
804    }
805
806    /// Like [`Self::transfer`], but attaches a 32-byte memo.
807    pub fn transfer_with_memo(
808        &mut self,
809        msg_sender: Address,
810        call: ITIP20::transferWithMemoCall,
811    ) -> Result<()> {
812        let to = Recipient::resolve(call.to)?;
813        self.validate_transfer(msg_sender, &to)?;
814        self.check_and_update_spending_limit(msg_sender, call.amount)?;
815
816        self._transfer(msg_sender, &to, call.amount)?;
817
818        self.emit_event(TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo {
819            from: msg_sender,
820            to: call.to,
821            amount: call.amount,
822            memo: call.memo,
823        }))?;
824        if let Some(hop) = to.build_virtual_transfer_event(call.amount) {
825            self.emit_event(hop)?;
826        }
827        Ok(())
828    }
829}
830
831// Utility functions
832impl TIP20Token {
833    /// Creates a `TIP20Token` handle from a raw address.
834    ///
835    /// # Errors
836    /// - `InvalidToken` — address does not carry the `0x20C0` TIP-20 prefix
837    pub fn from_address(address: Address) -> Result<Self> {
838        if !address.is_tip20() {
839            return Err(TIP20Error::invalid_token().into());
840        }
841        Ok(Self::__new(address))
842    }
843
844    /// Creates a TIP20Token without validating the prefix.
845    ///
846    /// # Safety
847    /// Caller must ensure `is_tip20_prefix(address)` returns true.
848    #[inline]
849    pub fn from_address_unchecked(address: Address) -> Self {
850        debug_assert!(address.is_tip20(), "address must have TIP20 prefix");
851        Self::__new(address)
852    }
853
854    /// Initializes the TIP-20 token precompile with metadata, quote token, supply cap, and
855    /// default admin role. Called once by [`TIP20Factory`] during token creation.
856    pub fn initialize(
857        &mut self,
858        msg_sender: Address,
859        name: &str,
860        symbol: &str,
861        currency: &str,
862        quote_token: Address,
863        admin: Address,
864    ) -> Result<()> {
865        trace!(%name, address=%self.address, "Initializing token");
866
867        // must ensure the account is not empty, by setting some code
868        self.__initialize()?;
869
870        self.name.write(name.to_string())?;
871        self.symbol.write(symbol.to_string())?;
872        self.currency.write(currency.to_string())?;
873
874        self.quote_token.write(quote_token)?;
875        // Initialize nextQuoteToken to the same value as quoteToken
876        self.next_quote_token.write(quote_token)?;
877
878        // Set default values
879        self.supply_cap.write(U128_MAX)?;
880        self.transfer_policy_id.write(1)?;
881
882        // Initialize roles system and grant admin role
883        self.initialize_roles()?;
884        self.grant_default_admin(msg_sender, admin)
885    }
886
887    fn get_balance(&self, account: Address) -> Result<U256> {
888        self.balances[account].read()
889    }
890
891    fn set_balance(&mut self, account: Address, amount: U256) -> Result<()> {
892        self.balances[account].write(amount)
893    }
894
895    fn get_allowance(&self, owner: Address, spender: Address) -> Result<U256> {
896        self.allowances[owner][spender].read()
897    }
898
899    fn set_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> {
900        self.allowances[owner][spender].write(amount)
901    }
902
903    fn set_total_supply(&mut self, amount: U256) -> Result<()> {
904        self.total_supply.write(amount)
905    }
906
907    pub fn check_not_paused(&self) -> Result<()> {
908        if self.paused()? {
909            return Err(TIP20Error::contract_paused().into());
910        }
911        Ok(())
912    }
913
914    /// Checks pause state, validates the effective recipient, and ensures the transfer
915    /// is authorized. Shared by public entrypoints that resolve a [`Recipient`] up front.
916    fn validate_transfer(&self, from: Address, to: &Recipient) -> Result<()> {
917        self.check_not_paused()?;
918        to.validate()?;
919        self.ensure_transfer_authorized(from, to.target)
920    }
921
922    /// Ensures that the recipient is authorized to receive mints.
923    /// Additionally (+T3) checks pause state, validates the effective recipient.
924    fn validate_mint(&self, to: &Recipient) -> Result<()> {
925        if self.storage.spec().is_t3() {
926            self.check_not_paused()?;
927            to.validate()?;
928        }
929
930        // Check if the resolved target address is authorized to receive minted tokens
931        if !TIP403Registry::new().is_authorized_as(
932            self.transfer_policy_id()?,
933            to.target,
934            AuthRole::mint_recipient(),
935        )? {
936            return Err(TIP20Error::policy_forbids().into());
937        }
938
939        Ok(())
940    }
941
942    /// Check whether a transfer is authorized by the token's [`TIP403Registry`] policy.
943    /// [TIP-1015]: For T2+, uses directional sender/recipient checks.
944    ///
945    /// [TIP-1015]: <https://docs.tempo.xyz/protocol/tips/tip-1015>
946    pub fn is_transfer_authorized(&self, from: Address, to: Address) -> Result<bool> {
947        let policy_id = self.transfer_policy_id()?;
948        let registry = TIP403Registry::new();
949
950        // (spec: +T2) short-circuit and skip recipient check if sender fails
951        let sender_auth = registry.is_authorized_as(policy_id, from, AuthRole::sender())?;
952        if self.storage.spec().is_t2() && !sender_auth {
953            return Ok(false);
954        }
955        let recipient_auth = registry.is_authorized_as(policy_id, to, AuthRole::recipient())?;
956        Ok(sender_auth && recipient_auth)
957    }
958
959    /// Ensures the transfer is authorized by the token's [`TIP403Registry`] policy.
960    ///
961    /// # Errors
962    /// - `PolicyForbids` — sender or recipient is not authorized by the active transfer policy
963    pub fn ensure_transfer_authorized(&self, from: Address, to: Address) -> Result<()> {
964        if !self.is_transfer_authorized(from, to)? {
965            return Err(TIP20Error::policy_forbids().into());
966        }
967
968        Ok(())
969    }
970
971    /// Checks and deducts `amount` from the caller's [`AccountKeychain`] spending limit.
972    ///
973    /// # Errors
974    /// - `SpendingLimitExceeded` — access key spending limit exceeded
975    pub fn check_and_update_spending_limit(&mut self, from: Address, amount: U256) -> Result<()> {
976        AccountKeychain::new().authorize_transfer(from, self.address, amount)
977    }
978
979    /// Core transfer: debits `from`, credits `to.target`, emits `Transfer(from, event_addr, amount)`.
980    ///
981    /// For virtual recipients the event address is the virtual alias; the balance update always
982    /// targets `to.target` (the resolved master).
983    fn _transfer(&mut self, from: Address, to: &Recipient, amount: U256) -> Result<()> {
984        let from_balance = self.get_balance(from)?;
985        if amount > from_balance {
986            return Err(
987                TIP20Error::insufficient_balance(from_balance, amount, self.address).into(),
988            );
989        }
990
991        self.handle_rewards_on_transfer(from, to.target, amount)?;
992
993        // Adjust balances
994        let new_from_balance = from_balance
995            .checked_sub(amount)
996            .ok_or(TempoPrecompileError::under_overflow())?;
997
998        self.set_balance(from, new_from_balance)?;
999
1000        if to.target != Address::ZERO {
1001            let to_balance = self.get_balance(to.target)?;
1002            let new_to_balance = to_balance
1003                .checked_add(amount)
1004                .ok_or(TempoPrecompileError::under_overflow())?;
1005
1006            self.set_balance(to.target, new_to_balance)?;
1007        }
1008
1009        self.emit_event(to.build_transfer_event(from, amount))
1010    }
1011
1012    /// Transfers fee tokens from `from` to the fee manager before transaction execution.
1013    /// Respects the token's pause state and deducts from the [`AccountKeychain`] spending limit.
1014    ///
1015    /// # Errors
1016    /// - `Paused` — token transfers are currently paused
1017    /// - `InsufficientBalance` — sender balance lower than fee amount
1018    /// - `SpendingLimitExceeded` — access key spending limit exceeded
1019    pub fn transfer_fee_pre_tx(&mut self, from: Address, amount: U256) -> Result<()> {
1020        // This function respects the token's pause state and will revert if the token is paused.
1021        // transfer_fee_post_tx is intentionally allowed to execute even when the token is paused.
1022        // This ensures that a transaction which pauses the token can still complete successfully and receive its fee refund.
1023        // Apart from this specific refund transfer, no other token transfers can occur after a pause event.
1024        self.check_not_paused()?;
1025        let from_balance = self.get_balance(from)?;
1026        if amount > from_balance {
1027            return Err(
1028                TIP20Error::insufficient_balance(from_balance, amount, self.address).into(),
1029            );
1030        }
1031
1032        self.check_and_update_spending_limit(from, amount)?;
1033
1034        // Update rewards for the sender and get their reward recipient
1035        let from_reward_recipient = self.update_rewards(from)?;
1036
1037        // If user is opted into rewards, decrease opted-in supply
1038        if from_reward_recipient != Address::ZERO {
1039            let opted_in_supply = U256::from(self.get_opted_in_supply()?)
1040                .checked_sub(amount)
1041                .ok_or(TempoPrecompileError::under_overflow())?;
1042            self.set_opted_in_supply(
1043                opted_in_supply
1044                    .try_into()
1045                    .map_err(|_| TempoPrecompileError::under_overflow())?,
1046            )?;
1047        }
1048
1049        let new_from_balance =
1050            from_balance
1051                .checked_sub(amount)
1052                .ok_or(TIP20Error::insufficient_balance(
1053                    from_balance,
1054                    amount,
1055                    self.address,
1056                ))?;
1057
1058        self.set_balance(from, new_from_balance)?;
1059
1060        let to_balance = self.get_balance(TIP_FEE_MANAGER_ADDRESS)?;
1061        let new_to_balance = to_balance
1062            .checked_add(amount)
1063            .ok_or(TIP20Error::supply_cap_exceeded())?;
1064        self.set_balance(TIP_FEE_MANAGER_ADDRESS, new_to_balance)
1065    }
1066
1067    /// Refunds unused fee tokens from the fee manager back to `to` and emits a transfer event for
1068    /// the actual gas spent. Intentionally allowed when paused so that a pause transaction can
1069    /// still receive its fee refund. On T1C+, also restores the [`AccountKeychain`] spending limit
1070    /// by the refund amount.
1071    pub fn transfer_fee_post_tx(
1072        &mut self,
1073        to: Address,
1074        refund: U256,
1075        actual_spending: U256,
1076    ) -> Result<()> {
1077        self.emit_event(TIP20Event::Transfer(ITIP20::Transfer {
1078            from: to,
1079            to: TIP_FEE_MANAGER_ADDRESS,
1080            amount: actual_spending,
1081        }))?;
1082
1083        // Exit early if there is no refund
1084        if refund.is_zero() {
1085            return Ok(());
1086        }
1087
1088        if self.storage.spec().is_t1c() {
1089            AccountKeychain::new().refund_spending_limit(to, self.address, refund)?;
1090        }
1091
1092        // Update rewards for the recipient and get their reward recipient
1093        let to_reward_recipient = self.update_rewards(to)?;
1094
1095        // If user is opted into rewards, increase opted-in supply by refund amount
1096        if to_reward_recipient != Address::ZERO {
1097            let opted_in_supply = U256::from(self.get_opted_in_supply()?)
1098                .checked_add(refund)
1099                .ok_or(TempoPrecompileError::under_overflow())?;
1100            self.set_opted_in_supply(
1101                opted_in_supply
1102                    .try_into()
1103                    .map_err(|_| TempoPrecompileError::under_overflow())?,
1104            )?;
1105        }
1106
1107        let from_balance = self.get_balance(TIP_FEE_MANAGER_ADDRESS)?;
1108        let new_from_balance =
1109            from_balance
1110                .checked_sub(refund)
1111                .ok_or(TIP20Error::insufficient_balance(
1112                    from_balance,
1113                    refund,
1114                    self.address,
1115                ))?;
1116
1117        self.set_balance(TIP_FEE_MANAGER_ADDRESS, new_from_balance)?;
1118
1119        let to_balance = self.get_balance(to)?;
1120        let new_to_balance = to_balance
1121            .checked_add(refund)
1122            .ok_or(TIP20Error::supply_cap_exceeded())?;
1123        self.set_balance(to, new_to_balance)
1124    }
1125}
1126
1127/// Resolved transfer recipient for [TIP-1022] virtual address support.
1128///
1129/// `target` is always the effective (resolved) address where the balance is credited. For virtual
1130/// recipients, `virtual_addr` carries the original virtual address for event emission.
1131///
1132/// [TIP-1022]: <https://docs.tempo.xyz/protocol/tip1022>
1133#[derive(Debug, PartialEq)]
1134pub(crate) struct Recipient {
1135    /// The effective (resolved) address where the balance is credited.
1136    pub(crate) target: Address,
1137    /// The virtual address, if registered.
1138    pub(crate) virtual_addr: Option<Address>,
1139}
1140
1141impl Recipient {
1142    /// Creates a [`Recipient`] with no virtual indirection.
1143    #[inline]
1144    pub(crate) fn direct(addr: Address) -> Self {
1145        Self {
1146            target: addr,
1147            virtual_addr: None,
1148        }
1149    }
1150
1151    /// Resolves a recipient via the [`AddressRegistry`].
1152    ///
1153    /// If `addr` is a virtual address its registered master is looked up and stored in `target`,
1154    /// with the original virtual address preserved in `virtual_addr`.
1155    pub(crate) fn resolve(addr: Address) -> Result<Self> {
1156        let effective = AddressRegistry::new().resolve_recipient(addr)?;
1157        Ok(if effective == addr {
1158            Self::direct(addr)
1159        } else {
1160            Self {
1161                target: effective,
1162                virtual_addr: Some(addr),
1163            }
1164        })
1165    }
1166
1167    /// Validates that the recipient is not:
1168    /// - the zero address (preventing accidental burns)
1169    /// - an address with the TIP-20 prefix (preventing transfers to token contracts)
1170    pub(crate) fn validate(&self) -> Result<()> {
1171        if self.target.is_zero() || self.target.is_tip20() {
1172            return Err(TIP20Error::invalid_recipient().into());
1173        }
1174        Ok(())
1175    }
1176
1177    /// Builds the primary `Transfer(from, to, amount)` event.
1178    ///
1179    /// For virtual recipients `to` is the virtual address (first hop); for regular
1180    /// recipients this is the only `Transfer` event needed.
1181    pub(crate) fn build_transfer_event(&self, from: Address, amount: U256) -> TIP20Event {
1182        TIP20Event::Transfer(ITIP20::Transfer {
1183            from,
1184            to: self.virtual_addr.unwrap_or(self.target),
1185            amount,
1186        })
1187    }
1188
1189    /// Builds the forwarding `Transfer(virtual, master, amount)` event for virtual recipients.
1190    /// Returns `None` for non-virtual recipients.
1191    pub(crate) fn build_virtual_transfer_event(&self, amount: U256) -> Option<TIP20Event> {
1192        self.virtual_addr.map(|virtual_addr| {
1193            TIP20Event::Transfer(ITIP20::Transfer {
1194                from: virtual_addr,
1195                to: self.target,
1196                amount,
1197            })
1198        })
1199    }
1200}
1201
1202#[cfg(test)]
1203mod recipient_tests {
1204    use super::*;
1205    use crate::{
1206        address_registry::{AddressRegistry, MasterId, UserTag},
1207        error::TempoPrecompileError,
1208        storage::{StorageCtx, hashmap::HashMapStorageProvider},
1209        test_util::{VIRTUAL_MASTER, register_virtual_master},
1210    };
1211    use alloy::primitives::{Address, U256};
1212    use tempo_chainspec::hardfork::TempoHardfork;
1213
1214    #[test]
1215    fn test_resolve() -> eyre::Result<()> {
1216        // direct (non-virtual)
1217        let addr = Address::repeat_byte(0x11);
1218        assert_eq!(
1219            Recipient::direct(addr),
1220            Recipient {
1221                target: addr,
1222                virtual_addr: None
1223            }
1224        );
1225
1226        // T3: non-virtual → direct
1227        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T3);
1228        StorageCtx::enter(&mut storage, || {
1229            let r = Recipient::resolve(addr)?;
1230            assert_eq!(
1231                r,
1232                Recipient {
1233                    target: addr,
1234                    virtual_addr: None
1235                }
1236            );
1237
1238            // T3: registered virtual → master
1239            let mut registry = AddressRegistry::new();
1240            let (_, virtual_addr) = register_virtual_master(&mut registry)?;
1241            let r = Recipient::resolve(virtual_addr)?;
1242            assert_eq!(
1243                r,
1244                Recipient {
1245                    target: VIRTUAL_MASTER,
1246                    virtual_addr: Some(virtual_addr)
1247                }
1248            );
1249
1250            // T3: unregistered virtual → error
1251            let unregistered = Address::new_virtual(MasterId::ZERO, UserTag::ZERO);
1252            assert!(Recipient::resolve(unregistered).is_err());
1253
1254            Ok::<_, TempoPrecompileError>(())
1255        })?;
1256
1257        // Pre-T3: virtual address passed through as literal
1258        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T2);
1259        StorageCtx::enter(&mut storage, || {
1260            let virtual_addr = Address::new_virtual(MasterId::ZERO, UserTag::ZERO);
1261            let r = Recipient::resolve(virtual_addr)?;
1262            assert_eq!(
1263                r,
1264                Recipient {
1265                    target: virtual_addr,
1266                    virtual_addr: None
1267                }
1268            );
1269            Ok::<_, TempoPrecompileError>(())
1270        })?;
1271        Ok(())
1272    }
1273
1274    #[test]
1275    fn test_validate() {
1276        assert!(Recipient::direct(Address::ZERO).validate().is_err());
1277        assert!(
1278            Recipient::direct(crate::PATH_USD_ADDRESS)
1279                .validate()
1280                .is_err()
1281        );
1282        assert!(
1283            Recipient::direct(Address::repeat_byte(0x11))
1284                .validate()
1285                .is_ok()
1286        );
1287    }
1288
1289    #[test]
1290    fn test_build_events() {
1291        let from = Address::repeat_byte(0x01);
1292        let target = Address::repeat_byte(0x02);
1293        let vaddr = Address::repeat_byte(0x03);
1294        let amount = U256::from(42);
1295
1296        let direct = Recipient::direct(target);
1297        let virt = Recipient {
1298            target,
1299            virtual_addr: Some(vaddr),
1300        };
1301
1302        // transfer event uses virtual_addr when present, target otherwise
1303        assert!(matches!(direct.build_transfer_event(from, amount),
1304            TIP20Event::Transfer(ITIP20::Transfer { to, .. }) if to == target));
1305        assert!(matches!(virt.build_transfer_event(from, amount),
1306            TIP20Event::Transfer(ITIP20::Transfer { to, .. }) if to == vaddr));
1307
1308        // virtual transfer event: None for direct, Some(virtual→master) for virtual
1309        assert!(direct.build_virtual_transfer_event(amount).is_none());
1310        let hop = virt.build_virtual_transfer_event(amount).unwrap();
1311        assert!(matches!(hop,
1312            TIP20Event::Transfer(ITIP20::Transfer { from, to, .. })
1313            if from == vaddr && to == target));
1314    }
1315}
1316
1317#[cfg(test)]
1318pub(crate) mod tests {
1319    use alloy::primitives::{Address, FixedBytes, IntoLogData, U256, hex};
1320    use tempo_contracts::precompiles::ITIP20Factory;
1321
1322    use super::*;
1323    use crate::{
1324        PATH_USD_ADDRESS,
1325        account_keychain::{
1326            AccountKeychain, KeyRestrictions, SignatureType, TokenLimit, authorizeKeyCall,
1327            getRemainingLimitCall,
1328        },
1329        address_registry::{AddressRegistry, MasterId, UserTag},
1330        error::TempoPrecompileError,
1331        storage::{StorageCtx, hashmap::HashMapStorageProvider},
1332        test_util::{TIP20Setup, VIRTUAL_MASTER, register_virtual_master, setup_storage},
1333    };
1334    use rand_08::{Rng, distributions::Alphanumeric, thread_rng};
1335    use tempo_chainspec::hardfork::TempoHardfork;
1336
1337    #[test]
1338    fn test_mint_increases_balance_and_supply() -> eyre::Result<()> {
1339        let (mut storage, admin) = setup_storage();
1340        let addr = Address::random();
1341        let amount = U256::random() % U256::from(u128::MAX);
1342
1343        StorageCtx::enter(&mut storage, || {
1344            let mut token = TIP20Setup::create("Test", "TST", admin)
1345                .with_issuer(admin)
1346                .clear_events()
1347                .apply()?;
1348
1349            token.mint(admin, ITIP20::mintCall { to: addr, amount })?;
1350
1351            assert_eq!(token.get_balance(addr)?, amount);
1352            assert_eq!(token.total_supply()?, amount);
1353
1354            token.assert_emitted_events(vec![
1355                TIP20Event::Transfer(ITIP20::Transfer {
1356                    from: Address::ZERO,
1357                    to: addr,
1358                    amount,
1359                }),
1360                TIP20Event::Mint(ITIP20::Mint { to: addr, amount }),
1361            ]);
1362
1363            Ok(())
1364        })
1365    }
1366
1367    #[test]
1368    fn test_transfer_moves_balance() -> eyre::Result<()> {
1369        let (mut storage, admin) = setup_storage();
1370        let from = Address::random();
1371        let to = Address::random();
1372        let amount = U256::random() % U256::from(u128::MAX);
1373
1374        StorageCtx::enter(&mut storage, || {
1375            let mut token = TIP20Setup::create("Test", "TST", admin)
1376                .with_issuer(admin)
1377                .with_mint(from, amount)
1378                .clear_events()
1379                .apply()?;
1380
1381            token.transfer(from, ITIP20::transferCall { to, amount })?;
1382
1383            assert_eq!(token.get_balance(from)?, U256::ZERO);
1384            assert_eq!(token.get_balance(to)?, amount);
1385            assert_eq!(token.total_supply()?, amount); // Supply unchanged
1386
1387            token.assert_emitted_events(vec![TIP20Event::Transfer(ITIP20::Transfer {
1388                from,
1389                to,
1390                amount,
1391            })]);
1392
1393            Ok(())
1394        })
1395    }
1396
1397    #[test]
1398    fn test_transfer_insufficient_balance_fails() -> eyre::Result<()> {
1399        let (mut storage, admin) = setup_storage();
1400        let from = Address::random();
1401        let to = Address::random();
1402        let amount = U256::random() % U256::from(u128::MAX);
1403
1404        StorageCtx::enter(&mut storage, || {
1405            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
1406
1407            let result = token.transfer(from, ITIP20::transferCall { to, amount });
1408            assert!(matches!(
1409                result,
1410                Err(TempoPrecompileError::TIP20(
1411                    TIP20Error::InsufficientBalance(_)
1412                ))
1413            ));
1414
1415            Ok(())
1416        })
1417    }
1418
1419    #[test]
1420    fn test_mint_with_memo() -> eyre::Result<()> {
1421        let mut storage = HashMapStorageProvider::new(1);
1422        let admin = Address::random();
1423        let amount = U256::random() % U256::from(u128::MAX);
1424        let to = Address::random();
1425        let memo = FixedBytes::random();
1426
1427        StorageCtx::enter(&mut storage, || {
1428            let mut token = TIP20Setup::create("Test", "TST", admin)
1429                .with_issuer(admin)
1430                .clear_events()
1431                .apply()?;
1432
1433            token.mint_with_memo(admin, ITIP20::mintWithMemoCall { to, amount, memo })?;
1434
1435            // TransferWithMemo event should have Address::ZERO as from for mint
1436            token.assert_emitted_events(vec![
1437                TIP20Event::Transfer(ITIP20::Transfer {
1438                    from: Address::ZERO,
1439                    to,
1440                    amount,
1441                }),
1442                TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo {
1443                    from: Address::ZERO,
1444                    to,
1445                    amount,
1446                    memo,
1447                }),
1448                TIP20Event::Mint(ITIP20::Mint { to, amount }),
1449            ]);
1450
1451            Ok(())
1452        })
1453    }
1454
1455    #[test]
1456    fn test_burn_with_memo() -> eyre::Result<()> {
1457        let mut storage = HashMapStorageProvider::new(1);
1458        let admin = Address::random();
1459        let amount = U256::random() % U256::from(u128::MAX);
1460        let memo = FixedBytes::random();
1461
1462        StorageCtx::enter(&mut storage, || {
1463            let mut token = TIP20Setup::create("Test", "TST", admin)
1464                .with_issuer(admin)
1465                .with_mint(admin, amount)
1466                .clear_events()
1467                .apply()?;
1468
1469            token.burn_with_memo(admin, ITIP20::burnWithMemoCall { amount, memo })?;
1470            token.assert_emitted_events(vec![
1471                TIP20Event::Transfer(ITIP20::Transfer {
1472                    from: admin,
1473                    to: Address::ZERO,
1474                    amount,
1475                }),
1476                TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo {
1477                    from: admin,
1478                    to: Address::ZERO,
1479                    amount,
1480                    memo,
1481                }),
1482                TIP20Event::Burn(ITIP20::Burn {
1483                    from: admin,
1484                    amount,
1485                }),
1486            ]);
1487
1488            Ok(())
1489        })
1490    }
1491
1492    #[test]
1493    fn test_transfer_from_with_memo_from_address() -> eyre::Result<()> {
1494        let mut storage = HashMapStorageProvider::new(1);
1495        let admin = Address::random();
1496        let owner = Address::random();
1497        let spender = Address::random();
1498        let to = Address::random();
1499        let memo = FixedBytes::random();
1500        let amount = U256::random() % U256::from(u128::MAX);
1501
1502        StorageCtx::enter(&mut storage, || {
1503            let mut token = TIP20Setup::create("Test", "TST", admin)
1504                .with_issuer(admin)
1505                .with_mint(owner, amount)
1506                .with_approval(owner, spender, amount)
1507                .clear_events()
1508                .apply()?;
1509
1510            token.transfer_from_with_memo(
1511                spender,
1512                ITIP20::transferFromWithMemoCall {
1513                    from: owner,
1514                    to,
1515                    amount,
1516                    memo,
1517                },
1518            )?;
1519
1520            // TransferWithMemo event should have use call.from in transfer event
1521            token.assert_emitted_events(vec![
1522                TIP20Event::Transfer(ITIP20::Transfer {
1523                    from: owner,
1524                    to,
1525                    amount,
1526                }),
1527                TIP20Event::TransferWithMemo(ITIP20::TransferWithMemo {
1528                    from: owner,
1529                    to,
1530                    amount,
1531                    memo,
1532                }),
1533            ]);
1534
1535            Ok(())
1536        })
1537    }
1538
1539    #[test]
1540    fn test_transfer_fee_pre_tx() -> eyre::Result<()> {
1541        let mut storage = HashMapStorageProvider::new(1);
1542        let admin = Address::random();
1543        let user = Address::random();
1544        let amount = U256::from(100);
1545        let fee_amount = amount / U256::from(2);
1546
1547        StorageCtx::enter(&mut storage, || {
1548            let mut token = TIP20Setup::create("Test", "TST", admin)
1549                .with_issuer(admin)
1550                .with_mint(user, amount)
1551                .apply()?;
1552
1553            token.transfer_fee_pre_tx(user, fee_amount)?;
1554
1555            assert_eq!(token.get_balance(user)?, fee_amount);
1556            assert_eq!(token.get_balance(TIP_FEE_MANAGER_ADDRESS)?, fee_amount);
1557
1558            Ok(())
1559        })
1560    }
1561
1562    #[test]
1563    fn test_transfer_fee_pre_tx_insufficient_balance() -> eyre::Result<()> {
1564        let mut storage = HashMapStorageProvider::new(1);
1565        let admin = Address::random();
1566        let user = Address::random();
1567        let amount = U256::from(100);
1568        let fee_amount = amount / U256::from(2);
1569
1570        StorageCtx::enter(&mut storage, || {
1571            let mut token = TIP20Setup::create("Test", "TST", admin)
1572                .with_issuer(admin)
1573                .apply()?;
1574
1575            assert_eq!(
1576                token.transfer_fee_pre_tx(user, fee_amount),
1577                Err(TempoPrecompileError::TIP20(
1578                    TIP20Error::insufficient_balance(U256::ZERO, fee_amount, token.address)
1579                ))
1580            );
1581            Ok(())
1582        })
1583    }
1584
1585    #[test]
1586    fn test_transfer_fee_pre_tx_paused() -> eyre::Result<()> {
1587        let mut storage = HashMapStorageProvider::new(1);
1588        let admin = Address::random();
1589        let user = Address::random();
1590        let amount = U256::from(100);
1591        let fee_amount = amount / U256::from(2);
1592
1593        StorageCtx::enter(&mut storage, || {
1594            let mut token = TIP20Setup::create("Test", "TST", admin)
1595                .with_issuer(admin)
1596                .with_role(admin, *PAUSE_ROLE)
1597                .with_mint(user, amount)
1598                .apply()?;
1599
1600            // Pause the token
1601            token.pause(admin, ITIP20::pauseCall {})?;
1602
1603            // transfer_fee_pre_tx should fail when paused
1604            assert_eq!(
1605                token.transfer_fee_pre_tx(user, fee_amount),
1606                Err(TempoPrecompileError::TIP20(TIP20Error::contract_paused()))
1607            );
1608            Ok(())
1609        })
1610    }
1611
1612    #[test]
1613    fn test_transfer_fee_post_tx() -> eyre::Result<()> {
1614        let mut storage = HashMapStorageProvider::new(1);
1615        let admin = Address::random();
1616        let user = Address::random();
1617        let initial_fee = U256::from(100);
1618        let refund_amount = U256::from(30);
1619        let gas_used = U256::from(10);
1620
1621        StorageCtx::enter(&mut storage, || {
1622            let mut token = TIP20Setup::create("Test", "TST", admin)
1623                .with_issuer(admin)
1624                .with_mint(TIP_FEE_MANAGER_ADDRESS, initial_fee)
1625                .apply()?;
1626
1627            token.transfer_fee_post_tx(user, refund_amount, gas_used)?;
1628
1629            assert_eq!(token.get_balance(user)?, refund_amount);
1630            assert_eq!(
1631                token.get_balance(TIP_FEE_MANAGER_ADDRESS)?,
1632                initial_fee - refund_amount
1633            );
1634            assert_eq!(
1635                token.emitted_events().last().unwrap(),
1636                &TIP20Event::Transfer(ITIP20::Transfer {
1637                    from: user,
1638                    to: TIP_FEE_MANAGER_ADDRESS,
1639                    amount: gas_used
1640                })
1641                .into_log_data()
1642            );
1643
1644            Ok(())
1645        })
1646    }
1647
1648    #[test]
1649    fn test_transfer_fee_post_tx_refunds_spending_limit() -> eyre::Result<()> {
1650        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T1C);
1651        let admin = Address::random();
1652        let user = Address::random();
1653        let access_key = Address::random();
1654        let max_fee = U256::from(1000);
1655        let refund_amount = U256::from(300);
1656        let gas_used = U256::from(100);
1657
1658        StorageCtx::enter(&mut storage, || {
1659            let mut token = TIP20Setup::create("Test", "TST", admin)
1660                .with_issuer(admin)
1661                .with_mint(TIP_FEE_MANAGER_ADDRESS, max_fee)
1662                .apply()?;
1663
1664            let token_address = token.address;
1665            let spending_limit = U256::from(2000);
1666
1667            // Set up keychain: authorize an access key with a spending limit
1668            let mut keychain = AccountKeychain::new();
1669            keychain.initialize()?;
1670            keychain.set_transaction_key(Address::ZERO)?;
1671
1672            keychain.authorize_key(
1673                user,
1674                authorizeKeyCall {
1675                    keyId: access_key,
1676                    signatureType: SignatureType::Secp256k1,
1677                    config: KeyRestrictions {
1678                        expiry: u64::MAX,
1679                        enforceLimits: true,
1680                        limits: vec![TokenLimit {
1681                            token: token_address,
1682                            amount: spending_limit,
1683                            period: 0,
1684                        }],
1685                        allowAnyCalls: true,
1686                        allowedCalls: vec![],
1687                    },
1688                },
1689            )?;
1690
1691            // Simulate pre-tx: access key deducts max fee from spending limit
1692            keychain.set_transaction_key(access_key)?;
1693            keychain.set_tx_origin(user)?;
1694            keychain.authorize_transfer(user, token_address, max_fee)?;
1695
1696            let remaining_after_deduction =
1697                keychain.get_remaining_limit(getRemainingLimitCall {
1698                    account: user,
1699                    keyId: access_key,
1700                    token: token_address,
1701                })?;
1702            assert_eq!(remaining_after_deduction, spending_limit - max_fee);
1703
1704            // Call transfer_fee_post_tx — should refund the spending limit via is_t1c() gate
1705            token.transfer_fee_post_tx(user, refund_amount, gas_used)?;
1706
1707            let remaining_after_refund = keychain.get_remaining_limit(getRemainingLimitCall {
1708                account: user,
1709                keyId: access_key,
1710                token: token_address,
1711            })?;
1712            assert_eq!(
1713                remaining_after_refund,
1714                spending_limit - max_fee + refund_amount,
1715                "spending limit should be restored by refund amount"
1716            );
1717
1718            Ok(())
1719        })
1720    }
1721
1722    #[test]
1723    fn test_transfer_fee_post_tx_pre_t1c() -> eyre::Result<()> {
1724        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T1B);
1725        let admin = Address::random();
1726        let user = Address::random();
1727        let access_key = Address::random();
1728        let max_fee = U256::from(1000);
1729        let refund_amount = U256::from(300);
1730        let gas_used = U256::from(100);
1731
1732        StorageCtx::enter(&mut storage, || {
1733            let mut token = TIP20Setup::create("Test", "TST", admin)
1734                .with_issuer(admin)
1735                .with_mint(TIP_FEE_MANAGER_ADDRESS, max_fee)
1736                .apply()?;
1737
1738            let token_address = token.address;
1739            let spending_limit = U256::from(2000);
1740
1741            let mut keychain = AccountKeychain::new();
1742            keychain.initialize()?;
1743            keychain.set_transaction_key(Address::ZERO)?;
1744
1745            keychain.authorize_key(
1746                user,
1747                authorizeKeyCall {
1748                    keyId: access_key,
1749                    signatureType: SignatureType::Secp256k1,
1750                    config: KeyRestrictions {
1751                        expiry: u64::MAX,
1752                        enforceLimits: true,
1753                        limits: vec![TokenLimit {
1754                            token: token_address,
1755                            amount: spending_limit,
1756                            period: 0,
1757                        }],
1758                        allowAnyCalls: true,
1759                        allowedCalls: vec![],
1760                    },
1761                },
1762            )?;
1763
1764            keychain.set_transaction_key(access_key)?;
1765            keychain.set_tx_origin(user)?;
1766            keychain.authorize_transfer(user, token_address, max_fee)?;
1767
1768            let remaining_after_deduction =
1769                keychain.get_remaining_limit(getRemainingLimitCall {
1770                    account: user,
1771                    keyId: access_key,
1772                    token: token_address,
1773                })?;
1774            assert_eq!(remaining_after_deduction, spending_limit - max_fee);
1775
1776            token.transfer_fee_post_tx(user, refund_amount, gas_used)?;
1777
1778            // spending limit unchanged pre-t1c
1779            let remaining_after_refund = keychain.get_remaining_limit(getRemainingLimitCall {
1780                account: user,
1781                keyId: access_key,
1782                token: token_address,
1783            })?;
1784            assert_eq!(remaining_after_refund, spending_limit - max_fee);
1785
1786            Ok(())
1787        })
1788    }
1789
1790    #[test]
1791    fn test_transfer_from_insufficient_allowance() -> eyre::Result<()> {
1792        let mut storage = HashMapStorageProvider::new(1);
1793        let admin = Address::random();
1794        let from = Address::random();
1795        let spender = Address::random();
1796        let to = Address::random();
1797        let amount = U256::random() % U256::from(u128::MAX);
1798
1799        StorageCtx::enter(&mut storage, || {
1800            let mut token = TIP20Setup::create("Test", "TST", admin)
1801                .with_issuer(admin)
1802                .with_mint(from, amount)
1803                .apply()?;
1804
1805            assert!(matches!(
1806                token.transfer_from(spender, ITIP20::transferFromCall { from, to, amount }),
1807                Err(TempoPrecompileError::TIP20(
1808                    TIP20Error::InsufficientAllowance(_)
1809                ))
1810            ));
1811
1812            Ok(())
1813        })
1814    }
1815
1816    #[test]
1817    fn test_system_transfer_from() -> eyre::Result<()> {
1818        let mut storage = HashMapStorageProvider::new(1);
1819        let admin = Address::random();
1820        let from = Address::random();
1821        let to = Address::random();
1822        let amount = U256::random() % U256::from(u128::MAX);
1823
1824        StorageCtx::enter(&mut storage, || {
1825            let mut token = TIP20Setup::create("Test", "TST", admin)
1826                .with_issuer(admin)
1827                .with_mint(from, amount)
1828                .apply()?;
1829
1830            assert!(token.system_transfer_from(from, to, amount).is_ok());
1831            assert_eq!(
1832                token.emitted_events().last().unwrap(),
1833                &TIP20Event::Transfer(ITIP20::Transfer { from, to, amount }).into_log_data()
1834            );
1835
1836            Ok(())
1837        })
1838    }
1839
1840    #[test]
1841    fn test_initialize_sets_next_quote_token() -> eyre::Result<()> {
1842        let mut storage = HashMapStorageProvider::new(1);
1843        let admin = Address::random();
1844
1845        StorageCtx::enter(&mut storage, || {
1846            let token = TIP20Setup::create("Test", "TST", admin).apply()?;
1847
1848            // Verify both quoteToken and nextQuoteToken are set to the same value
1849            assert_eq!(token.quote_token()?, PATH_USD_ADDRESS);
1850            assert_eq!(token.next_quote_token()?, PATH_USD_ADDRESS);
1851
1852            Ok(())
1853        })
1854    }
1855
1856    #[test]
1857    fn test_update_quote_token() -> eyre::Result<()> {
1858        let mut storage = HashMapStorageProvider::new(1);
1859        let admin = Address::random();
1860
1861        StorageCtx::enter(&mut storage, || {
1862            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
1863
1864            // Create a new USD token to use as the new quote token
1865            let new_quote_token = TIP20Setup::create("New Quote", "NQ", admin).apply()?;
1866            let new_quote_token_address = new_quote_token.address;
1867
1868            // Verify initial quote token is PATH_USD
1869            assert_eq!(token.quote_token()?, PATH_USD_ADDRESS);
1870
1871            // Set next quote token to the new token
1872            token.set_next_quote_token(
1873                admin,
1874                ITIP20::setNextQuoteTokenCall {
1875                    newQuoteToken: new_quote_token_address,
1876                },
1877            )?;
1878
1879            // Verify next quote token was set to the new token
1880            assert_eq!(token.next_quote_token()?, new_quote_token_address);
1881
1882            // Verify event was emitted
1883            assert_eq!(
1884                token.emitted_events().last().unwrap(),
1885                &TIP20Event::NextQuoteTokenSet(ITIP20::NextQuoteTokenSet {
1886                    updater: admin,
1887                    nextQuoteToken: new_quote_token_address,
1888                })
1889                .into_log_data()
1890            );
1891
1892            Ok(())
1893        })
1894    }
1895
1896    #[test]
1897    fn test_update_quote_token_requires_admin() -> eyre::Result<()> {
1898        let mut storage = HashMapStorageProvider::new(1);
1899        let admin = Address::random();
1900        let non_admin = Address::random();
1901
1902        StorageCtx::enter(&mut storage, || {
1903            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
1904
1905            // Use the token's own quote token for the test
1906            let quote_token_address = token.quote_token()?;
1907
1908            // Try to set next quote token as non-admin
1909            let result = token.set_next_quote_token(
1910                non_admin,
1911                ITIP20::setNextQuoteTokenCall {
1912                    newQuoteToken: quote_token_address,
1913                },
1914            );
1915
1916            assert!(matches!(
1917                result,
1918                Err(TempoPrecompileError::RolesAuthError(
1919                    RolesAuthError::Unauthorized(_)
1920                ))
1921            ));
1922
1923            Ok(())
1924        })
1925    }
1926
1927    #[test]
1928    fn test_update_quote_token_rejects_non_tip20() -> eyre::Result<()> {
1929        let mut storage = HashMapStorageProvider::new(1);
1930        let admin = Address::random();
1931
1932        StorageCtx::enter(&mut storage, || {
1933            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
1934
1935            // Try to set a non-TIP20 address (random address that doesn't match TIP20 pattern)
1936            let non_tip20_address = Address::random();
1937            let result = token.set_next_quote_token(
1938                admin,
1939                ITIP20::setNextQuoteTokenCall {
1940                    newQuoteToken: non_tip20_address,
1941                },
1942            );
1943
1944            assert!(matches!(
1945                result,
1946                Err(TempoPrecompileError::TIP20(TIP20Error::InvalidQuoteToken(
1947                    _
1948                )))
1949            ));
1950
1951            Ok(())
1952        })
1953    }
1954
1955    #[test]
1956    fn test_update_quote_token_rejects_undeployed_token() -> eyre::Result<()> {
1957        let mut storage = HashMapStorageProvider::new(1);
1958        let admin = Address::random();
1959
1960        StorageCtx::enter(&mut storage, || {
1961            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
1962
1963            // Try to set a TIP20 address that hasn't been deployed yet
1964            // This has the correct TIP20 address pattern but hasn't been created
1965            let undeployed_token_address =
1966                Address::from(hex!("20C0000000000000000000000000000000000999"));
1967            let result = token.set_next_quote_token(
1968                admin,
1969                ITIP20::setNextQuoteTokenCall {
1970                    newQuoteToken: undeployed_token_address,
1971                },
1972            );
1973
1974            assert!(matches!(
1975                result,
1976                Err(TempoPrecompileError::TIP20(TIP20Error::InvalidQuoteToken(
1977                    _
1978                )))
1979            ));
1980
1981            Ok(())
1982        })
1983    }
1984
1985    #[test]
1986    fn test_finalize_quote_token_update() -> eyre::Result<()> {
1987        let mut storage = HashMapStorageProvider::new(1);
1988        let admin = Address::random();
1989
1990        StorageCtx::enter(&mut storage, || {
1991            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
1992            let quote_token_address = token.quote_token()?;
1993
1994            // Set next quote token
1995            token.set_next_quote_token(
1996                admin,
1997                ITIP20::setNextQuoteTokenCall {
1998                    newQuoteToken: quote_token_address,
1999                },
2000            )?;
2001
2002            // Complete the update
2003            token.complete_quote_token_update(admin, ITIP20::completeQuoteTokenUpdateCall {})?;
2004
2005            // Verify quote token was updated
2006            assert_eq!(token.quote_token()?, quote_token_address);
2007
2008            // Verify event was emitted
2009            assert_eq!(
2010                token.emitted_events().last().unwrap(),
2011                &TIP20Event::QuoteTokenUpdate(ITIP20::QuoteTokenUpdate {
2012                    updater: admin,
2013                    newQuoteToken: quote_token_address,
2014                })
2015                .into_log_data()
2016            );
2017
2018            Ok(())
2019        })
2020    }
2021
2022    #[test]
2023    fn test_finalize_quote_token_update_detects_loop() -> eyre::Result<()> {
2024        let mut storage = HashMapStorageProvider::new(1);
2025        let admin = Address::random();
2026
2027        StorageCtx::enter(&mut storage, || {
2028            // Create token_b first (links to LINKING_USD)
2029            let mut token_b = TIP20Setup::create("Token B", "TKB", admin).apply()?;
2030            // Create token_a (links to token_b)
2031            let token_a = TIP20Setup::create("Token A", "TKA", admin)
2032                .quote_token(token_b.address)
2033                .apply()?;
2034
2035            // Now try to set token_a as the next quote token for token_b (would create A -> B -> A loop)
2036            token_b.set_next_quote_token(
2037                admin,
2038                ITIP20::setNextQuoteTokenCall {
2039                    newQuoteToken: token_a.address,
2040                },
2041            )?;
2042
2043            // Try to complete the update - should fail due to loop detection
2044            let result =
2045                token_b.complete_quote_token_update(admin, ITIP20::completeQuoteTokenUpdateCall {});
2046
2047            assert!(matches!(
2048                result,
2049                Err(TempoPrecompileError::TIP20(TIP20Error::InvalidQuoteToken(
2050                    _
2051                )))
2052            ));
2053
2054            Ok(())
2055        })
2056    }
2057
2058    #[test]
2059    fn test_finalize_quote_token_update_requires_admin() -> eyre::Result<()> {
2060        let mut storage = HashMapStorageProvider::new(1);
2061        let admin = Address::random();
2062        let non_admin = Address::random();
2063
2064        StorageCtx::enter(&mut storage, || {
2065            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
2066            let quote_token_address = token.quote_token()?;
2067
2068            // Set next quote token as admin
2069            token.set_next_quote_token(
2070                admin,
2071                ITIP20::setNextQuoteTokenCall {
2072                    newQuoteToken: quote_token_address,
2073                },
2074            )?;
2075
2076            // Try to complete update as non-admin
2077            let result = token
2078                .complete_quote_token_update(non_admin, ITIP20::completeQuoteTokenUpdateCall {});
2079
2080            assert!(matches!(
2081                result,
2082                Err(TempoPrecompileError::RolesAuthError(
2083                    RolesAuthError::Unauthorized(_)
2084                ))
2085            ));
2086
2087            Ok(())
2088        })
2089    }
2090
2091    #[test]
2092    fn test_arbitrary_currency() -> eyre::Result<()> {
2093        let mut storage = HashMapStorageProvider::new(1);
2094        let admin = Address::random();
2095
2096        StorageCtx::enter(&mut storage, || {
2097            for _ in 0..50 {
2098                let currency: String = thread_rng()
2099                    .sample_iter(&Alphanumeric)
2100                    .take(31)
2101                    .map(char::from)
2102                    .collect();
2103
2104                // Initialize token with the random currency
2105                let token = TIP20Setup::create("Test", "TST", admin)
2106                    .currency(&currency)
2107                    .apply()?;
2108
2109                // Verify the currency was stored and can be retrieved correctly
2110                let stored_currency = token.currency()?;
2111                assert_eq!(stored_currency, currency,);
2112            }
2113
2114            Ok(())
2115        })
2116    }
2117
2118    #[test]
2119    fn test_from_address() -> eyre::Result<()> {
2120        let mut storage = HashMapStorageProvider::new(1);
2121        let admin = Address::random();
2122
2123        StorageCtx::enter(&mut storage, || {
2124            // Test with factory-created token (hash-derived address)
2125            let token = TIP20Setup::create("Test", "TST", admin).apply()?;
2126            let via_from_address = TIP20Token::from_address(token.address)?.address;
2127
2128            assert_eq!(
2129                via_from_address, token.address,
2130                "from_address should use the provided address directly"
2131            );
2132
2133            // Test with reserved token (pathUSD)
2134            let _path_usd = TIP20Setup::path_usd(admin).apply()?;
2135            let via_from_address_reserved = TIP20Token::from_address(PATH_USD_ADDRESS)?.address;
2136
2137            assert_eq!(
2138                via_from_address_reserved, PATH_USD_ADDRESS,
2139                "from_address should work for reserved addresses too"
2140            );
2141
2142            Ok(())
2143        })
2144    }
2145
2146    #[test]
2147    fn test_new_invalid_quote_token() -> eyre::Result<()> {
2148        let mut storage = HashMapStorageProvider::new(1);
2149        let admin = Address::random();
2150
2151        StorageCtx::enter(&mut storage, || {
2152            let currency: String = thread_rng()
2153                .sample_iter(&Alphanumeric)
2154                .take(31)
2155                .map(char::from)
2156                .collect();
2157
2158            let token = TIP20Setup::create("Token", "T", admin)
2159                .currency(&currency)
2160                .apply()?;
2161
2162            // Try to create a new USD token with the arbitrary token as the quote token, this should fail
2163            TIP20Setup::create("USD Token", "USDT", admin)
2164                .currency(USD_CURRENCY)
2165                .quote_token(token.address)
2166                .expect_tip20_err(TIP20Error::invalid_quote_token());
2167
2168            Ok(())
2169        })
2170    }
2171
2172    #[test]
2173    fn test_new_valid_quote_token() -> eyre::Result<()> {
2174        let mut storage = HashMapStorageProvider::new(1);
2175        let admin = Address::random();
2176
2177        StorageCtx::enter(&mut storage, || {
2178            let usd_token1 = TIP20Setup::create("USD Token", "USDT", admin).apply()?;
2179
2180            // USD token with USD token as quote
2181            let _usd_token2 = TIP20Setup::create("USD Token", "USDT", admin)
2182                .quote_token(usd_token1.address)
2183                .apply()?;
2184
2185            // Create non USD token
2186            let currency_1: String = thread_rng()
2187                .sample_iter(&Alphanumeric)
2188                .take(31)
2189                .map(char::from)
2190                .collect();
2191
2192            let token_1 = TIP20Setup::create("USD Token", "USDT", admin)
2193                .currency(currency_1)
2194                .apply()?;
2195
2196            // Create a non USD token with non USD quote token
2197            let currency_2: String = thread_rng()
2198                .sample_iter(&Alphanumeric)
2199                .take(31)
2200                .map(char::from)
2201                .collect();
2202
2203            let _token_2 = TIP20Setup::create("USD Token", "USDT", admin)
2204                .currency(currency_2)
2205                .quote_token(token_1.address)
2206                .apply()?;
2207
2208            Ok(())
2209        })
2210    }
2211
2212    #[test]
2213    fn test_update_quote_token_invalid_token() -> eyre::Result<()> {
2214        let mut storage = HashMapStorageProvider::new(1);
2215        let admin = Address::random();
2216
2217        StorageCtx::enter(&mut storage, || {
2218            let _path_usd = TIP20Setup::path_usd(admin).apply()?;
2219
2220            let currency: String = thread_rng()
2221                .sample_iter(&Alphanumeric)
2222                .take(31)
2223                .map(char::from)
2224                .collect();
2225
2226            let token_1 = TIP20Setup::create("Token 1", "TK1", admin)
2227                .currency(&currency)
2228                .apply()?;
2229
2230            // Create a new USD token
2231            let mut usd_token = TIP20Setup::create("USD Token", "USDT", admin).apply()?;
2232
2233            // Try to update the USD token's quote token to the arbitrary currency token, this should fail
2234            let result = usd_token.set_next_quote_token(
2235                admin,
2236                ITIP20::setNextQuoteTokenCall {
2237                    newQuoteToken: token_1.address,
2238                },
2239            );
2240
2241            assert!(result.is_err_and(
2242                |err| err == TempoPrecompileError::TIP20(TIP20Error::invalid_quote_token())
2243            ));
2244
2245            Ok(())
2246        })
2247    }
2248
2249    #[test]
2250    fn test_is_tip20_prefix() -> eyre::Result<()> {
2251        let mut storage = HashMapStorageProvider::new(1);
2252        let sender = Address::random();
2253
2254        StorageCtx::enter(&mut storage, || {
2255            let _path_usd = TIP20Setup::path_usd(sender).apply()?;
2256
2257            let created_tip20 = TIP20Factory::new().create_token(
2258                sender,
2259                ITIP20Factory::createTokenCall {
2260                    name: "Test Token".to_string(),
2261                    symbol: "TEST".to_string(),
2262                    currency: "USD".to_string(),
2263                    quoteToken: crate::PATH_USD_ADDRESS,
2264                    admin: sender,
2265                    salt: B256::random(),
2266                },
2267            )?;
2268            let non_tip20 = Address::random();
2269
2270            assert!(PATH_USD_ADDRESS.is_tip20());
2271            assert!(created_tip20.is_tip20());
2272            assert!(!non_tip20.is_tip20());
2273            Ok(())
2274        })
2275    }
2276
2277    #[test]
2278    fn test_initialize_supply_cap() -> eyre::Result<()> {
2279        let mut storage = HashMapStorageProvider::new(1);
2280        let admin = Address::random();
2281
2282        StorageCtx::enter(&mut storage, || {
2283            let token = TIP20Setup::create("Token", "TKN", admin).apply()?;
2284
2285            let supply_cap = token.supply_cap()?;
2286            assert_eq!(supply_cap, U256::from(u128::MAX));
2287
2288            Ok(())
2289        })
2290    }
2291
2292    #[test]
2293    fn test_unable_to_burn_blocked_from_protected_address() -> eyre::Result<()> {
2294        let mut storage = HashMapStorageProvider::new(1);
2295        let admin = Address::random();
2296        let burner = Address::random();
2297        let amount = (U256::random() % U256::from(u128::MAX)) / U256::from(2);
2298
2299        StorageCtx::enter(&mut storage, || {
2300            let mut token = TIP20Setup::create("Token", "TKN", admin)
2301                .with_issuer(admin)
2302                // Grant BURN_BLOCKED_ROLE to burner
2303                .with_role(burner, *BURN_BLOCKED_ROLE)
2304                // Simulate collected fees
2305                .with_mint(TIP_FEE_MANAGER_ADDRESS, amount)
2306                // Mint tokens to StablecoinDEX
2307                .with_mint(STABLECOIN_DEX_ADDRESS, amount)
2308                .apply()?;
2309
2310            // Attempt to burn from FeeManager
2311            let result = token.burn_blocked(
2312                burner,
2313                ITIP20::burnBlockedCall {
2314                    from: TIP_FEE_MANAGER_ADDRESS,
2315                    amount: amount / U256::from(2),
2316                },
2317            );
2318
2319            assert!(matches!(
2320                result,
2321                Err(TempoPrecompileError::TIP20(TIP20Error::ProtectedAddress(_)))
2322            ));
2323
2324            // Verify FeeManager balance is unchanged
2325            let balance = token.balance_of(ITIP20::balanceOfCall {
2326                account: TIP_FEE_MANAGER_ADDRESS,
2327            })?;
2328            assert_eq!(balance, amount);
2329
2330            // Attempt to burn from StablecoinDEX
2331            let result = token.burn_blocked(
2332                burner,
2333                ITIP20::burnBlockedCall {
2334                    from: STABLECOIN_DEX_ADDRESS,
2335                    amount: amount / U256::from(2),
2336                },
2337            );
2338
2339            assert!(matches!(
2340                result,
2341                Err(TempoPrecompileError::TIP20(TIP20Error::ProtectedAddress(_)))
2342            ));
2343
2344            // Verify StablecoinDEX balance is unchanged
2345            let balance = token.balance_of(ITIP20::balanceOfCall {
2346                account: STABLECOIN_DEX_ADDRESS,
2347            })?;
2348            assert_eq!(balance, amount);
2349
2350            Ok(())
2351        })
2352    }
2353
2354    #[test]
2355    fn test_initialize_usd_token() -> eyre::Result<()> {
2356        let mut storage = HashMapStorageProvider::new(1);
2357        let admin = Address::random();
2358
2359        StorageCtx::enter(&mut storage, || {
2360            // USD token with zero quote token should succeed
2361            let _token = TIP20Setup::create("TestToken", "TEST", admin).apply()?;
2362
2363            // Non-USD token with zero quote token should succeed
2364            let eur_token = TIP20Setup::create("EuroToken", "EUR", admin)
2365                .currency("EUR")
2366                .apply()?;
2367
2368            // USD token with non-USD quote token should fail
2369            TIP20Setup::create("USDToken", "USD", admin)
2370                .quote_token(eur_token.address)
2371                .expect_tip20_err(TIP20Error::invalid_quote_token());
2372
2373            Ok(())
2374        })
2375    }
2376
2377    #[test]
2378    fn test_change_transfer_policy_id_invalid_policy() -> eyre::Result<()> {
2379        let mut storage = HashMapStorageProvider::new(1);
2380        let admin = Address::random();
2381
2382        StorageCtx::enter(&mut storage, || {
2383            let mut token = TIP20Setup::path_usd(admin).apply()?;
2384
2385            // Initialize the TIP403 registry
2386            let mut registry = TIP403Registry::new();
2387            registry.initialize()?;
2388
2389            // Try to change to a non-existent policy ID (should fail)
2390            let invalid_policy_id = 999u64;
2391            let result = token.change_transfer_policy_id(
2392                admin,
2393                ITIP20::changeTransferPolicyIdCall {
2394                    newPolicyId: invalid_policy_id,
2395                },
2396            );
2397
2398            assert!(matches!(
2399                result.unwrap_err(),
2400                TempoPrecompileError::TIP20(TIP20Error::InvalidTransferPolicyId(_))
2401            ));
2402
2403            Ok(())
2404        })
2405    }
2406
2407    #[test]
2408    fn test_transfer_invalid_recipient() -> eyre::Result<()> {
2409        let mut storage = HashMapStorageProvider::new(1);
2410        let admin = Address::random();
2411        let bob = Address::random();
2412        let amount = U256::random() % U256::from(u128::MAX);
2413
2414        StorageCtx::enter(&mut storage, || {
2415            let mut token = TIP20Setup::create("Token", "TKN", admin)
2416                .with_issuer(admin)
2417                .with_mint(admin, amount)
2418                .with_approval(admin, bob, amount)
2419                .apply()?;
2420
2421            let result = token.transfer(
2422                admin,
2423                ITIP20::transferCall {
2424                    to: Address::ZERO,
2425                    amount,
2426                },
2427            );
2428            assert!(result.is_err_and(|err| err.to_string().contains("InvalidRecipient")));
2429
2430            let result = token.transfer_from(
2431                bob,
2432                ITIP20::transferFromCall {
2433                    from: admin,
2434                    to: Address::ZERO,
2435                    amount,
2436                },
2437            );
2438            assert!(result.is_err_and(|err| err.to_string().contains("InvalidRecipient")));
2439
2440            Ok(())
2441        })
2442    }
2443
2444    #[test]
2445    fn test_change_transfer_policy_id() -> eyre::Result<()> {
2446        let mut storage = HashMapStorageProvider::new(1);
2447        let admin = Address::random();
2448
2449        StorageCtx::enter(&mut storage, || {
2450            let mut token = TIP20Setup::path_usd(admin).apply()?;
2451
2452            // Initialize the TIP403 registry
2453            let mut registry = TIP403Registry::new();
2454            registry.initialize()?;
2455
2456            // Test special policies 0 and 1 (should always work)
2457            token.change_transfer_policy_id(
2458                admin,
2459                ITIP20::changeTransferPolicyIdCall { newPolicyId: 0 },
2460            )?;
2461            assert_eq!(token.transfer_policy_id()?, 0);
2462
2463            token.change_transfer_policy_id(
2464                admin,
2465                ITIP20::changeTransferPolicyIdCall { newPolicyId: 1 },
2466            )?;
2467            assert_eq!(token.transfer_policy_id()?, 1);
2468
2469            // Test random invalid policy IDs should fail
2470            let mut rng = rand_08::thread_rng();
2471            for _ in 0..20 {
2472                let invalid_policy_id = rng.gen_range(2..u64::MAX);
2473                let result = token.change_transfer_policy_id(
2474                    admin,
2475                    ITIP20::changeTransferPolicyIdCall {
2476                        newPolicyId: invalid_policy_id,
2477                    },
2478                );
2479                assert!(matches!(
2480                    result.unwrap_err(),
2481                    TempoPrecompileError::TIP20(TIP20Error::InvalidTransferPolicyId(_))
2482                ));
2483            }
2484
2485            // Create some valid policies
2486            let mut valid_policy_ids = Vec::new();
2487            for i in 0..10 {
2488                let policy_id = registry.create_policy(
2489                    admin,
2490                    ITIP403Registry::createPolicyCall {
2491                        admin,
2492                        policyType: if i % 2 == 0 {
2493                            ITIP403Registry::PolicyType::WHITELIST
2494                        } else {
2495                            ITIP403Registry::PolicyType::BLACKLIST
2496                        },
2497                    },
2498                )?;
2499                valid_policy_ids.push(policy_id);
2500            }
2501
2502            // Test that all created policies can be set
2503            for policy_id in valid_policy_ids {
2504                let result = token.change_transfer_policy_id(
2505                    admin,
2506                    ITIP20::changeTransferPolicyIdCall {
2507                        newPolicyId: policy_id,
2508                    },
2509                );
2510                assert!(result.is_ok());
2511                assert_eq!(token.transfer_policy_id()?, policy_id);
2512            }
2513
2514            Ok(())
2515        })
2516    }
2517
2518    #[test]
2519    fn test_is_transfer_authorized() -> eyre::Result<()> {
2520        use tempo_chainspec::hardfork::TempoHardfork;
2521
2522        let admin = Address::random();
2523        let sender = Address::random();
2524        let recipient = Address::random();
2525
2526        for hardfork in [TempoHardfork::T0, TempoHardfork::T1] {
2527            let mut storage = HashMapStorageProvider::new_with_spec(1, hardfork);
2528
2529            StorageCtx::enter(&mut storage, || {
2530                let token = TIP20Setup::path_usd(admin).apply()?;
2531
2532                // Initialize TIP403 registry and create a whitelist policy
2533                let mut registry = TIP403Registry::new();
2534                registry.initialize()?;
2535
2536                let policy_id = registry.create_policy(
2537                    admin,
2538                    ITIP403Registry::createPolicyCall {
2539                        admin,
2540                        policyType: ITIP403Registry::PolicyType::WHITELIST,
2541                    },
2542                )?;
2543
2544                // Assign token to use this policy
2545                let mut token = token;
2546                token.change_transfer_policy_id(
2547                    admin,
2548                    ITIP20::changeTransferPolicyIdCall {
2549                        newPolicyId: policy_id,
2550                    },
2551                )?;
2552
2553                // Sender not whitelisted, recipient whitelisted
2554                registry.modify_policy_whitelist(
2555                    admin,
2556                    ITIP403Registry::modifyPolicyWhitelistCall {
2557                        policyId: policy_id,
2558                        account: recipient,
2559                        allowed: true,
2560                    },
2561                )?;
2562                assert!(!token.is_transfer_authorized(sender, recipient)?);
2563
2564                // Sender whitelisted, recipient not whitelisted
2565                registry.modify_policy_whitelist(
2566                    admin,
2567                    ITIP403Registry::modifyPolicyWhitelistCall {
2568                        policyId: policy_id,
2569                        account: sender,
2570                        allowed: true,
2571                    },
2572                )?;
2573                registry.modify_policy_whitelist(
2574                    admin,
2575                    ITIP403Registry::modifyPolicyWhitelistCall {
2576                        policyId: policy_id,
2577                        account: recipient,
2578                        allowed: false,
2579                    },
2580                )?;
2581                assert!(!token.is_transfer_authorized(sender, recipient)?);
2582
2583                // Both whitelisted
2584                registry.modify_policy_whitelist(
2585                    admin,
2586                    ITIP403Registry::modifyPolicyWhitelistCall {
2587                        policyId: policy_id,
2588                        account: recipient,
2589                        allowed: true,
2590                    },
2591                )?;
2592                assert!(token.is_transfer_authorized(sender, recipient)?);
2593
2594                Ok::<_, TempoPrecompileError>(())
2595            })?;
2596        }
2597
2598        Ok(())
2599    }
2600
2601    #[test]
2602    fn test_set_next_quote_token_rejects_path_usd() -> eyre::Result<()> {
2603        let mut storage = HashMapStorageProvider::new(1);
2604        let admin = Address::random();
2605
2606        StorageCtx::enter(&mut storage, || {
2607            let mut path_usd = TIP20Setup::path_usd(admin).apply()?;
2608            let other_token = TIP20Setup::create("Test", "T", admin).apply()?;
2609
2610            // pathUSD cannot update its quote token
2611            let result = path_usd.set_next_quote_token(
2612                admin,
2613                ITIP20::setNextQuoteTokenCall {
2614                    newQuoteToken: other_token.address,
2615                },
2616            );
2617            assert!(matches!(
2618                result,
2619                Err(TempoPrecompileError::TIP20(TIP20Error::InvalidQuoteToken(
2620                    _
2621                )))
2622            ));
2623
2624            Ok(())
2625        })
2626    }
2627
2628    #[test]
2629    fn test_non_path_usd_cycle_detection() -> eyre::Result<()> {
2630        let mut storage = HashMapStorageProvider::new(1);
2631        let admin = Address::random();
2632
2633        StorageCtx::enter(&mut storage, || {
2634            TIP20Setup::path_usd(admin).apply()?;
2635
2636            let mut token_b = TIP20Setup::create("TokenB", "TKNB", admin).apply()?;
2637            let token_a = TIP20Setup::create("TokenA", "TKNA", admin)
2638                .quote_token(token_b.address)
2639                .apply()?;
2640
2641            // Verify chain where token_a -> token_b -> PATH_USD
2642            assert_eq!(token_a.quote_token()?, token_b.address);
2643            assert_eq!(token_b.quote_token()?, PATH_USD_ADDRESS);
2644
2645            // Try to create cycle where token_b -> token_a
2646            token_b.set_next_quote_token(
2647                admin,
2648                ITIP20::setNextQuoteTokenCall {
2649                    newQuoteToken: token_a.address,
2650                },
2651            )?;
2652
2653            let result =
2654                token_b.complete_quote_token_update(admin, ITIP20::completeQuoteTokenUpdateCall {});
2655
2656            assert!(matches!(
2657                result,
2658                Err(TempoPrecompileError::TIP20(TIP20Error::InvalidQuoteToken(
2659                    _
2660                )))
2661            ));
2662
2663            // assert that quote tokens are unchanged
2664            assert_eq!(token_a.quote_token()?, token_b.address);
2665            assert_eq!(token_b.quote_token()?, PATH_USD_ADDRESS);
2666
2667            Ok(())
2668        })
2669    }
2670
2671    // ═══════════════════════════════════════════════════════════
2672    //  TIP-1022 Virtual Address Tests
2673    // ═══════════════════════════════════════════════════════════
2674
2675    #[test]
2676    fn test_mint_to_virtual_address_credits_master() -> eyre::Result<()> {
2677        let amount = U256::from(1000);
2678
2679        for hardfork in [TempoHardfork::T2, TempoHardfork::T3] {
2680            let mut storage = HashMapStorageProvider::new_with_spec(1, hardfork);
2681            let admin = Address::random();
2682
2683            StorageCtx::enter(&mut storage, || {
2684                let mut registry = AddressRegistry::new();
2685                let (_, virtual_addr) = register_virtual_master(&mut registry)?;
2686                let credited = if hardfork.is_t3() {
2687                    VIRTUAL_MASTER
2688                } else {
2689                    virtual_addr
2690                };
2691
2692                let mut token = TIP20Setup::create("Test", "TST", admin)
2693                    .with_issuer(admin)
2694                    .clear_events()
2695                    .apply()?;
2696
2697                // mint
2698                token.mint(
2699                    admin,
2700                    ITIP20::mintCall {
2701                        to: virtual_addr,
2702                        amount,
2703                    },
2704                )?;
2705
2706                if hardfork.is_t3() {
2707                    // T3: master is credited, virtual balance stays zero
2708                    assert_eq!(token.get_balance(VIRTUAL_MASTER)?, amount);
2709                    assert_eq!(token.get_balance(virtual_addr)?, U256::ZERO);
2710                    assert_eq!(token.total_supply()?, amount);
2711
2712                    // Events: Transfer(0→virtual) + Mint(virtual) + Transfer(virtual→master)
2713                    token.assert_emitted_events(vec![
2714                        TIP20Event::Transfer(ITIP20::Transfer {
2715                            from: Address::ZERO,
2716                            to: virtual_addr,
2717                            amount,
2718                        }),
2719                        TIP20Event::Mint(ITIP20::Mint {
2720                            to: virtual_addr,
2721                            amount,
2722                        }),
2723                        TIP20Event::Transfer(ITIP20::Transfer {
2724                            from: virtual_addr,
2725                            to: VIRTUAL_MASTER,
2726                            amount,
2727                        }),
2728                    ]);
2729                } else {
2730                    // Pre-T3: virtual address treated as literal, balance goes there
2731                    assert_eq!(token.get_balance(virtual_addr)?, amount);
2732                    assert_eq!(token.get_balance(VIRTUAL_MASTER)?, U256::ZERO);
2733                }
2734
2735                // mintWithMemo: same resolution behavior
2736                let pre = token.get_balance(credited)?;
2737                token.mint_with_memo(
2738                    admin,
2739                    ITIP20::mintWithMemoCall {
2740                        to: virtual_addr,
2741                        amount,
2742                        memo: FixedBytes::ZERO,
2743                    },
2744                )?;
2745                assert_eq!(token.get_balance(credited)? - pre, amount);
2746
2747                Ok::<_, TempoPrecompileError>(())
2748            })?;
2749        }
2750        Ok(())
2751    }
2752
2753    #[test]
2754    fn test_transfer_to_virtual_address_credits_master() -> eyre::Result<()> {
2755        let amount = U256::from(500);
2756
2757        for hardfork in [TempoHardfork::T2, TempoHardfork::T3] {
2758            let mut storage = HashMapStorageProvider::new_with_spec(1, hardfork);
2759            let admin = Address::random();
2760            let sender = Address::random();
2761
2762            StorageCtx::enter(&mut storage, || {
2763                let mut registry = AddressRegistry::new();
2764                let (_, virtual_addr) = register_virtual_master(&mut registry)?;
2765                let credited = if hardfork.is_t3() {
2766                    VIRTUAL_MASTER
2767                } else {
2768                    virtual_addr
2769                };
2770
2771                let mut token = TIP20Setup::create("Test", "TST", admin)
2772                    .with_issuer(admin)
2773                    .with_mint(sender, amount * U256::from(2))
2774                    .clear_events()
2775                    .apply()?;
2776
2777                // transfer
2778                token.transfer(
2779                    sender,
2780                    ITIP20::transferCall {
2781                        to: virtual_addr,
2782                        amount,
2783                    },
2784                )?;
2785
2786                if hardfork.is_t3() {
2787                    assert_eq!(token.get_balance(VIRTUAL_MASTER)?, amount);
2788                    assert_eq!(token.get_balance(virtual_addr)?, U256::ZERO);
2789
2790                    // Events: Transfer(sender→virtual) + Transfer(virtual→master)
2791                    token.assert_emitted_events(vec![
2792                        TIP20Event::Transfer(ITIP20::Transfer {
2793                            from: sender,
2794                            to: virtual_addr,
2795                            amount,
2796                        }),
2797                        TIP20Event::Transfer(ITIP20::Transfer {
2798                            from: virtual_addr,
2799                            to: VIRTUAL_MASTER,
2800                            amount,
2801                        }),
2802                    ]);
2803                } else {
2804                    assert_eq!(token.get_balance(virtual_addr)?, amount);
2805                    assert_eq!(token.get_balance(VIRTUAL_MASTER)?, U256::ZERO);
2806                }
2807
2808                // transferWithMemo: same resolution behavior
2809                let pre = token.get_balance(credited)?;
2810                token.transfer_with_memo(
2811                    sender,
2812                    ITIP20::transferWithMemoCall {
2813                        to: virtual_addr,
2814                        amount,
2815                        memo: FixedBytes::ZERO,
2816                    },
2817                )?;
2818                assert_eq!(token.get_balance(credited)? - pre, amount);
2819
2820                Ok::<_, TempoPrecompileError>(())
2821            })?;
2822        }
2823        Ok(())
2824    }
2825
2826    #[test]
2827    fn test_transfer_from_to_virtual_address_credits_master() -> eyre::Result<()> {
2828        let amount = U256::from(300);
2829
2830        for hardfork in [TempoHardfork::T2, TempoHardfork::T3] {
2831            let mut storage = HashMapStorageProvider::new_with_spec(1, hardfork);
2832            let admin = Address::random();
2833            let owner = Address::random();
2834            let spender = Address::random();
2835
2836            StorageCtx::enter(&mut storage, || {
2837                let mut registry = AddressRegistry::new();
2838                let (_, virtual_addr) = register_virtual_master(&mut registry)?;
2839                let credited = if hardfork.is_t3() {
2840                    VIRTUAL_MASTER
2841                } else {
2842                    virtual_addr
2843                };
2844
2845                let total = amount * U256::from(2);
2846                let mut token = TIP20Setup::create("Test", "TST", admin)
2847                    .with_issuer(admin)
2848                    .with_mint(owner, total)
2849                    .with_approval(owner, spender, total)
2850                    .clear_events()
2851                    .apply()?;
2852
2853                // transferFrom
2854                token.transfer_from(
2855                    spender,
2856                    ITIP20::transferFromCall {
2857                        from: owner,
2858                        to: virtual_addr,
2859                        amount,
2860                    },
2861                )?;
2862
2863                if hardfork.is_t3() {
2864                    assert_eq!(token.get_balance(VIRTUAL_MASTER)?, amount);
2865                    assert_eq!(token.get_balance(virtual_addr)?, U256::ZERO);
2866                } else {
2867                    assert_eq!(token.get_balance(virtual_addr)?, amount);
2868                    assert_eq!(token.get_balance(VIRTUAL_MASTER)?, U256::ZERO);
2869                }
2870
2871                // transferFromWithMemo: same resolution behavior
2872                let pre = token.get_balance(credited)?;
2873                token.transfer_from_with_memo(
2874                    spender,
2875                    ITIP20::transferFromWithMemoCall {
2876                        from: owner,
2877                        to: virtual_addr,
2878                        amount,
2879                        memo: FixedBytes::ZERO,
2880                    },
2881                )?;
2882                assert_eq!(token.get_balance(credited)? - pre, amount);
2883
2884                Ok::<_, TempoPrecompileError>(())
2885            })?;
2886        }
2887        Ok(())
2888    }
2889
2890    #[test]
2891    #[rustfmt::skip]
2892    fn test_unregistered_virtual_reverts_on_t3() -> eyre::Result<()> {
2893        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T3);
2894        let admin = Address::random();
2895        let sender = Address::random();
2896        let spender = Address::random();
2897        let to = Address::new_virtual(MasterId::ZERO, UserTag::ZERO);
2898        let amount = U256::from(100);
2899        let memo = FixedBytes::ZERO;
2900
2901        StorageCtx::enter(&mut storage, || {
2902            let mut token = TIP20Setup::create("Test", "TST", admin)
2903                .with_issuer(admin)
2904                .with_mint(sender, amount)
2905                .with_approval(sender, spender, amount)
2906                .apply()?;
2907
2908            // All 6 entrypoints should revert for an unregistered virtual address
2909            assert!(token.mint(admin, ITIP20::mintCall { to, amount }).is_err());
2910            assert!(token.mint_with_memo(admin, ITIP20::mintWithMemoCall { to, amount, memo }).is_err());
2911            assert!(token.transfer(sender, ITIP20::transferCall { to, amount }).is_err());
2912            assert!(token.transfer_with_memo(sender, ITIP20::transferWithMemoCall { to, amount, memo }).is_err());
2913            assert!(token.transfer_from(spender, ITIP20::transferFromCall { from: sender, to, amount }).is_err());
2914            assert!(token.transfer_from_with_memo(spender, ITIP20::transferFromWithMemoCall { from: sender, to, amount, memo }).is_err());
2915
2916            Ok(())
2917        })
2918    }
2919
2920    // ═══════════════════════════════════════════════════════════
2921    //  EIP-2612 Permit Tests (TIP-1004)
2922    // ═══════════════════════════════════════════════════════════
2923
2924    mod permit_tests {
2925        use super::*;
2926        use alloy::sol_types::SolValue;
2927        use alloy_signer::SignerSync;
2928        use alloy_signer_local::PrivateKeySigner;
2929        use tempo_chainspec::hardfork::TempoHardfork;
2930
2931        const CHAIN_ID: u64 = 42;
2932
2933        /// Create a T2 storage provider for permit tests
2934        fn setup_t2_storage() -> HashMapStorageProvider {
2935            HashMapStorageProvider::new_with_spec(CHAIN_ID, TempoHardfork::T2)
2936        }
2937
2938        /// Helper to create a valid permit signature
2939        fn sign_permit(
2940            signer: &PrivateKeySigner,
2941            token_name: &str,
2942            token_address: Address,
2943            spender: Address,
2944            value: U256,
2945            nonce: U256,
2946            deadline: U256,
2947        ) -> (u8, B256, B256) {
2948            let domain_separator = compute_domain_separator(token_name, token_address);
2949            let struct_hash = keccak256(
2950                (
2951                    *PERMIT_TYPEHASH,
2952                    signer.address(),
2953                    spender,
2954                    value,
2955                    nonce,
2956                    deadline,
2957                )
2958                    .abi_encode(),
2959            );
2960            let digest = keccak256(
2961                [
2962                    &[0x19, 0x01],
2963                    domain_separator.as_slice(),
2964                    struct_hash.as_slice(),
2965                ]
2966                .concat(),
2967            );
2968
2969            let sig = signer.sign_hash_sync(&digest).unwrap();
2970            let v = sig.v() as u8 + 27;
2971            let r: B256 = sig.r().into();
2972            let s: B256 = sig.s().into();
2973            (v, r, s)
2974        }
2975
2976        fn compute_domain_separator(token_name: &str, token_address: Address) -> B256 {
2977            keccak256(
2978                (
2979                    *EIP712_DOMAIN_TYPEHASH,
2980                    keccak256(token_name.as_bytes()),
2981                    *VERSION_HASH,
2982                    U256::from(CHAIN_ID),
2983                    token_address,
2984                )
2985                    .abi_encode(),
2986            )
2987        }
2988
2989        struct PermitFixture {
2990            storage: HashMapStorageProvider,
2991            admin: Address,
2992            signer: PrivateKeySigner,
2993            spender: Address,
2994        }
2995
2996        impl PermitFixture {
2997            fn new() -> Self {
2998                Self {
2999                    storage: setup_t2_storage(),
3000                    admin: Address::random(),
3001                    signer: PrivateKeySigner::random(),
3002                    spender: Address::random(),
3003                }
3004            }
3005        }
3006
3007        fn make_permit_call(
3008            signer: &PrivateKeySigner,
3009            spender: Address,
3010            token_address: Address,
3011            value: U256,
3012            nonce: U256,
3013            deadline: U256,
3014        ) -> ITIP20::permitCall {
3015            let (v, r, s) = sign_permit(
3016                signer,
3017                "Test",
3018                token_address,
3019                spender,
3020                value,
3021                nonce,
3022                deadline,
3023            );
3024            ITIP20::permitCall {
3025                owner: signer.address(),
3026                spender,
3027                value,
3028                deadline,
3029                v,
3030                r,
3031                s,
3032            }
3033        }
3034
3035        #[test]
3036        fn test_permit_happy_path() -> eyre::Result<()> {
3037            let PermitFixture {
3038                mut storage,
3039                admin,
3040                ref signer,
3041                spender,
3042            } = PermitFixture::new();
3043            let owner = signer.address();
3044            let value = U256::from(1000);
3045
3046            StorageCtx::enter(&mut storage, || {
3047                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
3048                let call =
3049                    make_permit_call(signer, spender, token.address, value, U256::ZERO, U256::MAX);
3050                token.permit(call)?;
3051
3052                // Verify allowance was set
3053                let allowance = token.allowance(ITIP20::allowanceCall { owner, spender })?;
3054                assert_eq!(allowance, value);
3055
3056                // Verify nonce was incremented
3057                let nonce = token.nonces(ITIP20::noncesCall { owner })?;
3058                assert_eq!(nonce, U256::from(1));
3059
3060                Ok(())
3061            })
3062        }
3063
3064        #[test]
3065        fn test_permit_expired() -> eyre::Result<()> {
3066            let PermitFixture {
3067                mut storage,
3068                admin,
3069                ref signer,
3070                spender,
3071            } = PermitFixture::new();
3072            let value = U256::from(1000);
3073            // Deadline in the past
3074            let deadline = U256::ZERO;
3075
3076            StorageCtx::enter(&mut storage, || {
3077                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
3078                let call =
3079                    make_permit_call(signer, spender, token.address, value, U256::ZERO, deadline);
3080
3081                let result = token.permit(call);
3082
3083                assert!(matches!(
3084                    result,
3085                    Err(TempoPrecompileError::TIP20(TIP20Error::PermitExpired(_)))
3086                ));
3087
3088                Ok(())
3089            })
3090        }
3091
3092        #[test]
3093        fn test_permit_invalid_signature() -> eyre::Result<()> {
3094            let mut storage = setup_t2_storage();
3095            let admin = Address::random();
3096            let owner = Address::random();
3097            let spender = Address::random();
3098            let value = U256::from(1000);
3099            let deadline = U256::MAX;
3100
3101            StorageCtx::enter(&mut storage, || {
3102                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
3103
3104                // Use garbage signature bytes
3105                let result = token.permit(ITIP20::permitCall {
3106                    owner,
3107                    spender,
3108                    value,
3109                    deadline,
3110                    v: 27,
3111                    r: B256::ZERO,
3112                    s: B256::ZERO,
3113                });
3114
3115                assert!(matches!(
3116                    result,
3117                    Err(TempoPrecompileError::TIP20(TIP20Error::InvalidSignature(_)))
3118                ));
3119
3120                Ok(())
3121            })
3122        }
3123
3124        #[test]
3125        fn test_permit_wrong_signer() -> eyre::Result<()> {
3126            let PermitFixture {
3127                mut storage,
3128                admin,
3129                ref signer,
3130                spender,
3131            } = PermitFixture::new();
3132            let wrong_owner = Address::random(); // Not the signer's address
3133            let value = U256::from(1000);
3134            let deadline = U256::MAX;
3135
3136            StorageCtx::enter(&mut storage, || {
3137                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
3138
3139                // Sign with signer but claim wrong_owner
3140                let (v, r, s) = sign_permit(
3141                    signer,
3142                    "Test",
3143                    token.address,
3144                    spender,
3145                    value,
3146                    U256::ZERO,
3147                    deadline,
3148                );
3149
3150                let result = token.permit(ITIP20::permitCall {
3151                    owner: wrong_owner, // Different from signer
3152                    spender,
3153                    value,
3154                    deadline,
3155                    v,
3156                    r,
3157                    s,
3158                });
3159
3160                assert!(matches!(
3161                    result,
3162                    Err(TempoPrecompileError::TIP20(TIP20Error::InvalidSignature(_)))
3163                ));
3164
3165                Ok(())
3166            })
3167        }
3168
3169        #[test]
3170        fn test_permit_replay_protection() -> eyre::Result<()> {
3171            let PermitFixture {
3172                mut storage,
3173                admin,
3174                ref signer,
3175                spender,
3176            } = PermitFixture::new();
3177            let value = U256::from(1000);
3178
3179            StorageCtx::enter(&mut storage, || {
3180                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
3181                let call =
3182                    make_permit_call(signer, spender, token.address, value, U256::ZERO, U256::MAX);
3183
3184                // First use should succeed
3185                token.permit(call.clone())?;
3186
3187                // Second use of same signature should fail (nonce incremented)
3188                let result = token.permit(call);
3189
3190                assert!(matches!(
3191                    result,
3192                    Err(TempoPrecompileError::TIP20(TIP20Error::InvalidSignature(_)))
3193                ));
3194
3195                Ok(())
3196            })
3197        }
3198
3199        #[test]
3200        fn test_permit_nonce_tracking() -> eyre::Result<()> {
3201            let PermitFixture {
3202                mut storage,
3203                admin,
3204                ref signer,
3205                spender,
3206            } = PermitFixture::new();
3207            let owner = signer.address();
3208
3209            StorageCtx::enter(&mut storage, || {
3210                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
3211
3212                // Initial nonce should be 0
3213                assert_eq!(token.nonces(ITIP20::noncesCall { owner })?, U256::ZERO);
3214
3215                // Do 3 permits, each with correct nonce
3216                for i in 0u64..3 {
3217                    let nonce = U256::from(i);
3218                    let value = U256::from(100 * (i + 1));
3219                    let call =
3220                        make_permit_call(signer, spender, token.address, value, nonce, U256::MAX);
3221                    token.permit(call)?;
3222
3223                    assert_eq!(
3224                        token.nonces(ITIP20::noncesCall { owner })?,
3225                        U256::from(i + 1)
3226                    );
3227                }
3228
3229                Ok(())
3230            })
3231        }
3232
3233        #[test]
3234        fn test_permit_works_when_paused() -> eyre::Result<()> {
3235            let PermitFixture {
3236                mut storage,
3237                admin,
3238                ref signer,
3239                spender,
3240            } = PermitFixture::new();
3241            let owner = signer.address();
3242            let value = U256::from(1000);
3243
3244            StorageCtx::enter(&mut storage, || {
3245                let mut token = TIP20Setup::create("Test", "TST", admin)
3246                    .with_role(admin, *PAUSE_ROLE)
3247                    .apply()?;
3248
3249                // Pause the token
3250                token.pause(admin, ITIP20::pauseCall {})?;
3251                assert!(token.paused()?);
3252
3253                let call =
3254                    make_permit_call(signer, spender, token.address, value, U256::ZERO, U256::MAX);
3255
3256                // Permit should work even when paused
3257                token.permit(call)?;
3258
3259                assert_eq!(
3260                    token.allowance(ITIP20::allowanceCall { owner, spender })?,
3261                    value
3262                );
3263
3264                Ok(())
3265            })
3266        }
3267
3268        #[test]
3269        fn test_permit_domain_separator() -> eyre::Result<()> {
3270            let PermitFixture {
3271                mut storage, admin, ..
3272            } = PermitFixture::new();
3273
3274            StorageCtx::enter(&mut storage, || {
3275                let token = TIP20Setup::create("Test", "TST", admin).apply()?;
3276
3277                let ds = token.domain_separator()?;
3278                let expected = compute_domain_separator("Test", token.address);
3279                assert_eq!(ds, expected);
3280
3281                Ok(())
3282            })
3283        }
3284
3285        #[test]
3286        fn test_permit_max_allowance() -> eyre::Result<()> {
3287            let PermitFixture {
3288                mut storage,
3289                admin,
3290                ref signer,
3291                spender,
3292            } = PermitFixture::new();
3293            let owner = signer.address();
3294
3295            StorageCtx::enter(&mut storage, || {
3296                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
3297                let call = make_permit_call(
3298                    signer,
3299                    spender,
3300                    token.address,
3301                    U256::MAX,
3302                    U256::ZERO,
3303                    U256::MAX,
3304                );
3305                token.permit(call)?;
3306
3307                assert_eq!(
3308                    token.allowance(ITIP20::allowanceCall { owner, spender })?,
3309                    U256::MAX
3310                );
3311
3312                Ok(())
3313            })
3314        }
3315
3316        #[test]
3317        fn test_permit_allowance_override() -> eyre::Result<()> {
3318            let PermitFixture {
3319                mut storage,
3320                admin,
3321                ref signer,
3322                spender,
3323            } = PermitFixture::new();
3324            let owner = signer.address();
3325
3326            StorageCtx::enter(&mut storage, || {
3327                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
3328
3329                // First permit: set allowance to 1000
3330                let call = make_permit_call(
3331                    signer,
3332                    spender,
3333                    token.address,
3334                    U256::from(1000),
3335                    U256::ZERO,
3336                    U256::MAX,
3337                );
3338                token.permit(call)?;
3339                assert_eq!(
3340                    token.allowance(ITIP20::allowanceCall { owner, spender })?,
3341                    U256::from(1000)
3342                );
3343
3344                // Second permit: override to 0
3345                let call = make_permit_call(
3346                    signer,
3347                    spender,
3348                    token.address,
3349                    U256::ZERO,
3350                    U256::from(1),
3351                    U256::MAX,
3352                );
3353                token.permit(call)?;
3354                assert_eq!(
3355                    token.allowance(ITIP20::allowanceCall { owner, spender })?,
3356                    U256::ZERO
3357                );
3358
3359                Ok(())
3360            })
3361        }
3362
3363        #[test]
3364        fn test_permit_invalid_v_values() -> eyre::Result<()> {
3365            let PermitFixture {
3366                mut storage,
3367                admin,
3368                spender,
3369                ..
3370            } = PermitFixture::new();
3371
3372            StorageCtx::enter(&mut storage, || {
3373                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
3374
3375                for v in [0u8, 1] {
3376                    let result = token.permit(ITIP20::permitCall {
3377                        owner: admin,
3378                        spender,
3379                        value: U256::from(1000),
3380                        deadline: U256::MAX,
3381                        v,
3382                        r: B256::ZERO,
3383                        s: B256::ZERO,
3384                    });
3385
3386                    assert!(
3387                        matches!(
3388                            result,
3389                            Err(TempoPrecompileError::TIP20(TIP20Error::InvalidSignature(_)))
3390                        ),
3391                        "v={v} should revert with InvalidSignature"
3392                    );
3393                }
3394
3395                Ok(())
3396            })
3397        }
3398
3399        #[test]
3400        fn test_permit_zero_address_recovery_reverts() -> eyre::Result<()> {
3401            let PermitFixture {
3402                mut storage,
3403                admin,
3404                spender,
3405                ..
3406            } = PermitFixture::new();
3407
3408            StorageCtx::enter(&mut storage, || {
3409                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
3410
3411                let result = token.permit(ITIP20::permitCall {
3412                    owner: Address::ZERO,
3413                    spender,
3414                    value: U256::from(1000),
3415                    deadline: U256::MAX,
3416                    v: 27,
3417                    r: B256::ZERO,
3418                    s: B256::ZERO,
3419                });
3420
3421                assert!(matches!(
3422                    result,
3423                    Err(TempoPrecompileError::TIP20(TIP20Error::InvalidSignature(_)))
3424                ));
3425
3426                Ok(())
3427            })
3428        }
3429
3430        #[test]
3431        fn test_permit_domain_separator_changes_with_chain_id() -> eyre::Result<()> {
3432            let PermitFixture { admin, .. } = PermitFixture::new();
3433
3434            let mut storage_a = setup_t2_storage();
3435            let mut storage_b =
3436                HashMapStorageProvider::new_with_spec(CHAIN_ID + 1, TempoHardfork::T2);
3437
3438            let ds_a = StorageCtx::enter(&mut storage_a, || {
3439                TIP20Setup::create("Test", "TST", admin)
3440                    .apply()?
3441                    .domain_separator()
3442            })?;
3443
3444            let ds_b = StorageCtx::enter(&mut storage_b, || {
3445                TIP20Setup::create("Test", "TST", admin)
3446                    .apply()?
3447                    .domain_separator()
3448            })?;
3449
3450            assert_ne!(
3451                ds_a, ds_b,
3452                "domain separator must change when chainId changes"
3453            );
3454
3455            Ok(())
3456        }
3457    }
3458
3459    #[test]
3460    fn test_mint_rejects_when_paused_on_t3() -> eyre::Result<()> {
3461        let to = Address::random();
3462        let amount = U256::from(1000);
3463        let memo = FixedBytes::random();
3464
3465        for hardfork in [TempoHardfork::T2, TempoHardfork::T3] {
3466            let mut storage = HashMapStorageProvider::new_with_spec(1, hardfork);
3467            let admin = Address::random();
3468
3469            StorageCtx::enter(&mut storage, || {
3470                let mut token = TIP20Setup::create("Test", "TST", admin)
3471                    .with_issuer(admin)
3472                    .with_role(admin, *PAUSE_ROLE)
3473                    .apply()?;
3474
3475                token.pause(admin, ITIP20::pauseCall {})?;
3476
3477                let mint_result = token.mint(admin, ITIP20::mintCall { to, amount });
3478                let mint_memo_result =
3479                    token.mint_with_memo(admin, ITIP20::mintWithMemoCall { to, amount, memo });
3480
3481                if hardfork.is_t3() {
3482                    let expected = TempoPrecompileError::TIP20(TIP20Error::contract_paused());
3483                    assert_eq!(mint_result, Err(expected.clone()));
3484                    assert_eq!(mint_memo_result, Err(expected));
3485                } else {
3486                    assert!(mint_result.is_ok());
3487                    assert!(mint_memo_result.is_ok());
3488                }
3489
3490                Ok::<_, TempoPrecompileError>(())
3491            })?;
3492        }
3493        Ok(())
3494    }
3495
3496    #[test]
3497    fn test_burn_rejects_when_paused_on_t3() -> eyre::Result<()> {
3498        let amount = U256::from(500);
3499        let memo = FixedBytes::random();
3500
3501        for hardfork in [TempoHardfork::T2, TempoHardfork::T3] {
3502            let mut storage = HashMapStorageProvider::new_with_spec(1, hardfork);
3503            let admin = Address::random();
3504
3505            StorageCtx::enter(&mut storage, || {
3506                let mut token = TIP20Setup::create("Test", "TST", admin)
3507                    .with_issuer(admin)
3508                    .with_role(admin, *PAUSE_ROLE)
3509                    .with_mint(admin, amount * U256::from(2))
3510                    .apply()?;
3511
3512                token.pause(admin, ITIP20::pauseCall {})?;
3513
3514                let burn_result = token.burn(admin, ITIP20::burnCall { amount });
3515                let burn_memo_result =
3516                    token.burn_with_memo(admin, ITIP20::burnWithMemoCall { amount, memo });
3517
3518                if hardfork.is_t3() {
3519                    let expected = TempoPrecompileError::TIP20(TIP20Error::contract_paused());
3520                    assert_eq!(burn_result, Err(expected.clone()));
3521                    assert_eq!(burn_memo_result, Err(expected));
3522                } else {
3523                    assert!(burn_result.is_ok());
3524                    assert!(burn_memo_result.is_ok());
3525                }
3526
3527                Ok::<_, TempoPrecompileError>(())
3528            })?;
3529        }
3530        Ok(())
3531    }
3532
3533    #[test]
3534    fn test_burn_blocked_rejects_when_paused_on_t3() -> eyre::Result<()> {
3535        let amount = U256::from(500);
3536        let blocked = Address::random();
3537
3538        for hardfork in [TempoHardfork::T2, TempoHardfork::T3] {
3539            let mut storage = HashMapStorageProvider::new_with_spec(1, hardfork);
3540            let admin = Address::random();
3541
3542            StorageCtx::enter(&mut storage, || {
3543                // Create a blacklist policy and block the address
3544                let mut registry = TIP403Registry::new();
3545                registry.initialize()?;
3546                let policy_id = registry.create_policy(
3547                    admin,
3548                    ITIP403Registry::createPolicyCall {
3549                        admin,
3550                        policyType: ITIP403Registry::PolicyType::BLACKLIST,
3551                    },
3552                )?;
3553                registry.modify_policy_blacklist(
3554                    admin,
3555                    ITIP403Registry::modifyPolicyBlacklistCall {
3556                        policyId: policy_id,
3557                        account: blocked,
3558                        restricted: true,
3559                    },
3560                )?;
3561
3562                let mut token = TIP20Setup::create("Test", "TST", admin)
3563                    .with_issuer(admin)
3564                    .with_role(admin, *PAUSE_ROLE)
3565                    .with_role(admin, *BURN_BLOCKED_ROLE)
3566                    .with_mint(blocked, amount)
3567                    .apply()?;
3568
3569                // Point the token's transfer policy at our blacklist
3570                token.change_transfer_policy_id(
3571                    admin,
3572                    ITIP20::changeTransferPolicyIdCall {
3573                        newPolicyId: policy_id,
3574                    },
3575                )?;
3576
3577                // Pause the token
3578                token.pause(admin, ITIP20::pauseCall {})?;
3579
3580                let result = token.burn_blocked(
3581                    admin,
3582                    ITIP20::burnBlockedCall {
3583                        from: blocked,
3584                        amount,
3585                    },
3586                );
3587
3588                if hardfork.is_t3() {
3589                    assert_eq!(
3590                        result,
3591                        Err(TempoPrecompileError::TIP20(TIP20Error::contract_paused()))
3592                    );
3593                } else {
3594                    // T2: pause not enforced, burn succeeds
3595                    assert!(result.is_ok());
3596                    assert_eq!(token.get_balance(blocked)?, U256::ZERO);
3597                }
3598
3599                Ok::<_, TempoPrecompileError>(())
3600            })?;
3601        }
3602        Ok(())
3603    }
3604}