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