Skip to main content

tempo_precompiles/tip20/
mod.rs

1//! [TIP-20] token standard — Tempo's native fungible token implementation.
2//!
3//! Provides ERC-20-like balances, allowances, and transfers with Tempo extensions:
4//! role-based access control, pausability, supply caps, transfer policies ([TIP-403]), opt-in
5//! staking rewards, EIP-2612 permits (T2+), quote-token graphs, and virtual addresses ([TIP-1022]).
6//!
7//! [TIP-20]: <https://docs.tempo.xyz/protocol/tip20>
8//! [TIP-403]: <https://docs.tempo.xyz/protocol/tip403>
9//! [TIP-1022]: <https://docs.tempo.xyz/protocol/tip1022>
10
11pub mod dispatch;
12pub mod rewards;
13pub mod roles;
14
15pub use tempo_contracts::precompiles::{
16    IRolesAuth, ITIP20, RolesAuthError, RolesAuthEvent, TIP20Error, TIP20Event, USD_CURRENCY,
17};
18pub use tempo_primitives::is_tip20_prefix;
19
20// Re-export the generated slots module for external access to storage slot constants
21pub use slots as tip20_slots;
22
23use crate::{
24    PATH_USD_ADDRESS, RECEIVE_POLICY_GUARD_ADDRESS, TIP_FEE_MANAGER_ADDRESS,
25    account_keychain::AccountKeychain,
26    address_registry::AddressRegistry,
27    error::{Result, TempoPrecompileError},
28    receive_policy_guard::{InboundKind, ReceivePolicyGuard, RecoveryMode},
29    storage::{Handler, Mapping},
30    tip20::{rewards::UserRewardInfo, roles::DEFAULT_ADMIN_ROLE},
31    tip20_factory::TIP20Factory,
32    tip403_registry::{AuthRole, ITIP403Registry, TIP403Registry},
33};
34use alloy::{
35    primitives::{Address, B256, U256, keccak256, uint},
36    sol_types::SolValue,
37};
38use std::sync::LazyLock;
39use tempo_chainspec::hardfork::TempoHardfork;
40use tempo_contracts::precompiles::{
41    DECIMALS as TIP20_DECIMALS, ReceivePolicyGuardError, STABLECOIN_DEX_ADDRESS,
42    TIP20_CHANNEL_RESERVE_ADDRESS,
43};
44use tempo_precompiles_macros::contract;
45use tempo_primitives::TempoAddressExt;
46use tracing::trace;
47
48/// u128::MAX as U256
49pub const U128_MAX: U256 = uint!(0xffffffffffffffffffffffffffffffff_U256);
50
51/// Validates that the given token's currency is `"USD"`.
52///
53/// # Errors
54/// - `InvalidToken` — address does not have the TIP-20 prefix
55/// - `InvalidCurrency` — token currency is not `"USD"`
56pub fn validate_usd_currency(token: Address) -> Result<()> {
57    if TIP20Token::from_address(token)?.currency()? != USD_CURRENCY {
58        return Err(TIP20Error::invalid_currency().into());
59    }
60    Ok(())
61}
62
63/// TIP-20 token contract — the native token standard on Tempo.
64///
65/// Implements ERC-20-like functionality (balances, allowances, transfers) with additional
66/// features: role-based access control, pausability, supply caps, transfer policies ([TIP-403]),
67/// virtual addresses ([TIP-1022]), and opt-in staking rewards.
68///
69/// [TIP-403]: <https://docs.tempo.xyz/protocol/tip403>
70/// [TIP-1022]: <https://docs.tempo.xyz/protocol/tip1022>
71///
72/// Each token lives at a deterministic address with the `0x20C0` prefix.
73///
74/// The struct fields define the on-chain storage layout; the `#[contract]` macro generates the
75/// storage handlers which provide an ergonomic way to interact with the EVM state.
76#[contract]
77pub struct TIP20Token {
78    // RolesAuth
79    roles: Mapping<Address, Mapping<B256, bool>>,
80    role_admins: Mapping<B256, B256>,
81
82    // TIP20 Metadata
83    name: String,
84    symbol: String,
85    currency: String,
86    // TIP-1026: Token Logo URI.
87    // Reuses the previously-unused `_domain_separator` slot (always 0 on
88    // pre-T5 tokens), which reads as the empty string under Solidity's
89    // short-string encoding — matching the spec's "default empty" semantics.
90    // Assumes the slot was never written; do not write to it from pre-T5 code.
91    logo_uri: String,
92    quote_token: Address,
93    next_quote_token: Address,
94    transfer_policy_id: u64,
95
96    // TIP20 Token
97    total_supply: U256,
98    balances: Mapping<Address, U256>,
99    allowances: Mapping<Address, Mapping<Address, U256>>,
100    permit_nonces: Mapping<Address, U256>,
101    paused: bool,
102    supply_cap: U256,
103    // Unused slot, kept for storage layout compatibility
104    _salts: Mapping<B256, bool>,
105
106    // TIP20 Rewards
107    global_reward_per_token: U256,
108    opted_in_supply: u128,
109    user_reward_info: Mapping<Address, UserRewardInfo>,
110}
111
112/// EIP-712 Permit typehash: keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")
113pub static PERMIT_TYPEHASH: LazyLock<B256> = LazyLock::new(|| {
114    keccak256(b"Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)")
115});
116
117/// EIP-712 domain separator typehash
118pub static EIP712_DOMAIN_TYPEHASH: LazyLock<B256> = LazyLock::new(|| {
119    keccak256(b"EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)")
120});
121
122/// EIP-712 version hash: keccak256("1")
123pub static VERSION_HASH: LazyLock<B256> = LazyLock::new(|| keccak256(b"1"));
124
125/// Role hash for pausing token transfers.
126pub static PAUSE_ROLE: LazyLock<B256> = LazyLock::new(|| keccak256(b"PAUSE_ROLE"));
127/// Role hash for unpausing token transfers.
128pub static UNPAUSE_ROLE: LazyLock<B256> = LazyLock::new(|| keccak256(b"UNPAUSE_ROLE"));
129/// Role hash for minting new tokens.
130pub static ISSUER_ROLE: LazyLock<B256> = LazyLock::new(|| keccak256(b"ISSUER_ROLE"));
131/// Role hash that authorizes burning tokens from blocked accounts.
132pub static BURN_BLOCKED_ROLE: LazyLock<B256> = LazyLock::new(|| keccak256(b"BURN_BLOCKED_ROLE"));
133
134#[rustfmt::skip]
135/// System custody addresses added to burn-blocked protection at each hardfork.
136pub const PROTECTED: &[(TempoHardfork, &[Address])] = &[
137    (TempoHardfork::Genesis, &[TIP_FEE_MANAGER_ADDRESS, STABLECOIN_DEX_ADDRESS]),
138    (TempoHardfork::T5, &[TIP20_CHANNEL_RESERVE_ADDRESS]),
139    (TempoHardfork::T6, &[RECEIVE_POLICY_GUARD_ADDRESS]),
140];
141
142impl TIP20Token {
143    /// Returns the token name.
144    pub fn name(&self) -> Result<String> {
145        self.name.read()
146    }
147
148    /// Returns the token symbol.
149    pub fn symbol(&self) -> Result<String> {
150        self.symbol.read()
151    }
152
153    /// Returns the token decimals (always 6 for TIP-20).
154    pub fn decimals(&self) -> Result<u8> {
155        Ok(TIP20_DECIMALS)
156    }
157
158    /// Returns the token's currency denomination (e.g. `"USD"`).
159    pub fn currency(&self) -> Result<String> {
160        self.currency.read()
161    }
162
163    /// Returns the logo URI for this token (TIP-1026).
164    ///
165    /// Returns an empty string if not set.
166    pub fn logo_uri(&self) -> Result<String> {
167        self.logo_uri.read()
168    }
169
170    /// Returns the current total supply.
171    pub fn total_supply(&self) -> Result<U256> {
172        self.total_supply.read()
173    }
174
175    /// Returns the active quote token address used for pricing.
176    pub fn quote_token(&self) -> Result<Address> {
177        self.quote_token.read()
178    }
179
180    /// Returns the pending next quote token address (set but not yet finalized).
181    pub fn next_quote_token(&self) -> Result<Address> {
182        self.next_quote_token.read()
183    }
184
185    /// Returns the maximum mintable supply.
186    pub fn supply_cap(&self) -> Result<U256> {
187        self.supply_cap.read()
188    }
189
190    /// Returns whether the token is currently paused.
191    pub fn paused(&self) -> Result<bool> {
192        self.paused.read()
193    }
194
195    /// Returns the TIP-403 transfer policy ID governing this token's transfers.
196    pub fn transfer_policy_id(&self) -> Result<u64> {
197        self.transfer_policy_id.read()
198    }
199
200    /// Returns the PAUSE_ROLE constant
201    ///
202    /// This role identifier grants permission to pause the token contract.
203    /// The role is computed as `keccak256("PAUSE_ROLE")`.
204    pub fn pause_role() -> B256 {
205        *PAUSE_ROLE
206    }
207
208    /// Returns the UNPAUSE_ROLE constant
209    ///
210    /// This role identifier grants permission to unpause the token contract.
211    /// The role is computed as `keccak256("UNPAUSE_ROLE")`.
212    pub fn unpause_role() -> B256 {
213        *UNPAUSE_ROLE
214    }
215
216    /// Returns the ISSUER_ROLE constant
217    ///
218    /// This role identifier grants permission to mint and burn tokens.
219    /// The role is computed as `keccak256("ISSUER_ROLE")`.
220    pub fn issuer_role() -> B256 {
221        *ISSUER_ROLE
222    }
223
224    /// Returns the BURN_BLOCKED_ROLE constant
225    ///
226    /// This role identifier grants permission to burn tokens from blocked accounts.
227    /// The role is computed as `keccak256("BURN_BLOCKED_ROLE")`.
228    pub fn burn_blocked_role() -> B256 {
229        *BURN_BLOCKED_ROLE
230    }
231
232    /// Returns the token balance of `account`.
233    pub fn balance_of(&self, call: ITIP20::balanceOfCall) -> Result<U256> {
234        self.balances[call.account].read()
235    }
236
237    /// Returns the remaining allowance that `spender` can transfer on behalf of `owner`.
238    pub fn allowance(&self, call: ITIP20::allowanceCall) -> Result<U256> {
239        self.allowances[call.owner][call.spender].read()
240    }
241
242    /// Updates the [`TIP403Registry`] transfer policy governing this token's transfers.
243    ///
244    /// # Errors
245    /// - `Unauthorized` — caller does not hold `DEFAULT_ADMIN_ROLE`
246    /// - `InvalidTransferPolicyId` — policy does not exist in the [`TIP403Registry`]
247    pub fn change_transfer_policy_id(
248        &mut self,
249        msg_sender: Address,
250        call: ITIP20::changeTransferPolicyIdCall,
251    ) -> Result<()> {
252        self.check_role(msg_sender, DEFAULT_ADMIN_ROLE)?;
253
254        // Validate that the policy exists
255        if !TIP403Registry::new().policy_exists(ITIP403Registry::policyExistsCall {
256            policyId: call.newPolicyId,
257        })? {
258            return Err(TIP20Error::invalid_transfer_policy_id().into());
259        }
260
261        self.transfer_policy_id.write(call.newPolicyId)?;
262
263        self.emit_event(TIP20Event::transfer_policy_update(
264            msg_sender,
265            call.newPolicyId,
266        ))
267    }
268
269    /// Sets a new supply cap. Must be ≥ current total supply and ≤ [`U128_MAX`].
270    ///
271    /// # Errors
272    /// - `Unauthorized` — caller does not hold `DEFAULT_ADMIN_ROLE`
273    /// - `InvalidSupplyCap` — new cap is below current total supply
274    /// - `SupplyCapExceeded` — new cap exceeds [`U128_MAX`]
275    pub fn set_supply_cap(
276        &mut self,
277        msg_sender: Address,
278        call: ITIP20::setSupplyCapCall,
279    ) -> Result<()> {
280        self.check_role(msg_sender, DEFAULT_ADMIN_ROLE)?;
281        if call.newSupplyCap < self.total_supply()? {
282            return Err(TIP20Error::invalid_supply_cap().into());
283        }
284
285        if call.newSupplyCap > U128_MAX {
286            return Err(TIP20Error::supply_cap_exceeded().into());
287        }
288
289        self.supply_cap.write(call.newSupplyCap)?;
290
291        self.emit_event(TIP20Event::supply_cap_update(msg_sender, call.newSupplyCap))
292    }
293
294    // ========== TIP-1026: Logo URI ==========
295
296    /// Maximum byte length of a token logo URI (TIP-1026).
297    pub const MAX_LOGO_URI_BYTES: usize = 256;
298
299    /// Allowlist of ASCII-case-insensitive URI schemes accepted for [`Self::set_logo_uri`].
300    ///
301    /// TIP-1026 guarantees that the protocol validates the scheme prefix to make integration easier
302    /// and reject obviously dangerous values (e.g. `javascript:`). What the consumer does with the URI
303    /// afterwards (rendering, fetching, etc.) is out of scope and remains the consumer's responsibility.
304    pub const ALLOWED_LOGO_URI_SCHEMES: &'static [&'static str] =
305        &["https", "http", "ipfs", "data"];
306
307    /// Validates a logo URI against the TIP-1026 protocol rules:
308    /// - length ≤ [`Self::MAX_LOGO_URI_BYTES`]
309    /// - syntactically well-formed URI schemes in [`Self::ALLOWED_LOGO_URI_SCHEMES`].
310    ///
311    /// Empty strings are accepted unconditionally.
312    pub(crate) fn validate_logo_uri(uri: &str) -> Result<()> {
313        if uri.len() > Self::MAX_LOGO_URI_BYTES {
314            return Err(TIP20Error::logo_uri_too_long().into());
315        }
316        if !uri.is_empty() && !Self::is_allowed_logo_uri(uri) {
317            return Err(TIP20Error::invalid_logo_uri().into());
318        }
319        Ok(())
320    }
321
322    fn is_allowed_logo_uri(uri: &str) -> bool {
323        let Some((scheme, _rest)) = uri.split_once(':') else {
324            return false;
325        };
326
327        let mut bytes = scheme.bytes();
328        let Some(first) = bytes.next() else {
329            return false;
330        };
331        if !first.is_ascii_alphabetic() {
332            return false;
333        }
334        if !bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.')) {
335            return false;
336        }
337
338        Self::ALLOWED_LOGO_URI_SCHEMES
339            .iter()
340            .any(|allowed| scheme.eq_ignore_ascii_case(allowed))
341    }
342
343    /// Sets the logo URI for this token (TIP-1026). Empty strings are valid
344    /// and clear the URI.
345    ///
346    /// # Errors
347    /// - `Unauthorized` — caller does not hold `DEFAULT_ADMIN_ROLE`
348    /// - `LogoURITooLong` — `bytes(newLogoURI).length > 256`
349    /// - `InvalidLogoURI` — `newLogoURI` is non-empty and either has no
350    ///   parseable scheme (RFC 3986 §3.1) or its scheme is not in
351    ///   [`Self::ALLOWED_LOGO_URI_SCHEMES`]
352    pub fn set_logo_uri(
353        &mut self,
354        msg_sender: Address,
355        call: ITIP20::setLogoURICall,
356    ) -> Result<()> {
357        self.check_role(msg_sender, DEFAULT_ADMIN_ROLE)?;
358        self.write_logo_uri(msg_sender, call.newLogoURI)
359    }
360
361    /// Internal helper: runs [`Self::validate_logo_uri`] (length cap + scheme allowlist), stores the
362    /// value, and emits `LogoURIUpdated`.
363    ///
364    /// **IMPORTANT:** this function performs NO role check. It is the caller's responsibility.
365    pub(crate) fn write_logo_uri(&mut self, updater: Address, new_logo_uri: String) -> Result<()> {
366        Self::validate_logo_uri(&new_logo_uri)?;
367
368        self.logo_uri.write(new_logo_uri.clone())?;
369
370        self.emit_event(TIP20Event::LogoURIUpdated(ITIP20::LogoURIUpdated {
371            updater,
372            newLogoURI: new_logo_uri,
373        }))
374    }
375
376    // ========== End TIP-1026 ==========
377
378    /// Pauses all token transfers.
379    ///
380    /// # Errors
381    /// - `Unauthorized` — caller does not hold `PAUSE_ROLE`
382    pub fn pause(&mut self, msg_sender: Address, _call: ITIP20::pauseCall) -> Result<()> {
383        self.check_role(msg_sender, *PAUSE_ROLE)?;
384        self.paused.write(true)?;
385
386        self.emit_event(TIP20Event::pause_state_update(msg_sender, true))
387    }
388
389    /// Unpauses token transfers.
390    ///
391    /// # Errors
392    /// - `Unauthorized` — caller does not hold `UNPAUSE_ROLE`
393    pub fn unpause(&mut self, msg_sender: Address, _call: ITIP20::unpauseCall) -> Result<()> {
394        self.check_role(msg_sender, *UNPAUSE_ROLE)?;
395        self.paused.write(false)?;
396
397        self.emit_event(TIP20Event::pause_state_update(msg_sender, false))
398    }
399
400    /// Stages a new quote token. Must be finalized via [`Self::complete_quote_token_update`].
401    /// Validates that the candidate is a deployed TIP-20 token (via [`TIP20Factory`]) and, for
402    /// USD-denominated tokens, that the candidate is also USD-denominated.
403    ///
404    /// # Errors
405    /// - `Unauthorized` — caller does not hold `DEFAULT_ADMIN_ROLE`
406    /// - `InvalidQuoteToken` — token is pathUSD, candidate is not a deployed TIP-20, or
407    ///   USD currency mismatch
408    pub fn set_next_quote_token(
409        &mut self,
410        msg_sender: Address,
411        call: ITIP20::setNextQuoteTokenCall,
412    ) -> Result<()> {
413        self.check_role(msg_sender, DEFAULT_ADMIN_ROLE)?;
414
415        if self.address == PATH_USD_ADDRESS {
416            return Err(TIP20Error::invalid_quote_token().into());
417        }
418
419        // Verify the new quote token is a valid TIP20 token that has been deployed
420        // use factory's `is_tip20()` which checks both prefix and counter
421        if !TIP20Factory::new().is_tip20(call.newQuoteToken)? {
422            return Err(TIP20Error::invalid_quote_token().into());
423        }
424
425        // Check if the currency is USD, if so then the quote token's currency MUST also be USD
426        let currency = self.currency()?;
427        if currency == USD_CURRENCY {
428            let quote_token_currency = Self::from_address(call.newQuoteToken)?.currency()?;
429            if quote_token_currency != USD_CURRENCY {
430                return Err(TIP20Error::invalid_quote_token().into());
431            }
432        }
433
434        self.next_quote_token.write(call.newQuoteToken)?;
435
436        self.emit_event(TIP20Event::next_quote_token_set(
437            msg_sender,
438            call.newQuoteToken,
439        ))
440    }
441
442    /// Finalizes the staged quote token update. Walks the quote-token chain to detect cycles
443    /// before committing the change.
444    ///
445    /// # Errors
446    /// - `Unauthorized` — caller does not hold `DEFAULT_ADMIN_ROLE`
447    /// - `InvalidQuoteToken` — update would create a cycle in the quote-token graph
448    pub fn complete_quote_token_update(
449        &mut self,
450        msg_sender: Address,
451        _call: ITIP20::completeQuoteTokenUpdateCall,
452    ) -> Result<()> {
453        self.check_role(msg_sender, DEFAULT_ADMIN_ROLE)?;
454
455        let next_quote_token = self.next_quote_token()?;
456
457        // Check that this does not create a loop
458        // Loop through quote tokens until we reach the root (pathUSD)
459        let mut current = next_quote_token;
460        while current != PATH_USD_ADDRESS {
461            if current == self.address {
462                return Err(TIP20Error::invalid_quote_token().into());
463            }
464
465            current = Self::from_address(current)?.quote_token()?;
466        }
467
468        // Update the quote token
469        self.quote_token.write(next_quote_token)?;
470
471        self.emit_event(TIP20Event::quote_token_update(msg_sender, next_quote_token))
472    }
473
474    // Token operations
475
476    /// Mints `amount` tokens to the resolved target `to` address:
477    /// - Enforces mint-recipient compliance via [`TIP403Registry`] and validates against supply cap
478    /// - Resolves `to` via the [`AddressRegistry`]. If `to` is a virtual address, credits the
479    ///   resolved master and emits a two-hop `Transfer` and `Mint(virtual, amount)` events
480    ///
481    /// # Errors
482    /// - `Unauthorized` — caller does not hold the `ISSUER_ROLE` role
483    /// - `ContractPaused` — (+T3) token is paused
484    /// - `InvalidRecipient` — (+T3) recipient is zero or a TIP-20 prefix address
485    /// - `PolicyForbids` — TIP-403 policy rejects the mint recipient
486    /// - `SupplyCapExceeded` — minting would push total supply above the cap
487    pub fn mint(&mut self, msg_sender: Address, call: ITIP20::mintCall) -> Result<()> {
488        let Some((total_supply, to)) =
489            self.validate_mint(msg_sender, call.to, call.amount, B256::ZERO)?
490        else {
491            return Ok(());
492        };
493
494        self._mint(&to, total_supply, call.amount)?;
495        self.emit_event(TIP20Event::mint(call.to, call.amount))?;
496        if let Some(hop) = to.build_virtual_transfer_event(call.amount) {
497            self.emit_event(hop)?;
498        }
499
500        Ok(())
501    }
502
503    /// Like [`Self::mint`], but attaches a 32-byte memo.
504    pub fn mint_with_memo(
505        &mut self,
506        msg_sender: Address,
507        call: ITIP20::mintWithMemoCall,
508    ) -> Result<()> {
509        let Some((total_supply, to)) =
510            self.validate_mint(msg_sender, call.to, call.amount, call.memo)?
511        else {
512            return Ok(());
513        };
514
515        self._mint(&to, total_supply, call.amount)?;
516        self.emit_event(TIP20Event::transfer_with_memo(
517            Address::ZERO,
518            call.to,
519            call.amount,
520            call.memo,
521        ))?;
522        self.emit_event(TIP20Event::mint(call.to, call.amount))?;
523        if let Some(hop) = to.build_virtual_transfer_event(call.amount) {
524            self.emit_event(hop)?;
525        }
526        Ok(())
527    }
528
529    /// Internal helper to mint new tokens and update balances.
530    pub(crate) fn _mint(&mut self, to: &Recipient, total_supply: U256, amount: U256) -> Result<()> {
531        let new_supply = total_supply
532            .checked_add(amount)
533            .ok_or(TempoPrecompileError::under_overflow())?;
534
535        let supply_cap = self.supply_cap()?;
536        if new_supply > supply_cap {
537            return Err(TIP20Error::supply_cap_exceeded().into());
538        }
539
540        self.handle_rewards_on_mint(to.target, amount)?;
541
542        self.set_total_supply(new_supply)?;
543        self.increment_balance(to.target, amount)?;
544
545        self.emit_event(to.build_transfer_event(Address::ZERO, amount))
546    }
547
548    /// Burns `amount` from the caller's balance and reduces total supply.
549    ///
550    /// # Errors
551    /// - `ContractPaused` — (+T3) token is paused
552    /// - `Unauthorized` — caller does not hold the `ISSUER_ROLE` role
553    /// - `InsufficientBalance` — caller balance lower than burn amount
554    pub fn burn(&mut self, msg_sender: Address, call: ITIP20::burnCall) -> Result<()> {
555        self._burn(msg_sender, call.amount)?;
556        self.emit_event(TIP20Event::burn(msg_sender, call.amount))
557    }
558
559    /// Like [`Self::burn`], but attaches a 32-byte memo.
560    pub fn burn_with_memo(
561        &mut self,
562        msg_sender: Address,
563        call: ITIP20::burnWithMemoCall,
564    ) -> Result<()> {
565        self._burn(msg_sender, call.amount)?;
566
567        self.emit_event(TIP20Event::transfer_with_memo(
568            msg_sender,
569            Address::ZERO,
570            call.amount,
571            call.memo,
572        ))?;
573        self.emit_event(TIP20Event::burn(msg_sender, call.amount))
574    }
575
576    /// Burns tokens from addresses blocked by [`TIP403Registry`] policy. Where `owner` refers to
577    /// the account with ownership of the funds, either directly, or via the `ReceivePolicyGuard`.
578    ///
579    /// # Errors
580    /// - `ContractPaused` — (+T3) token is paused
581    /// - `Unauthorized` — caller does not hold `BURN_BLOCKED_ROLE`
582    /// - `PolicyForbids` — target address is not blocked by policy
583    /// - `ProtectedAddress` — cannot burn from protected system custody addresses
584    pub fn burn_blocked(
585        &mut self,
586        msg_sender: Address,
587        owner: Address,
588        amount: U256,
589        check_protected: bool,
590    ) -> Result<()> {
591        let hardfork = self.storage.spec();
592
593        // Validate burner role and (+T3) ensure token is not paused
594        if hardfork.is_t3() {
595            self.check_not_paused()?;
596        }
597        self.check_role(msg_sender, *BURN_BLOCKED_ROLE)?;
598
599        if check_protected {
600            // Prevent burning from system custody addresses to protect accounting invariants.
601            if PROTECTED
602                .iter()
603                .any(|(hf, addr)| hardfork >= *hf && addr.contains(&owner))
604                || (hardfork.is_t5() && owner == self.address)
605            {
606                return Err(TIP20Error::protected_address().into());
607            }
608        }
609
610        // Check if the address is blocked from transferring (sender authorization)
611        let policy_id = self.transfer_policy_id()?;
612        if TIP403Registry::new().is_authorized_as(policy_id, owner, AuthRole::sender())? {
613            // Only allow burning from addresses that are blocked from transferring
614            return Err(TIP20Error::policy_forbids().into());
615        }
616
617        let burn_from = if check_protected {
618            owner
619        } else {
620            RECEIVE_POLICY_GUARD_ADDRESS
621        };
622        self._transfer(burn_from, &Recipient::direct(Address::ZERO), amount)?;
623
624        let total_supply = self.total_supply()?;
625        let new_supply =
626            total_supply
627                .checked_sub(amount)
628                .ok_or(TIP20Error::insufficient_balance(
629                    total_supply,
630                    amount,
631                    self.address,
632                ))?;
633        self.set_total_supply(new_supply)?;
634
635        self.emit_event(TIP20Event::burn_blocked(owner, amount))
636    }
637
638    fn _burn(&mut self, msg_sender: Address, amount: U256) -> Result<()> {
639        // Validate issuer role and (+T3) ensure token is not paused
640        if self.storage.spec().is_t3() {
641            self.check_not_paused()?;
642        }
643        self.check_role(msg_sender, *ISSUER_ROLE)?;
644
645        self._transfer(msg_sender, &Recipient::direct(Address::ZERO), amount)?;
646
647        let total_supply = self.total_supply()?;
648        let new_supply =
649            total_supply
650                .checked_sub(amount)
651                .ok_or(TIP20Error::insufficient_balance(
652                    total_supply,
653                    amount,
654                    self.address,
655                ))?;
656        self.set_total_supply(new_supply)
657    }
658
659    /// Sets `spender`'s allowance to `amount` for the caller's tokens.
660    /// Deducts from the caller's [`AccountKeychain`] spending limit
661    /// when the new allowance exceeds the previous one.
662    ///
663    /// # Errors
664    /// - `SpendingLimitExceeded` — new allowance exceeds access key spending limit
665    pub fn approve(&mut self, msg_sender: Address, call: ITIP20::approveCall) -> Result<bool> {
666        // Check and update spending limits for access keys
667        AccountKeychain::new().authorize_approve(
668            msg_sender,
669            self.address,
670            self.get_allowance(msg_sender, call.spender)?,
671            call.amount,
672        )?;
673
674        // Set the new allowance
675        self.set_allowance(msg_sender, call.spender, call.amount)?;
676
677        self.emit_event(TIP20Event::approval(msg_sender, call.spender, call.amount))?;
678
679        Ok(true)
680    }
681
682    // EIP-2612 Permit
683
684    /// Returns the current nonce for an address (EIP-2612)
685    pub fn nonces(&self, call: ITIP20::noncesCall) -> Result<U256> {
686        self.permit_nonces[call.owner].read()
687    }
688
689    /// Returns the EIP-712 domain separator, computed dynamically from the token name and chain ID.
690    pub fn domain_separator(&self) -> Result<B256> {
691        let name = self.name()?;
692        let name_hash = self.storage.keccak256(name.as_bytes())?;
693        let chain_id = U256::from(self.storage.chain_id());
694
695        let encoded = (
696            *EIP712_DOMAIN_TYPEHASH,
697            name_hash,
698            *VERSION_HASH,
699            chain_id,
700            self.address,
701        )
702            .abi_encode();
703
704        self.storage.keccak256(&encoded)
705    }
706
707    /// Sets allowance via a signed [EIP-2612] permit. Validates the ECDSA signature, checks the
708    /// deadline, and increments the nonce. Allowed even when the token is paused.
709    ///
710    /// [EIP-2612]: https://eips.ethereum.org/EIPS/eip-2612
711    ///
712    /// # Errors
713    /// - `PermitExpired` — current timestamp exceeds permit deadline
714    /// - `InvalidSignature` — ECDSA recovery failed or recovered signer ≠ owner
715    pub fn permit(&mut self, call: ITIP20::permitCall) -> Result<()> {
716        // 1. Check deadline
717        if self.storage.timestamp() > call.deadline {
718            return Err(TIP20Error::permit_expired().into());
719        }
720
721        // 2. Construct EIP-712 struct hash
722        let nonce = self.permit_nonces[call.owner].read()?;
723        let struct_hash = self.storage.keccak256(
724            &(
725                *PERMIT_TYPEHASH,
726                call.owner,
727                call.spender,
728                call.value,
729                nonce,
730                call.deadline,
731            )
732                .abi_encode(),
733        )?;
734
735        // 3. Construct EIP-712 digest
736        let domain_separator = self.domain_separator()?;
737        let digest = self.storage.keccak256(
738            &[
739                &[0x19, 0x01],
740                domain_separator.as_slice(),
741                struct_hash.as_slice(),
742            ]
743            .concat(),
744        )?;
745
746        // 4. Validate ECDSA signature
747        // Only v=27/28 is accepted; v=0/1 is intentionally NOT normalized (see TIP-1004 spec).
748        let recovered = self
749            .storage
750            .recover_signer(digest, call.v, call.r, call.s)?
751            .ok_or(TIP20Error::invalid_signature())?;
752        if recovered != call.owner {
753            return Err(TIP20Error::invalid_signature().into());
754        }
755
756        // 5. Increment nonce
757        self.permit_nonces[call.owner].write(
758            nonce
759                .checked_add(U256::from(1))
760                .ok_or(TempoPrecompileError::under_overflow())?,
761        )?;
762
763        // 6. Set allowance
764        self.set_allowance(call.owner, call.spender, call.value)?;
765
766        // 7. Emit Approval event
767        self.emit_event(TIP20Event::approval(call.owner, call.spender, call.value))
768    }
769
770    /// Transfers `amount` tokens from the caller to `to`. Enforces compliance via the
771    /// [`TIP403Registry`] and deducts from the caller's [`AccountKeychain`] spending limit.
772    ///
773    /// # Errors
774    /// - `Paused` — token transfers are currently paused
775    /// - `InvalidRecipient` — recipient address is zero
776    /// - `PolicyForbids` — TIP-403 policy rejects sender or recipient
777    /// - `SpendingLimitExceeded` — access key spending limit exceeded
778    /// - `InsufficientBalance` — sender balance lower than transfer amount
779    pub fn transfer(&mut self, msg_sender: Address, call: ITIP20::transferCall) -> Result<bool> {
780        trace!(%msg_sender, ?call, "transferring TIP20");
781        let Some(to) =
782            self.validate_transfer(None, msg_sender, call.to, call.amount, B256::ZERO)?
783        else {
784            return Ok(true);
785        };
786
787        self._transfer(msg_sender, &to, call.amount)?;
788        if let Some(hop) = to.build_virtual_transfer_event(call.amount) {
789            self.emit_event(hop)?;
790        }
791
792        Ok(true)
793    }
794
795    /// Transfers `amount` on behalf of `from` using the caller's allowance.
796    /// Enforces compliance via the [`TIP403Registry`].
797    ///
798    /// # Errors
799    /// - `Paused` — token transfers are currently paused
800    /// - `InvalidRecipient` — recipient address is zero
801    /// - `PolicyForbids` — TIP-403 policy rejects sender or recipient
802    /// - `InsufficientAllowance` — caller allowance lower than transfer amount
803    /// - `InsufficientBalance` — `from` balance lower than transfer amount
804    pub fn transfer_from(
805        &mut self,
806        msg_sender: Address,
807        call: ITIP20::transferFromCall,
808    ) -> Result<bool> {
809        let Some(to) = self.validate_transfer(
810            Some(msg_sender),
811            call.from,
812            call.to,
813            call.amount,
814            B256::ZERO,
815        )?
816        else {
817            return Ok(true);
818        };
819
820        self._transfer(call.from, &to, call.amount)?;
821        if let Some(hop) = to.build_virtual_transfer_event(call.amount) {
822            self.emit_event(hop)?;
823        }
824
825        Ok(true)
826    }
827
828    /// Like [`Self::transfer_from`], but attaches a 32-byte memo.
829    pub fn transfer_from_with_memo(
830        &mut self,
831        msg_sender: Address,
832        call: ITIP20::transferFromWithMemoCall,
833    ) -> Result<bool> {
834        let Some(to) =
835            self.validate_transfer(Some(msg_sender), call.from, call.to, call.amount, call.memo)?
836        else {
837            return Ok(true);
838        };
839
840        self._transfer(call.from, &to, call.amount)?;
841        self.emit_event(TIP20Event::transfer_with_memo(
842            call.from,
843            call.to,
844            call.amount,
845            call.memo,
846        ))?;
847        if let Some(hop) = to.build_virtual_transfer_event(call.amount) {
848            self.emit_event(hop)?;
849        }
850        Ok(true)
851    }
852
853    /// Transfers `amount` from `from` to `to` without checking allowances. For use by precompiles
854    /// on the [`crate::address_registry::IMPLICIT_APPROVAL_LIST`] only — not exposed via ABI.
855    /// Enforces compliance via the [`TIP403Registry`] and [`AccountKeychain`].
856    ///
857    /// `caller` is the address of the precompile invoking this function. Starting at
858    /// `TempoHardfork::T5` (TIP-1035), the call returns `Unauthorized` unless `caller` is on the
859    /// Implicit Approval List. Pre-T5, `caller` is unchecked (preserves pre-TIP-1035 behavior of
860    /// the existing internal-only caller, `TipFeeManager`).
861    ///
862    /// Callers are also expected to pull only from the current `msg.sender`; this is a security
863    /// guideline of TIP-1035 enforced at the call site, not by this function.
864    ///
865    /// # Errors
866    /// - `Unauthorized` — `caller` is not on the Implicit Approval List (T5+)
867    /// - `Paused` — token transfers are currently paused
868    /// - `InvalidRecipient` — recipient address is zero
869    /// - `PolicyForbids` — TIP-403 policy rejects sender or recipient
870    /// - `SpendingLimitExceeded` — access key spending limit exceeded
871    /// - `InsufficientBalance` — `from` balance lower than transfer amount
872    pub fn system_transfer_from(
873        &mut self,
874        caller: Address,
875        from: Address,
876        amount: U256,
877    ) -> Result<bool> {
878        // [TIP-1035] List gating: at T5+, only listed precompiles may invoke this entrypoint.
879        let spec = self.storage.spec();
880        if spec.is_t5() && !crate::address_registry::is_implicitly_approved(caller, spec) {
881            return Err(TIP20Error::unauthorized().into());
882        }
883
884        let Some(to) = self.validate_transfer(None, from, caller, amount, B256::ZERO)? else {
885            return Ok(true);
886        };
887
888        self._transfer(from, &to, amount)?;
889        if let Some(hop) = to.build_virtual_transfer_event(amount) {
890            self.emit_event(hop)?;
891        }
892
893        Ok(true)
894    }
895
896    /// Debits `spender`'s allowance on `owner`. No-op when unlimited.
897    fn consume_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> {
898        let allowed = self.get_allowance(owner, spender)?;
899        if amount > allowed {
900            return Err(TIP20Error::insufficient_allowance().into());
901        }
902
903        if allowed != U256::MAX {
904            let new_allowance = allowed
905                .checked_sub(amount)
906                .ok_or(TIP20Error::insufficient_allowance())?;
907            self.set_allowance(owner, spender, new_allowance)?;
908        }
909        Ok(())
910    }
911
912    /// Like [`Self::transfer`], but attaches a 32-byte memo.
913    pub fn transfer_with_memo(
914        &mut self,
915        msg_sender: Address,
916        call: ITIP20::transferWithMemoCall,
917    ) -> Result<()> {
918        let Some(to) = self.validate_transfer(None, msg_sender, call.to, call.amount, call.memo)?
919        else {
920            return Ok(());
921        };
922
923        self._transfer(msg_sender, &to, call.amount)?;
924        self.emit_event(TIP20Event::transfer_with_memo(
925            msg_sender,
926            call.to,
927            call.amount,
928            call.memo,
929        ))?;
930        if let Some(hop) = to.build_virtual_transfer_event(call.amount) {
931            self.emit_event(hop)?;
932        }
933        Ok(())
934    }
935}
936
937// Utility functions
938impl TIP20Token {
939    /// Creates a `TIP20Token` handle from a raw address.
940    ///
941    /// # Errors
942    /// - `InvalidToken` — address does not carry the `0x20C0` TIP-20 prefix
943    pub fn from_address(address: Address) -> Result<Self> {
944        if !address.is_tip20() {
945            return Err(TIP20Error::invalid_token().into());
946        }
947        Ok(Self::__new(address))
948    }
949
950    /// Creates a TIP20Token without validating the prefix.
951    ///
952    /// # Safety
953    /// Caller must ensure `is_tip20_prefix(address)` returns true.
954    #[inline]
955    pub fn from_address_unchecked(address: Address) -> Self {
956        debug_assert!(address.is_tip20(), "address must have TIP20 prefix");
957        Self::__new(address)
958    }
959
960    /// Initializes the TIP-20 token precompile with metadata, quote token, supply cap, and
961    /// default admin role. Called once by [`TIP20Factory`] during token creation.
962    pub fn initialize(
963        &mut self,
964        msg_sender: Address,
965        name: &str,
966        symbol: &str,
967        currency: &str,
968        quote_token: Address,
969        admin: Address,
970    ) -> Result<()> {
971        trace!(%name, address=%self.address, "Initializing token");
972
973        // must ensure the account is not empty, by setting some code
974        self.__initialize()?;
975
976        self.name.write(name.to_string())?;
977        self.symbol.write(symbol.to_string())?;
978        self.currency.write(currency.to_string())?;
979
980        self.quote_token.write(quote_token)?;
981        // Initialize nextQuoteToken to the same value as quoteToken
982        self.next_quote_token.write(quote_token)?;
983
984        // Set default values
985        self.supply_cap.write(U128_MAX)?;
986        self.transfer_policy_id.write(1)?;
987
988        // Initialize roles system and grant admin role
989        self.initialize_roles()?;
990        self.grant_default_admin(msg_sender, admin)
991    }
992
993    fn get_balance(&self, account: Address) -> Result<U256> {
994        self.balances[account].read()
995    }
996
997    fn set_balance(&mut self, account: Address, amount: U256) -> Result<()> {
998        self.balances[account].write(amount)
999    }
1000
1001    fn increment_balance(&mut self, account: Address, amount: U256) -> Result<()> {
1002        self.balances[account].sinc(amount).map_err(|err| {
1003            if err == TempoPrecompileError::under_overflow() {
1004                TIP20Error::supply_cap_exceeded().into()
1005            } else {
1006                err
1007            }
1008        })
1009    }
1010
1011    fn decrement_balance(&mut self, account: Address, amount: U256) -> Result<()> {
1012        self.balances[account]
1013            .sdec(amount)
1014            .map_err(|err| match err {
1015                TempoPrecompileError::StorageDeltaUnderflow(current) => {
1016                    TIP20Error::insufficient_balance(current, amount, self.address).into()
1017                }
1018                err => err,
1019            })
1020    }
1021
1022    fn get_allowance(&self, owner: Address, spender: Address) -> Result<U256> {
1023        self.allowances[owner][spender].read()
1024    }
1025
1026    fn set_allowance(&mut self, owner: Address, spender: Address, amount: U256) -> Result<()> {
1027        self.allowances[owner][spender].write(amount)
1028    }
1029
1030    fn set_total_supply(&mut self, amount: U256) -> Result<()> {
1031        self.total_supply.write(amount)
1032    }
1033
1034    pub fn check_not_paused(&self) -> Result<()> {
1035        if self.paused()? {
1036            return Err(TIP20Error::contract_paused().into());
1037        }
1038        Ok(())
1039    }
1040
1041    /// Resolves `to`, checks pause state and recipient validity, ensures TIP-403 transfer
1042    /// authorization, and runs the caller-specific spend check. Additionally (+T6) applies
1043    /// TIP-1028 address-level receive policies.
1044    ///
1045    /// Updates the sender's [`AccountKeychain`] spending limit for direct transfers, and
1046    /// consumes allowance for `transfer_from` style calls.
1047    ///
1048    /// Returns `Some(to)` when the caller should perform the normal transfer.
1049    /// Returns `None` when funds were blocked, and the caller should return immediately.
1050    fn validate_transfer(
1051        &mut self,
1052        spender: Option<Address>,
1053        from: Address,
1054        to: Address,
1055        amount: U256,
1056        memo: B256,
1057    ) -> Result<Option<Recipient>> {
1058        let to = Recipient::resolve(to)?;
1059        self.check_not_paused()?;
1060        to.validate()?;
1061        self.ensure_transfer_authorized(from, to.target)?;
1062
1063        if let Some(spender) = spender {
1064            self.consume_allowance(from, spender, amount)?;
1065        } else {
1066            self.check_and_update_spending_limit(from, amount)?;
1067        }
1068
1069        if self.validate_inbound_or_block(from, &to, amount, None, memo)? {
1070            return Ok(None);
1071        }
1072
1073        Ok(Some(to))
1074    }
1075
1076    /// Resolves `to`, checks the issuer role, and ensures TIP-403 mint-recipient authorization.
1077    /// Additionally (+T3) checks pause state and validates the effective recipient; also
1078    /// (+T6) applies TIP-1028 address-level receive policies.
1079    ///
1080    /// Returns `Some(to)` when the caller should proceed with the regular mint.
1081    /// Returns `None` when funds were minted and blocked, and the caller should return immediately.
1082    fn validate_mint(
1083        &mut self,
1084        msg_sender: Address,
1085        to: Address,
1086        amount: U256,
1087        memo: B256,
1088    ) -> Result<Option<(U256, Recipient)>> {
1089        let to = Recipient::resolve(to)?;
1090        self.check_role(msg_sender, *ISSUER_ROLE)?;
1091        let total_supply = self.total_supply()?;
1092
1093        if self.storage.spec().is_t3() {
1094            self.check_not_paused()?;
1095            to.validate()?;
1096        }
1097
1098        // Check if the resolved target address is authorized to receive minted tokens
1099        if !TIP403Registry::new().is_authorized_as(
1100            self.transfer_policy_id()?,
1101            to.target,
1102            AuthRole::mint_recipient(),
1103        )? {
1104            return Err(TIP20Error::policy_forbids().into());
1105        }
1106
1107        if self.validate_inbound_or_block(msg_sender, &to, amount, Some(total_supply), memo)? {
1108            return Ok(None);
1109        }
1110
1111        Ok(Some((total_supply, to)))
1112    }
1113
1114    /// Check whether a transfer is authorized by the token's [`TIP403Registry`] policy.
1115    /// [TIP-1015]: For T2+, uses directional sender/recipient checks.
1116    ///
1117    /// [TIP-1015]: <https://docs.tempo.xyz/protocol/tips/tip-1015>
1118    pub fn is_transfer_authorized(&self, from: Address, to: Address) -> Result<bool> {
1119        let policy_id = self.transfer_policy_id()?;
1120        let registry = TIP403Registry::new();
1121
1122        // (spec: +T2) short-circuit and skip recipient check if sender fails
1123        let sender_auth = registry.is_authorized_as(policy_id, from, AuthRole::sender())?;
1124        if self.storage.spec().is_t2() && !sender_auth {
1125            return Ok(false);
1126        }
1127        let recipient_auth = registry.is_authorized_as(policy_id, to, AuthRole::recipient())?;
1128        Ok(sender_auth && recipient_auth)
1129    }
1130
1131    /// Ensures the transfer is authorized by the token's [`TIP403Registry`] policy.
1132    ///
1133    /// # Errors
1134    /// - `PolicyForbids` — sender or recipient is not authorized by the active transfer policy
1135    pub fn ensure_transfer_authorized(&self, from: Address, to: Address) -> Result<()> {
1136        if !self.is_transfer_authorized(from, to)? {
1137            return Err(TIP20Error::policy_forbids().into());
1138        }
1139
1140        Ok(())
1141    }
1142
1143    /// Check whether a user is authorized by the token's [`TIP403Registry`] policy for a given role.
1144    ///
1145    /// # Errors
1146    /// - `PolicyForbids` — user is not authorized for the requested role by the active transfer policy
1147    pub fn ensure_authorized_as(&self, user: Address, role: AuthRole) -> Result<()> {
1148        let policy_id = self.transfer_policy_id()?;
1149        if !TIP403Registry::new().is_authorized_as(policy_id, user, role)? {
1150            return Err(TIP20Error::policy_forbids().into());
1151        }
1152        Ok(())
1153    }
1154
1155    /// Checks and deducts `amount` from the caller's [`AccountKeychain`] spending limit.
1156    ///
1157    /// # Errors
1158    /// - `SpendingLimitExceeded` — access key spending limit exceeded
1159    pub fn check_and_update_spending_limit(&mut self, from: Address, amount: U256) -> Result<()> {
1160        AccountKeychain::new().authorize_transfer(from, self.address, amount)
1161    }
1162
1163    /// Core transfer: debits `from`, credits `to.target`, emits `Transfer(from, event_addr, amount)`.
1164    ///
1165    /// For virtual recipients the event address is the virtual alias; the balance update always
1166    /// targets `to.target` (the resolved master).
1167    pub(crate) fn _transfer(&mut self, from: Address, to: &Recipient, amount: U256) -> Result<()> {
1168        let from_balance = if !self.storage.spec().is_t8() {
1169            let from_balance = self.get_balance(from)?;
1170            if amount > from_balance {
1171                return Err(
1172                    TIP20Error::insufficient_balance(from_balance, amount, self.address).into(),
1173                );
1174            }
1175            Some(from_balance)
1176        } else {
1177            None
1178        };
1179
1180        self.handle_rewards_on_transfer(from, to.target, amount)?;
1181
1182        // Adjust balances
1183        //
1184        // We can't just use `decrement_balance` in both pre- and post-T8 codepaths, because `decrement_balance`
1185        // charges gas for balance SLOAD that we already do above for pre-T8.
1186        if let Some(from_balance) = from_balance {
1187            // pre-T8 path
1188            let new_from_balance = from_balance
1189                .checked_sub(amount)
1190                .ok_or(TempoPrecompileError::under_overflow())?;
1191
1192            self.set_balance(from, new_from_balance)?;
1193        } else {
1194            // post-T8 path
1195            self.decrement_balance(from, amount)?;
1196        }
1197
1198        if to.target != Address::ZERO {
1199            self.increment_balance(to.target, amount)?;
1200        }
1201
1202        self.emit_event(to.build_transfer_event(from, amount))
1203    }
1204
1205    /// Validates the receive policy of `to.target`. If blocked, moves the funds into the guard
1206    /// account and stores a claim receipt; returns `true`. Returns `false` when the inbound is
1207    /// authorized and the caller should proceed with the normal transfer or mint.
1208    pub(crate) fn validate_inbound_or_block(
1209        &mut self,
1210        originator: Address,
1211        to: &Recipient,
1212        amount: U256,
1213        mint_total_supply: Option<U256>,
1214        memo: B256,
1215    ) -> Result<bool> {
1216        if !self.storage.spec().is_t6() {
1217            return Ok(false);
1218        }
1219        if to.target == RECEIVE_POLICY_GUARD_ADDRESS {
1220            return Err(ReceivePolicyGuardError::address_reserved().into());
1221        }
1222
1223        let token = self.address;
1224        let Some((reason, recovery)) =
1225            TIP403Registry::new().check_receive_policy(token, originator, to.target)?
1226        else {
1227            return Ok(false);
1228        };
1229
1230        let guard = Recipient::direct(RECEIVE_POLICY_GUARD_ADDRESS);
1231        let kind = if let Some(total_supply) = mint_total_supply {
1232            self._mint(&guard, total_supply, amount)?;
1233            self.emit_event(TIP20Event::mint(guard.target, amount))?;
1234            InboundKind::MINT
1235        } else {
1236            self._transfer(originator, &guard, amount)?;
1237            InboundKind::TRANSFER
1238        };
1239        ReceivePolicyGuard::new()
1240            .store_blocked(token, originator, to, recovery, amount, reason, kind, memo)?;
1241
1242        Ok(true)
1243    }
1244
1245    /// Releases guarded funds to `to`. Resumes skip policy checks. Reroutes
1246    /// revalidate the transfer and receive policies and meter the spending limit.
1247    pub(crate) fn release_blocked_funds(
1248        &mut self,
1249        originator: Address,
1250        receiver: Address,
1251        to: Address,
1252        amount: U256,
1253        recovery_mode: RecoveryMode,
1254        recovery_auth: Address,
1255    ) -> Result<()> {
1256        debug_assert!(
1257            to != RECEIVE_POLICY_GUARD_ADDRESS,
1258            "checked in ReceivePolicyGuard::claim"
1259        );
1260
1261        self.check_not_paused()?;
1262        let destination = Recipient::resolve(to)?;
1263        destination.validate()?;
1264        if recovery_mode.is_reroute(to, receiver) {
1265            let policy_subject = recovery_mode.policy_subject(originator, receiver);
1266            self.ensure_transfer_authorized(policy_subject, destination.target)?;
1267            if TIP403Registry::new()
1268                .validate_receive_policy(self.address, policy_subject, destination.target)?
1269                .is_some()
1270            {
1271                return Err(TIP20Error::policy_forbids().into());
1272            }
1273            if let Some(addr) = recovery_mode.spending_account(recovery_auth) {
1274                self.check_and_update_spending_limit(addr, amount)?;
1275            }
1276        } else {
1277            self.ensure_authorized_as(destination.target, AuthRole::recipient())?;
1278        }
1279
1280        self._transfer(RECEIVE_POLICY_GUARD_ADDRESS, &destination, amount)?;
1281        if let Some(hop) = destination.build_virtual_transfer_event(amount) {
1282            self.emit_event(hop)?;
1283        }
1284        Ok(())
1285    }
1286
1287    /// Transfers fee tokens from `from` to the fee manager before transaction execution.
1288    /// Respects the token's pause state and deducts from the [`AccountKeychain`] spending limit.
1289    ///
1290    /// # Errors
1291    /// - `Paused` — token transfers are currently paused
1292    /// - `InsufficientBalance` — sender balance lower than fee amount
1293    /// - `SpendingLimitExceeded` — access key spending limit exceeded
1294    pub fn transfer_fee_pre_tx(&mut self, from: Address, amount: U256) -> Result<()> {
1295        // This function respects the token's pause state and will revert if the token is paused.
1296        // transfer_fee_post_tx is intentionally allowed to execute even when the token is paused.
1297        // This ensures that a transaction which pauses the token can still complete successfully and receive its fee refund.
1298        // Apart from this specific refund transfer, no other token transfers can occur after a pause event.
1299        self.check_not_paused()?;
1300        self.check_and_update_spending_limit(from, amount)?;
1301
1302        // Update rewards for the sender and get their reward recipient
1303        let from_reward_recipient = self.update_rewards(from)?;
1304
1305        // If user is opted into rewards, decrease opted-in supply
1306        if from_reward_recipient != Address::ZERO {
1307            let opted_in_supply = U256::from(self.get_opted_in_supply()?)
1308                .checked_sub(amount)
1309                .ok_or(TempoPrecompileError::under_overflow())?;
1310            self.set_opted_in_supply(
1311                opted_in_supply
1312                    .try_into()
1313                    .map_err(|_| TempoPrecompileError::under_overflow())?,
1314            )?;
1315        }
1316
1317        self.decrement_balance(from, amount)?;
1318        self.increment_balance(TIP_FEE_MANAGER_ADDRESS, amount)?;
1319
1320        Ok(())
1321    }
1322
1323    /// Refunds unused fee tokens from the fee manager back to `to` and emits a transfer event for
1324    /// the actual gas spent. Intentionally allowed when paused so that a pause transaction can
1325    /// still receive its fee refund. On T1C+, also restores the [`AccountKeychain`] spending limit
1326    /// by the refund amount.
1327    pub fn transfer_fee_post_tx(
1328        &mut self,
1329        to: Address,
1330        refund: U256,
1331        actual_spending: U256,
1332    ) -> Result<()> {
1333        self.emit_event(TIP20Event::transfer(
1334            to,
1335            TIP_FEE_MANAGER_ADDRESS,
1336            actual_spending,
1337        ))?;
1338
1339        // Exit early if there is no refund
1340        if refund.is_zero() {
1341            return Ok(());
1342        }
1343
1344        if self.storage.spec().is_t1c() {
1345            AccountKeychain::new().refund_spending_limit(to, self.address, refund)?;
1346        }
1347
1348        // Update rewards for the recipient and get their reward recipient
1349        let to_reward_recipient = self.update_rewards(to)?;
1350
1351        // If user is opted into rewards, increase opted-in supply by refund amount
1352        if to_reward_recipient != Address::ZERO {
1353            let opted_in_supply = U256::from(self.get_opted_in_supply()?)
1354                .checked_add(refund)
1355                .ok_or(TempoPrecompileError::under_overflow())?;
1356            self.set_opted_in_supply(
1357                opted_in_supply
1358                    .try_into()
1359                    .map_err(|_| TempoPrecompileError::under_overflow())?,
1360            )?;
1361        }
1362
1363        self.decrement_balance(TIP_FEE_MANAGER_ADDRESS, refund)?;
1364        self.increment_balance(to, refund)?;
1365
1366        Ok(())
1367    }
1368}
1369
1370/// Resolved transfer recipient for [TIP-1022] virtual address support.
1371///
1372/// `target` is always the effective (resolved) address where the balance is credited. For virtual
1373/// recipients, `virtual_addr` carries the original virtual address for event emission.
1374///
1375/// [TIP-1022]: <https://docs.tempo.xyz/protocol/tip1022>
1376#[derive(Debug, PartialEq)]
1377pub(crate) struct Recipient {
1378    /// The effective (resolved) address where the balance is credited.
1379    pub(crate) target: Address,
1380    /// The virtual address, if registered.
1381    pub(crate) virtual_addr: Option<Address>,
1382}
1383
1384impl Recipient {
1385    /// Creates a [`Recipient`] with no virtual indirection.
1386    #[inline]
1387    pub(crate) fn direct(addr: Address) -> Self {
1388        Self {
1389            target: addr,
1390            virtual_addr: None,
1391        }
1392    }
1393
1394    /// Resolves a recipient via the [`AddressRegistry`].
1395    ///
1396    /// If `addr` is a virtual address its registered master is looked up and stored in `target`,
1397    /// with the original virtual address preserved in `virtual_addr`.
1398    pub(crate) fn resolve(addr: Address) -> Result<Self> {
1399        let effective = AddressRegistry::new().resolve_recipient(addr)?;
1400        Ok(if effective == addr {
1401            Self::direct(addr)
1402        } else {
1403            Self {
1404                target: effective,
1405                virtual_addr: Some(addr),
1406            }
1407        })
1408    }
1409
1410    /// Validates that the recipient is not:
1411    /// - the zero address (preventing accidental burns)
1412    /// - an address with the TIP-20 prefix (preventing transfers to token contracts)
1413    pub(crate) fn validate(&self) -> Result<()> {
1414        if self.target.is_zero() || self.target.is_tip20() {
1415            return Err(TIP20Error::invalid_recipient().into());
1416        }
1417        Ok(())
1418    }
1419
1420    /// Builds the primary `Transfer(from, to, amount)` event.
1421    ///
1422    /// For virtual recipients `to` is the virtual address (first hop); for regular
1423    /// recipients this is the only `Transfer` event needed.
1424    pub(crate) fn build_transfer_event(&self, from: Address, amount: U256) -> TIP20Event {
1425        TIP20Event::transfer(from, self.virtual_addr.unwrap_or(self.target), amount)
1426    }
1427
1428    /// Builds the forwarding `Transfer(virtual, master, amount)` event for virtual recipients.
1429    /// Returns `None` for non-virtual recipients.
1430    pub(crate) fn build_virtual_transfer_event(&self, amount: U256) -> Option<TIP20Event> {
1431        self.virtual_addr
1432            .map(|virtual_addr| TIP20Event::transfer(virtual_addr, self.target, amount))
1433    }
1434}
1435
1436#[cfg(test)]
1437mod recipient_tests {
1438    use super::*;
1439    use crate::{
1440        address_registry::{AddressRegistry, MasterId, UserTag},
1441        error::TempoPrecompileError,
1442        storage::{StorageCtx, hashmap::HashMapStorageProvider},
1443        test_util::{VIRTUAL_MASTER, register_virtual_master},
1444    };
1445    use alloy::primitives::{Address, U256};
1446    use tempo_chainspec::hardfork::TempoHardfork;
1447
1448    #[test]
1449    fn test_resolve() -> eyre::Result<()> {
1450        // direct (non-virtual)
1451        let addr = Address::repeat_byte(0x11);
1452        assert_eq!(
1453            Recipient::direct(addr),
1454            Recipient {
1455                target: addr,
1456                virtual_addr: None
1457            }
1458        );
1459
1460        // T3: non-virtual → direct
1461        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T3);
1462        StorageCtx::enter(&mut storage, || {
1463            let r = Recipient::resolve(addr)?;
1464            assert_eq!(
1465                r,
1466                Recipient {
1467                    target: addr,
1468                    virtual_addr: None
1469                }
1470            );
1471
1472            // T3: registered virtual → master
1473            let mut registry = AddressRegistry::new();
1474            let (_, virtual_addr) = register_virtual_master(&mut registry)?;
1475            let r = Recipient::resolve(virtual_addr)?;
1476            assert_eq!(
1477                r,
1478                Recipient {
1479                    target: VIRTUAL_MASTER,
1480                    virtual_addr: Some(virtual_addr)
1481                }
1482            );
1483
1484            // T3: unregistered virtual → error
1485            let unregistered = Address::new_virtual(MasterId::ZERO, UserTag::ZERO);
1486            assert!(Recipient::resolve(unregistered).is_err());
1487
1488            Ok::<_, TempoPrecompileError>(())
1489        })?;
1490
1491        // Pre-T3: virtual address passed through as literal
1492        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T2);
1493        StorageCtx::enter(&mut storage, || {
1494            let virtual_addr = Address::new_virtual(MasterId::ZERO, UserTag::ZERO);
1495            let r = Recipient::resolve(virtual_addr)?;
1496            assert_eq!(
1497                r,
1498                Recipient {
1499                    target: virtual_addr,
1500                    virtual_addr: None
1501                }
1502            );
1503            Ok::<_, TempoPrecompileError>(())
1504        })?;
1505        Ok(())
1506    }
1507
1508    #[test]
1509    fn test_validate() {
1510        assert!(Recipient::direct(Address::ZERO).validate().is_err());
1511        assert!(
1512            Recipient::direct(crate::PATH_USD_ADDRESS)
1513                .validate()
1514                .is_err()
1515        );
1516        assert!(
1517            Recipient::direct(Address::repeat_byte(0x11))
1518                .validate()
1519                .is_ok()
1520        );
1521    }
1522
1523    #[test]
1524    fn test_build_events() {
1525        let from = Address::repeat_byte(0x01);
1526        let target = Address::repeat_byte(0x02);
1527        let vaddr = Address::repeat_byte(0x03);
1528        let amount = U256::from(42);
1529
1530        let direct = Recipient::direct(target);
1531        let virt = Recipient {
1532            target,
1533            virtual_addr: Some(vaddr),
1534        };
1535
1536        // transfer event uses virtual_addr when present, target otherwise
1537        assert!(matches!(direct.build_transfer_event(from, amount),
1538            TIP20Event::Transfer(ITIP20::Transfer { to, .. }) if to == target));
1539        assert!(matches!(virt.build_transfer_event(from, amount),
1540            TIP20Event::Transfer(ITIP20::Transfer { to, .. }) if to == vaddr));
1541
1542        // virtual transfer event: None for direct, Some(virtual→master) for virtual
1543        assert!(direct.build_virtual_transfer_event(amount).is_none());
1544        let hop = virt.build_virtual_transfer_event(amount).unwrap();
1545        assert!(matches!(hop,
1546            TIP20Event::Transfer(ITIP20::Transfer { from, to, .. })
1547            if from == vaddr && to == target));
1548    }
1549}
1550
1551#[cfg(test)]
1552pub(crate) mod tests {
1553    use super::*;
1554    use crate::{
1555        PATH_USD_ADDRESS,
1556        account_keychain::{
1557            AccountKeychain, KeyRestrictions, SignatureType, TokenLimit, getRemainingLimitCall,
1558        },
1559        address_registry::{AddressRegistry, MasterId, UserTag},
1560        error::TempoPrecompileError,
1561        receive_policy_guard::ReceivePolicyGuard,
1562        storage::{StorageCtx, hashmap::HashMapStorageProvider},
1563        test_util::{TIP20Setup, VIRTUAL_MASTER, register_virtual_master, setup_storage},
1564        tip403_registry::REJECT_ALL_POLICY_ID,
1565    };
1566    use alloy::primitives::{Address, FixedBytes, IntoLogData, U256, hex};
1567    use rand_08::{Rng, distributions::Alphanumeric, thread_rng};
1568    use tempo_chainspec::hardfork::TempoHardfork;
1569    use tempo_contracts::precompiles::{
1570        IReceivePolicyGuard, ReceivePolicyGuardEvent, createTokenCall,
1571    };
1572
1573    #[test]
1574    fn test_mint_increases_balance_and_supply() -> eyre::Result<()> {
1575        let (mut storage, admin) = setup_storage();
1576        let addr = Address::random();
1577        let amount = U256::random() % U256::from(u128::MAX);
1578
1579        StorageCtx::enter(&mut storage, || {
1580            let mut token = TIP20Setup::create("Test", "TST", admin)
1581                .with_issuer(admin)
1582                .clear_events()
1583                .apply()?;
1584
1585            token.mint(admin, ITIP20::mintCall { to: addr, amount })?;
1586
1587            assert_eq!(token.get_balance(addr)?, amount);
1588            assert_eq!(token.total_supply()?, amount);
1589
1590            token.assert_emitted_events(vec![
1591                TIP20Event::transfer(Address::ZERO, addr, amount),
1592                TIP20Event::mint(addr, amount),
1593            ]);
1594
1595            Ok(())
1596        })
1597    }
1598
1599    #[test]
1600    fn test_transfer_moves_balance() -> eyre::Result<()> {
1601        let (mut storage, admin) = setup_storage();
1602        let from = Address::random();
1603        let to = Address::random();
1604        let amount = U256::random() % U256::from(u128::MAX);
1605
1606        StorageCtx::enter(&mut storage, || {
1607            let mut token = TIP20Setup::create("Test", "TST", admin)
1608                .with_issuer(admin)
1609                .with_mint(from, amount)
1610                .clear_events()
1611                .apply()?;
1612
1613            token.transfer(from, ITIP20::transferCall { to, amount })?;
1614
1615            assert_eq!(token.get_balance(from)?, U256::ZERO);
1616            assert_eq!(token.get_balance(to)?, amount);
1617            assert_eq!(token.total_supply()?, amount); // Supply unchanged
1618
1619            token.assert_emitted_events(vec![TIP20Event::transfer(from, to, amount)]);
1620
1621            Ok(())
1622        })
1623    }
1624
1625    mod tip1028_tests {
1626        use super::*;
1627        use crate::{
1628            receive_policy_guard::BLOCKED_RECEIPT_VERSION, tip403_registry::ALLOW_ALL_POLICY_ID,
1629        };
1630
1631        const BLOCKED_AT: u64 = 1_728_100;
1632
1633        fn set_receive_policy(
1634            receiver: Address,
1635            sender_policy_id: u64,
1636            token_filter_id: u64,
1637            recovery_address: Address,
1638        ) -> Result<()> {
1639            TIP403Registry::new().set_receive_policy(
1640                receiver,
1641                ITIP403Registry::setReceivePolicyCall {
1642                    senderPolicyId: sender_policy_id,
1643                    tokenFilterId: token_filter_id,
1644                    recoveryAuthority: recovery_address,
1645                },
1646            )
1647        }
1648
1649        #[test]
1650        fn test_transfer_blocked_by_receive_policy_guards_funds() -> eyre::Result<()> {
1651            let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
1652            storage.set_timestamp(U256::from(BLOCKED_AT));
1653            let admin = Address::random();
1654            let sender = Address::random();
1655            let receiver = Address::random();
1656            let amount = U256::from(100u64);
1657
1658            StorageCtx::enter(&mut storage, || {
1659                let mut token = TIP20Setup::create("Test", "TST", admin)
1660                    .with_issuer(admin)
1661                    .with_mint(sender, amount)
1662                    .clear_events()
1663                    .apply()?;
1664                set_receive_policy(
1665                    receiver,
1666                    REJECT_ALL_POLICY_ID,
1667                    ALLOW_ALL_POLICY_ID,
1668                    Address::ZERO,
1669                )?;
1670
1671                token.transfer(
1672                    sender,
1673                    ITIP20::transferCall {
1674                        to: receiver,
1675                        amount,
1676                    },
1677                )?;
1678
1679                assert_eq!(token.get_balance(sender)?, U256::ZERO);
1680                assert_eq!(token.get_balance(receiver)?, U256::ZERO);
1681                assert_eq!(token.get_balance(RECEIVE_POLICY_GUARD_ADDRESS)?, amount);
1682                token.assert_emitted_events(vec![TIP20Event::Transfer(ITIP20::Transfer {
1683                    from: sender,
1684                    to: RECEIVE_POLICY_GUARD_ADDRESS,
1685                    amount,
1686                })]);
1687
1688                let receipt = IReceivePolicyGuard::ClaimReceiptV1::new(
1689                    token.address,
1690                    Address::ZERO,
1691                    sender,
1692                    receiver,
1693                    BLOCKED_AT,
1694                    1,
1695                    ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8,
1696                    InboundKind::TRANSFER,
1697                    B256::ZERO,
1698                );
1699                let guard = ReceivePolicyGuard::new();
1700                assert_eq!(guard.balance_of(receipt.abi_encode().into())?, amount);
1701                guard.assert_emitted_events(vec![ReceivePolicyGuardEvent::TransferBlocked(
1702                    IReceivePolicyGuard::TransferBlocked {
1703                        token: token.address,
1704                        receiver,
1705                        blockedNonce: 1,
1706                        receiptVersion: BLOCKED_RECEIPT_VERSION,
1707                        amount,
1708                        receipt: receipt.abi_encode().into(),
1709                    },
1710                )]);
1711
1712                Ok(())
1713            })
1714        }
1715
1716        #[test]
1717        #[rustfmt::skip]
1718        fn test_release_blocked_funds_receive_policy_paths() -> eyre::Result<()> {
1719            let admin = Address::random();
1720            let originator = Address::random();
1721            let receiver = Address::random();
1722            let third_party = Address::random();
1723            let open_destination = Address::random();
1724            let blocked_destination = Address::random();
1725            let amount = U256::from(10u64);
1726
1727            for (mode, recovery_auth, destination, destination_policy_blocks, should_succeed) in [
1728                // Receiver recovery back to the receiver is a resume: it skips receive-policy
1729                // validation, so the original blocking policy does not deadlock the claim.
1730                (RecoveryMode::Receiver, receiver, receiver, true, true),
1731                // Receiver and originator reroutes re-check the destination receive policy.
1732                (RecoveryMode::Receiver, receiver, blocked_destination, true, false),
1733                (RecoveryMode::Originator, originator, blocked_destination, true, false),
1734                (RecoveryMode::Originator, originator, originator, false, true),
1735                // Third-party recovery back to the receiver is also a resume: the receiver selected
1736                // that authority, so the claim skips receive-policy validation like receiver recovery.
1737                (RecoveryMode::ThirdParty, third_party, receiver, true, true),
1738                (RecoveryMode::ThirdParty, third_party, open_destination, false, true),
1739            ] {
1740                let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
1741                StorageCtx::enter(&mut storage, || {
1742                    let mut token = TIP20Setup::create("Test", "TST", admin)
1743                        .with_issuer(admin)
1744                        .apply()?;
1745                    token.set_balance(RECEIVE_POLICY_GUARD_ADDRESS, amount)?;
1746
1747                    set_receive_policy(
1748                        receiver,
1749                        REJECT_ALL_POLICY_ID,
1750                        ALLOW_ALL_POLICY_ID,
1751                        Address::ZERO,
1752                    )?;
1753                    if destination_policy_blocks && destination != receiver {
1754                        set_receive_policy(
1755                            destination,
1756                            REJECT_ALL_POLICY_ID,
1757                            ALLOW_ALL_POLICY_ID,
1758                            Address::ZERO,
1759                        )?;
1760                    }
1761
1762                    let result = token.release_blocked_funds(
1763                        originator,
1764                        receiver,
1765                        destination,
1766                        amount,
1767                        mode,
1768                        recovery_auth,
1769                    );
1770
1771                    if should_succeed {
1772                        result?;
1773                        assert_eq!(token.get_balance(RECEIVE_POLICY_GUARD_ADDRESS)?, U256::ZERO);
1774                        assert_eq!(token.get_balance(destination)?, amount);
1775                    } else {
1776                        assert_eq!(result.unwrap_err(), TIP20Error::policy_forbids().into());
1777                        assert_eq!(token.get_balance(RECEIVE_POLICY_GUARD_ADDRESS)?, amount);
1778                        assert_eq!(token.get_balance(destination)?, U256::ZERO);
1779                    }
1780
1781                    Ok::<(), TempoPrecompileError>(())
1782                })?;
1783            }
1784
1785            let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
1786            StorageCtx::enter(&mut storage, || {
1787                let mut registry = TIP403Registry::new();
1788                let recipient_policy = registry.create_policy_with_accounts(
1789                    admin,
1790                    ITIP403Registry::createPolicyWithAccountsCall {
1791                        admin,
1792                        policyType: ITIP403Registry::PolicyType::WHITELIST,
1793                        accounts: vec![receiver],
1794                    },
1795                )?;
1796                let transfer_policy = registry.create_compound_policy(
1797                    admin,
1798                    ITIP403Registry::createCompoundPolicyCall {
1799                        senderPolicyId: REJECT_ALL_POLICY_ID,
1800                        recipientPolicyId: recipient_policy,
1801                        mintRecipientPolicyId: ALLOW_ALL_POLICY_ID,
1802                    },
1803                )?;
1804
1805                let mut token = TIP20Setup::create("Test", "TST", admin)
1806                    .with_issuer(admin)
1807                    .apply()?;
1808                token.change_transfer_policy_id(
1809                    admin,
1810                    ITIP20::changeTransferPolicyIdCall {
1811                        newPolicyId: transfer_policy,
1812                    },
1813                )?;
1814                token.set_balance(RECEIVE_POLICY_GUARD_ADDRESS, amount)?;
1815
1816                // A third-party claim back to the receiver is a resume. It requires the receiver
1817                // to be authorized as recipient, but must not require the receiver/policy subject
1818                // to be authorized as sender.
1819                token.release_blocked_funds(
1820                    originator,
1821                    receiver,
1822                    receiver,
1823                    amount,
1824                    RecoveryMode::ThirdParty,
1825                    third_party,
1826                )?;
1827
1828                assert_eq!(token.get_balance(RECEIVE_POLICY_GUARD_ADDRESS)?, U256::ZERO);
1829                assert_eq!(token.get_balance(receiver)?, amount);
1830
1831                Ok::<(), TempoPrecompileError>(())
1832            })?;
1833
1834            Ok(())
1835        }
1836
1837        #[test]
1838        fn test_transfer_blocked_by_token_filter_records_reason() -> eyre::Result<()> {
1839            let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
1840            storage.set_timestamp(U256::from(BLOCKED_AT));
1841            let admin = Address::random();
1842            let sender = Address::random();
1843            let receiver = Address::random();
1844            let amount = U256::from(40u64);
1845
1846            StorageCtx::enter(&mut storage, || {
1847                let mut token = TIP20Setup::create("Test", "TST", admin)
1848                    .with_issuer(admin)
1849                    .with_mint(sender, amount)
1850                    .apply()?;
1851                set_receive_policy(
1852                    receiver,
1853                    ALLOW_ALL_POLICY_ID,
1854                    REJECT_ALL_POLICY_ID,
1855                    Address::ZERO,
1856                )?;
1857
1858                token.transfer(
1859                    sender,
1860                    ITIP20::transferCall {
1861                        to: receiver,
1862                        amount,
1863                    },
1864                )?;
1865
1866                let receipt = IReceivePolicyGuard::ClaimReceiptV1::new(
1867                    token.address,
1868                    Address::ZERO,
1869                    sender,
1870                    receiver,
1871                    BLOCKED_AT,
1872                    1,
1873                    ITIP403Registry::BlockedReason::TOKEN_FILTER as u8,
1874                    InboundKind::TRANSFER,
1875                    B256::ZERO,
1876                );
1877                let guard = ReceivePolicyGuard::new();
1878                assert_eq!(guard.balance_of(receipt.abi_encode().into())?, amount);
1879                guard.assert_emitted_events(vec![ReceivePolicyGuardEvent::TransferBlocked(
1880                    IReceivePolicyGuard::TransferBlocked {
1881                        token: token.address,
1882                        receiver,
1883                        blockedNonce: 1,
1884                        receiptVersion: BLOCKED_RECEIPT_VERSION,
1885                        amount,
1886                        receipt: receipt.abi_encode().into(),
1887                    },
1888                )]);
1889
1890                Ok(())
1891            })
1892        }
1893
1894        #[test]
1895        fn test_transfer_to_guard_address_rejects() -> eyre::Result<()> {
1896            let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
1897            let admin = Address::random();
1898            let sender = Address::random();
1899            let amount = U256::from(10u64);
1900
1901            StorageCtx::enter(&mut storage, || {
1902                let mut token = TIP20Setup::create("Test", "TST", admin)
1903                    .with_issuer(admin)
1904                    .with_mint(sender, amount)
1905                    .apply()?;
1906
1907                let result = token.transfer(
1908                    sender,
1909                    ITIP20::transferCall {
1910                        to: RECEIVE_POLICY_GUARD_ADDRESS,
1911                        amount,
1912                    },
1913                );
1914                assert!(matches!(
1915                    result,
1916                    Err(e) if e == ReceivePolicyGuardError::address_reserved().into()
1917                ));
1918                assert_eq!(token.get_balance(sender)?, amount);
1919                assert_eq!(token.get_balance(RECEIVE_POLICY_GUARD_ADDRESS)?, U256::ZERO);
1920
1921                Ok(())
1922            })
1923        }
1924
1925        #[test]
1926        fn test_pre_t6_receive_policy_does_not_guard() -> eyre::Result<()> {
1927            let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5);
1928            storage.set_timestamp(U256::from(BLOCKED_AT));
1929            let admin = Address::random();
1930            let sender = Address::random();
1931            let receiver = Address::random();
1932            let amount = U256::from(25u64);
1933
1934            StorageCtx::enter(&mut storage, || {
1935                let mut token = TIP20Setup::create("Test", "TST", admin)
1936                    .with_issuer(admin)
1937                    .with_mint(sender, amount)
1938                    .clear_events()
1939                    .apply()?;
1940                set_receive_policy(
1941                    receiver,
1942                    REJECT_ALL_POLICY_ID,
1943                    ALLOW_ALL_POLICY_ID,
1944                    Address::ZERO,
1945                )?;
1946
1947                token.transfer(
1948                    sender,
1949                    ITIP20::transferCall {
1950                        to: receiver,
1951                        amount,
1952                    },
1953                )?;
1954
1955                assert_eq!(token.get_balance(sender)?, U256::ZERO);
1956                assert_eq!(token.get_balance(receiver)?, amount);
1957                assert_eq!(token.get_balance(RECEIVE_POLICY_GUARD_ADDRESS)?, U256::ZERO);
1958                token.assert_emitted_events(vec![TIP20Event::Transfer(ITIP20::Transfer {
1959                    from: sender,
1960                    to: receiver,
1961                    amount,
1962                })]);
1963                let receipt = IReceivePolicyGuard::ClaimReceiptV1::new(
1964                    token.address,
1965                    sender,
1966                    sender,
1967                    receiver,
1968                    BLOCKED_AT,
1969                    1,
1970                    ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8,
1971                    InboundKind::TRANSFER,
1972                    B256::ZERO,
1973                );
1974                assert_eq!(
1975                    ReceivePolicyGuard::new().balance_of(receipt.abi_encode().into())?,
1976                    U256::ZERO
1977                );
1978
1979                Ok(())
1980            })
1981        }
1982
1983        #[test]
1984        fn test_transfer_from_blocked_consumes_allowance() -> eyre::Result<()> {
1985            let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
1986            storage.set_timestamp(U256::from(BLOCKED_AT));
1987            let admin = Address::random();
1988            let owner = Address::random();
1989            let spender = Address::random();
1990            let receiver = Address::random();
1991            let amount = U256::from(30u64);
1992            let allowance = amount + U256::from(5u64);
1993
1994            StorageCtx::enter(&mut storage, || {
1995                let mut token = TIP20Setup::create("Test", "TST", admin)
1996                    .with_issuer(admin)
1997                    .with_mint(owner, amount)
1998                    .with_approval(owner, spender, allowance)
1999                    .apply()?;
2000                set_receive_policy(
2001                    receiver,
2002                    REJECT_ALL_POLICY_ID,
2003                    ALLOW_ALL_POLICY_ID,
2004                    Address::ZERO,
2005                )?;
2006
2007                token.transfer_from(
2008                    spender,
2009                    ITIP20::transferFromCall {
2010                        from: owner,
2011                        to: receiver,
2012                        amount,
2013                    },
2014                )?;
2015
2016                assert_eq!(
2017                    token.allowance(ITIP20::allowanceCall { owner, spender })?,
2018                    allowance - amount
2019                );
2020                assert_eq!(token.get_balance(owner)?, U256::ZERO);
2021                assert_eq!(token.get_balance(receiver)?, U256::ZERO);
2022                assert_eq!(token.get_balance(RECEIVE_POLICY_GUARD_ADDRESS)?, amount);
2023
2024                Ok(())
2025            })
2026        }
2027
2028        #[test]
2029        fn test_transfer_with_memo_blocked_preserves_memo() -> eyre::Result<()> {
2030            let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
2031            storage.set_timestamp(U256::from(BLOCKED_AT));
2032            let admin = Address::random();
2033            let sender = Address::random();
2034            let receiver = Address::random();
2035            let amount = U256::from(55u64);
2036            let memo = B256::repeat_byte(0xab);
2037
2038            StorageCtx::enter(&mut storage, || {
2039                let mut token = TIP20Setup::create("Test", "TST", admin)
2040                    .with_issuer(admin)
2041                    .with_mint(sender, amount)
2042                    .clear_events()
2043                    .apply()?;
2044                set_receive_policy(
2045                    receiver,
2046                    REJECT_ALL_POLICY_ID,
2047                    ALLOW_ALL_POLICY_ID,
2048                    Address::ZERO,
2049                )?;
2050
2051                token.transfer_with_memo(
2052                    sender,
2053                    ITIP20::transferWithMemoCall {
2054                        to: receiver,
2055                        amount,
2056                        memo,
2057                    },
2058                )?;
2059
2060                token.assert_emitted_events(vec![TIP20Event::Transfer(ITIP20::Transfer {
2061                    from: sender,
2062                    to: RECEIVE_POLICY_GUARD_ADDRESS,
2063                    amount,
2064                })]);
2065                let receipt = IReceivePolicyGuard::ClaimReceiptV1::new(
2066                    token.address,
2067                    Address::ZERO,
2068                    sender,
2069                    receiver,
2070                    BLOCKED_AT,
2071                    1,
2072                    ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8,
2073                    InboundKind::TRANSFER,
2074                    memo,
2075                );
2076                assert_eq!(
2077                    ReceivePolicyGuard::new().balance_of(receipt.abi_encode().into())?,
2078                    amount
2079                );
2080
2081                Ok(())
2082            })
2083        }
2084
2085        #[test]
2086        fn test_mint_blocked_credits_guard() -> eyre::Result<()> {
2087            let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
2088            storage.set_timestamp(U256::from(BLOCKED_AT));
2089            let admin = Address::random();
2090            let receiver = Address::random();
2091            let amount = U256::from(70u64);
2092
2093            StorageCtx::enter(&mut storage, || {
2094                let mut token = TIP20Setup::create("Test", "TST", admin)
2095                    .with_issuer(admin)
2096                    .clear_events()
2097                    .apply()?;
2098                set_receive_policy(
2099                    receiver,
2100                    REJECT_ALL_POLICY_ID,
2101                    ALLOW_ALL_POLICY_ID,
2102                    Address::ZERO,
2103                )?;
2104
2105                let mut guard = ReceivePolicyGuard::new();
2106                guard.clear_emitted_events();
2107                token.mint(
2108                    admin,
2109                    ITIP20::mintCall {
2110                        to: receiver,
2111                        amount,
2112                    },
2113                )?;
2114
2115                assert_eq!(token.total_supply()?, amount);
2116                assert_eq!(token.get_balance(receiver)?, U256::ZERO);
2117                assert_eq!(token.get_balance(RECEIVE_POLICY_GUARD_ADDRESS)?, amount);
2118                token.assert_emitted_events(vec![
2119                    TIP20Event::transfer(Address::ZERO, RECEIVE_POLICY_GUARD_ADDRESS, amount),
2120                    TIP20Event::mint(RECEIVE_POLICY_GUARD_ADDRESS, amount),
2121                ]);
2122                let receipt = IReceivePolicyGuard::ClaimReceiptV1::new(
2123                    token.address,
2124                    Address::ZERO,
2125                    admin,
2126                    receiver,
2127                    BLOCKED_AT,
2128                    1,
2129                    ITIP403Registry::BlockedReason::RECEIVE_POLICY as u8,
2130                    InboundKind::MINT,
2131                    B256::ZERO,
2132                );
2133                guard.assert_emitted_events(vec![ReceivePolicyGuardEvent::TransferBlocked(
2134                    IReceivePolicyGuard::TransferBlocked {
2135                        token: token.address,
2136                        receiver,
2137                        blockedNonce: 1,
2138                        receiptVersion: BLOCKED_RECEIPT_VERSION,
2139                        amount,
2140                        receipt: receipt.abi_encode().into(),
2141                    },
2142                )]);
2143                assert_eq!(guard.balance_of(receipt.abi_encode().into())?, amount);
2144
2145                Ok(())
2146            })
2147        }
2148    }
2149
2150    #[test]
2151    fn test_transfer_insufficient_balance_fails() -> eyre::Result<()> {
2152        let (mut storage, admin) = setup_storage();
2153        let from = Address::random();
2154        let to = Address::random();
2155        let amount = U256::random() % U256::from(u128::MAX);
2156
2157        StorageCtx::enter(&mut storage, || {
2158            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
2159
2160            let result = token.transfer(from, ITIP20::transferCall { to, amount });
2161            assert!(matches!(
2162                result,
2163                Err(TempoPrecompileError::TIP20(
2164                    TIP20Error::InsufficientBalance(_)
2165                ))
2166            ));
2167
2168            Ok(())
2169        })
2170    }
2171
2172    #[test]
2173    fn test_mint_with_memo() -> eyre::Result<()> {
2174        let mut storage = HashMapStorageProvider::new(1);
2175        let admin = Address::random();
2176        let amount = U256::random() % U256::from(u128::MAX);
2177        let to = Address::random();
2178        let memo = FixedBytes::random();
2179
2180        StorageCtx::enter(&mut storage, || {
2181            let mut token = TIP20Setup::create("Test", "TST", admin)
2182                .with_issuer(admin)
2183                .clear_events()
2184                .apply()?;
2185
2186            token.mint_with_memo(admin, ITIP20::mintWithMemoCall { to, amount, memo })?;
2187
2188            // TransferWithMemo event should have Address::ZERO as from for mint
2189            token.assert_emitted_events(vec![
2190                TIP20Event::transfer(Address::ZERO, to, amount),
2191                TIP20Event::transfer_with_memo(Address::ZERO, to, amount, memo),
2192                TIP20Event::mint(to, amount),
2193            ]);
2194
2195            Ok(())
2196        })
2197    }
2198
2199    #[test]
2200    fn test_burn_with_memo() -> eyre::Result<()> {
2201        let mut storage = HashMapStorageProvider::new(1);
2202        let admin = Address::random();
2203        let amount = U256::random() % U256::from(u128::MAX);
2204        let memo = FixedBytes::random();
2205
2206        StorageCtx::enter(&mut storage, || {
2207            let mut token = TIP20Setup::create("Test", "TST", admin)
2208                .with_issuer(admin)
2209                .with_mint(admin, amount)
2210                .clear_events()
2211                .apply()?;
2212
2213            token.burn_with_memo(admin, ITIP20::burnWithMemoCall { amount, memo })?;
2214            token.assert_emitted_events(vec![
2215                TIP20Event::transfer(admin, Address::ZERO, amount),
2216                TIP20Event::transfer_with_memo(admin, Address::ZERO, amount, memo),
2217                TIP20Event::burn(admin, amount),
2218            ]);
2219
2220            Ok(())
2221        })
2222    }
2223
2224    #[test]
2225    fn test_transfer_from_with_memo_from_address() -> eyre::Result<()> {
2226        let mut storage = HashMapStorageProvider::new(1);
2227        let admin = Address::random();
2228        let owner = Address::random();
2229        let spender = Address::random();
2230        let to = Address::random();
2231        let memo = FixedBytes::random();
2232        let amount = U256::random() % U256::from(u128::MAX);
2233
2234        StorageCtx::enter(&mut storage, || {
2235            let mut token = TIP20Setup::create("Test", "TST", admin)
2236                .with_issuer(admin)
2237                .with_mint(owner, amount)
2238                .with_approval(owner, spender, amount)
2239                .clear_events()
2240                .apply()?;
2241
2242            token.transfer_from_with_memo(
2243                spender,
2244                ITIP20::transferFromWithMemoCall {
2245                    from: owner,
2246                    to,
2247                    amount,
2248                    memo,
2249                },
2250            )?;
2251
2252            // TransferWithMemo event should have use call.from in transfer event
2253            token.assert_emitted_events(vec![
2254                TIP20Event::transfer(owner, to, amount),
2255                TIP20Event::transfer_with_memo(owner, to, amount, memo),
2256            ]);
2257
2258            Ok(())
2259        })
2260    }
2261
2262    #[test]
2263    fn test_transfer_fee_pre_tx() -> eyre::Result<()> {
2264        let mut storage = HashMapStorageProvider::new(1);
2265        let admin = Address::random();
2266        let user = Address::random();
2267        let amount = U256::from(100);
2268        let fee_amount = amount / U256::from(2);
2269
2270        StorageCtx::enter(&mut storage, || {
2271            let mut token = TIP20Setup::create("Test", "TST", admin)
2272                .with_issuer(admin)
2273                .with_mint(user, amount)
2274                .apply()?;
2275
2276            token.transfer_fee_pre_tx(user, fee_amount)?;
2277
2278            assert_eq!(token.get_balance(user)?, fee_amount);
2279            assert_eq!(token.get_balance(TIP_FEE_MANAGER_ADDRESS)?, fee_amount);
2280
2281            Ok(())
2282        })
2283    }
2284
2285    #[test]
2286    fn test_transfer_fee_pre_tx_insufficient_balance() -> eyre::Result<()> {
2287        let mut storage = HashMapStorageProvider::new(1);
2288        let admin = Address::random();
2289        let user = Address::random();
2290        let amount = U256::from(100);
2291        let fee_amount = amount / U256::from(2);
2292
2293        StorageCtx::enter(&mut storage, || {
2294            let mut token = TIP20Setup::create("Test", "TST", admin)
2295                .with_issuer(admin)
2296                .apply()?;
2297
2298            assert_eq!(
2299                token.transfer_fee_pre_tx(user, fee_amount),
2300                Err(TempoPrecompileError::TIP20(
2301                    TIP20Error::insufficient_balance(U256::ZERO, fee_amount, token.address)
2302                ))
2303            );
2304            Ok(())
2305        })
2306    }
2307
2308    #[test]
2309    fn test_transfer_fee_pre_tx_fee_manager_overflow() -> eyre::Result<()> {
2310        let mut storage = HashMapStorageProvider::new(1);
2311        let admin = Address::random();
2312        let user = Address::random();
2313        let fee_amount = U256::ONE;
2314
2315        StorageCtx::enter(&mut storage, || {
2316            let mut token = TIP20Setup::create("Test", "TST", admin)
2317                .with_issuer(admin)
2318                .with_mint(user, fee_amount)
2319                .apply()?;
2320            token.set_balance(TIP_FEE_MANAGER_ADDRESS, U256::MAX)?;
2321
2322            assert_eq!(
2323                token.transfer_fee_pre_tx(user, fee_amount),
2324                Err(TempoPrecompileError::TIP20(
2325                    TIP20Error::supply_cap_exceeded()
2326                ))
2327            );
2328            Ok(())
2329        })
2330    }
2331
2332    #[test]
2333    fn test_transfer_fee_pre_tx_paused() -> eyre::Result<()> {
2334        let mut storage = HashMapStorageProvider::new(1);
2335        let admin = Address::random();
2336        let user = Address::random();
2337        let amount = U256::from(100);
2338        let fee_amount = amount / U256::from(2);
2339
2340        StorageCtx::enter(&mut storage, || {
2341            let mut token = TIP20Setup::create("Test", "TST", admin)
2342                .with_issuer(admin)
2343                .with_role(admin, *PAUSE_ROLE)
2344                .with_mint(user, amount)
2345                .apply()?;
2346
2347            // Pause the token
2348            token.pause(admin, ITIP20::pauseCall {})?;
2349
2350            // transfer_fee_pre_tx should fail when paused
2351            assert_eq!(
2352                token.transfer_fee_pre_tx(user, fee_amount),
2353                Err(TempoPrecompileError::TIP20(TIP20Error::contract_paused()))
2354            );
2355            Ok(())
2356        })
2357    }
2358
2359    #[test]
2360    fn test_transfer_fee_post_tx() -> eyre::Result<()> {
2361        let mut storage = HashMapStorageProvider::new(1);
2362        let admin = Address::random();
2363        let user = Address::random();
2364        let initial_fee = U256::from(100);
2365        let refund_amount = U256::from(30);
2366        let gas_used = U256::from(10);
2367
2368        StorageCtx::enter(&mut storage, || {
2369            let mut token = TIP20Setup::create("Test", "TST", admin)
2370                .with_issuer(admin)
2371                .with_mint(TIP_FEE_MANAGER_ADDRESS, initial_fee)
2372                .apply()?;
2373
2374            token.transfer_fee_post_tx(user, refund_amount, gas_used)?;
2375
2376            assert_eq!(token.get_balance(user)?, refund_amount);
2377            assert_eq!(
2378                token.get_balance(TIP_FEE_MANAGER_ADDRESS)?,
2379                initial_fee - refund_amount
2380            );
2381            assert_eq!(
2382                token.emitted_events().last().unwrap(),
2383                &TIP20Event::transfer(user, TIP_FEE_MANAGER_ADDRESS, gas_used).into_log_data()
2384            );
2385
2386            Ok(())
2387        })
2388    }
2389
2390    #[test]
2391    fn test_transfer_fee_post_tx_insufficient_fee_manager_balance() -> eyre::Result<()> {
2392        let mut storage = HashMapStorageProvider::new(1);
2393        let admin = Address::random();
2394        let user = Address::random();
2395        let initial_fee = U256::from(10);
2396        let refund_amount = U256::from(30);
2397
2398        StorageCtx::enter(&mut storage, || {
2399            let mut token = TIP20Setup::create("Test", "TST", admin)
2400                .with_issuer(admin)
2401                .with_mint(TIP_FEE_MANAGER_ADDRESS, initial_fee)
2402                .apply()?;
2403
2404            assert_eq!(
2405                token.transfer_fee_post_tx(user, refund_amount, U256::ZERO),
2406                Err(TempoPrecompileError::TIP20(
2407                    TIP20Error::insufficient_balance(initial_fee, refund_amount, token.address)
2408                ))
2409            );
2410
2411            Ok(())
2412        })
2413    }
2414
2415    #[test]
2416    fn test_transfer_fee_post_tx_recipient_overflow() -> eyre::Result<()> {
2417        let mut storage = HashMapStorageProvider::new(1);
2418        let admin = Address::random();
2419        let user = Address::random();
2420        let refund_amount = U256::ONE;
2421
2422        StorageCtx::enter(&mut storage, || {
2423            let mut token = TIP20Setup::create("Test", "TST", admin)
2424                .with_issuer(admin)
2425                .apply()?;
2426            token.set_balance(TIP_FEE_MANAGER_ADDRESS, refund_amount)?;
2427            token.set_balance(user, U256::MAX)?;
2428
2429            assert_eq!(
2430                token.transfer_fee_post_tx(user, refund_amount, U256::ZERO),
2431                Err(TempoPrecompileError::TIP20(
2432                    TIP20Error::supply_cap_exceeded()
2433                ))
2434            );
2435            Ok(())
2436        })
2437    }
2438
2439    #[test]
2440    fn test_transfer_fee_post_tx_refunds_spending_limit() -> eyre::Result<()> {
2441        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T1C);
2442        let admin = Address::random();
2443        let user = Address::random();
2444        let access_key = Address::random();
2445        let max_fee = U256::from(1000);
2446        let refund_amount = U256::from(300);
2447        let gas_used = U256::from(100);
2448
2449        StorageCtx::enter(&mut storage, || {
2450            let mut token = TIP20Setup::create("Test", "TST", admin)
2451                .with_issuer(admin)
2452                .with_mint(TIP_FEE_MANAGER_ADDRESS, max_fee)
2453                .apply()?;
2454
2455            let token_address = token.address;
2456            let spending_limit = U256::from(2000);
2457
2458            // Set up keychain: authorize an access key with a spending limit
2459            let mut keychain = AccountKeychain::new();
2460            keychain.initialize()?;
2461            keychain.set_transaction_key(Address::ZERO)?;
2462
2463            keychain.authorize_key(
2464                user,
2465                access_key,
2466                SignatureType::Secp256k1,
2467                KeyRestrictions {
2468                    expiry: u64::MAX,
2469                    enforceLimits: true,
2470                    limits: vec![TokenLimit {
2471                        token: token_address,
2472                        amount: spending_limit,
2473                        period: 0,
2474                    }],
2475                    allowAnyCalls: true,
2476                    allowedCalls: vec![],
2477                },
2478                None,
2479            )?;
2480
2481            // Simulate pre-tx: access key deducts max fee from spending limit
2482            keychain.set_transaction_key(access_key)?;
2483            keychain.set_tx_origin(user)?;
2484            keychain.authorize_transfer(user, token_address, max_fee)?;
2485
2486            let remaining_after_deduction =
2487                keychain.get_remaining_limit(getRemainingLimitCall {
2488                    account: user,
2489                    keyId: access_key,
2490                    token: token_address,
2491                })?;
2492            assert_eq!(remaining_after_deduction, spending_limit - max_fee);
2493
2494            // Call transfer_fee_post_tx — should refund the spending limit via is_t1c() gate
2495            token.transfer_fee_post_tx(user, refund_amount, gas_used)?;
2496
2497            let remaining_after_refund = keychain.get_remaining_limit(getRemainingLimitCall {
2498                account: user,
2499                keyId: access_key,
2500                token: token_address,
2501            })?;
2502            assert_eq!(
2503                remaining_after_refund,
2504                spending_limit - max_fee + refund_amount,
2505                "spending limit should be restored by refund amount"
2506            );
2507
2508            Ok(())
2509        })
2510    }
2511
2512    #[test]
2513    fn test_transfer_fee_post_tx_pre_t1c() -> eyre::Result<()> {
2514        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T1B);
2515        let admin = Address::random();
2516        let user = Address::random();
2517        let access_key = Address::random();
2518        let max_fee = U256::from(1000);
2519        let refund_amount = U256::from(300);
2520        let gas_used = U256::from(100);
2521
2522        StorageCtx::enter(&mut storage, || {
2523            let mut token = TIP20Setup::create("Test", "TST", admin)
2524                .with_issuer(admin)
2525                .with_mint(TIP_FEE_MANAGER_ADDRESS, max_fee)
2526                .apply()?;
2527
2528            let token_address = token.address;
2529            let spending_limit = U256::from(2000);
2530
2531            let mut keychain = AccountKeychain::new();
2532            keychain.initialize()?;
2533            keychain.set_transaction_key(Address::ZERO)?;
2534
2535            keychain.authorize_key(
2536                user,
2537                access_key,
2538                SignatureType::Secp256k1,
2539                KeyRestrictions {
2540                    expiry: u64::MAX,
2541                    enforceLimits: true,
2542                    limits: vec![TokenLimit {
2543                        token: token_address,
2544                        amount: spending_limit,
2545                        period: 0,
2546                    }],
2547                    allowAnyCalls: true,
2548                    allowedCalls: vec![],
2549                },
2550                None,
2551            )?;
2552
2553            keychain.set_transaction_key(access_key)?;
2554            keychain.set_tx_origin(user)?;
2555            keychain.authorize_transfer(user, token_address, max_fee)?;
2556
2557            let remaining_after_deduction =
2558                keychain.get_remaining_limit(getRemainingLimitCall {
2559                    account: user,
2560                    keyId: access_key,
2561                    token: token_address,
2562                })?;
2563            assert_eq!(remaining_after_deduction, spending_limit - max_fee);
2564
2565            token.transfer_fee_post_tx(user, refund_amount, gas_used)?;
2566
2567            // spending limit unchanged pre-t1c
2568            let remaining_after_refund = keychain.get_remaining_limit(getRemainingLimitCall {
2569                account: user,
2570                keyId: access_key,
2571                token: token_address,
2572            })?;
2573            assert_eq!(remaining_after_refund, spending_limit - max_fee);
2574
2575            Ok(())
2576        })
2577    }
2578
2579    #[test]
2580    fn test_transfer_from_insufficient_allowance() -> eyre::Result<()> {
2581        let mut storage = HashMapStorageProvider::new(1);
2582        let admin = Address::random();
2583        let from = Address::random();
2584        let spender = Address::random();
2585        let to = Address::random();
2586        let amount = U256::random() % U256::from(u128::MAX);
2587
2588        StorageCtx::enter(&mut storage, || {
2589            let mut token = TIP20Setup::create("Test", "TST", admin)
2590                .with_issuer(admin)
2591                .with_mint(from, amount)
2592                .apply()?;
2593
2594            assert!(matches!(
2595                token.transfer_from(spender, ITIP20::transferFromCall { from, to, amount }),
2596                Err(TempoPrecompileError::TIP20(
2597                    TIP20Error::InsufficientAllowance(_)
2598                ))
2599            ));
2600
2601            Ok(())
2602        })
2603    }
2604
2605    #[test]
2606    fn test_system_transfer_from() -> eyre::Result<()> {
2607        let mut storage = HashMapStorageProvider::new(1);
2608        let admin = Address::random();
2609        let from = Address::random();
2610        let to = Address::random();
2611        let amount = U256::random() % U256::from(u128::MAX);
2612
2613        StorageCtx::enter(&mut storage, || {
2614            let mut token = TIP20Setup::create("Test", "TST", admin)
2615                .with_issuer(admin)
2616                .with_mint(from, amount)
2617                .apply()?;
2618
2619            // Pre-T5: caller is unchecked (preserves pre-TIP-1035 FeeAMM behavior).
2620            assert!(token.system_transfer_from(to, from, amount).is_ok());
2621            assert_eq!(
2622                token.emitted_events().last().unwrap(),
2623                &TIP20Event::transfer(from, to, amount).into_log_data()
2624            );
2625
2626            Ok(())
2627        })
2628    }
2629
2630    #[test]
2631    fn test_system_transfer_from_t5_authorized() -> eyre::Result<()> {
2632        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5);
2633        let admin = Address::random();
2634        let from = Address::random();
2635        let amount = U256::random() % U256::from(u128::MAX);
2636
2637        StorageCtx::enter(&mut storage, || {
2638            let mut token = TIP20Setup::create("Test", "TST", admin)
2639                .with_issuer(admin)
2640                .with_mint(from, amount)
2641                .apply()?;
2642
2643            // Listed precompile is allowed to invoke `system_transfer_from`.
2644            assert!(
2645                token
2646                    .system_transfer_from(TIP_FEE_MANAGER_ADDRESS, from, amount)
2647                    .is_ok()
2648            );
2649
2650            Ok(())
2651        })
2652    }
2653
2654    #[test]
2655    fn test_system_transfer_from_t5_unauthorized_reverts() -> eyre::Result<()> {
2656        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5);
2657        let admin = Address::random();
2658        let unlisted = Address::random();
2659        let from = Address::random();
2660        let amount = U256::random() % U256::from(u128::MAX);
2661
2662        StorageCtx::enter(&mut storage, || {
2663            let mut token = TIP20Setup::create("Test", "TST", admin)
2664                .with_issuer(admin)
2665                .with_mint(from, amount)
2666                .apply()?;
2667
2668            // Unlisted callers are rejected with `Unauthorized` at T5+.
2669            assert!(matches!(
2670                token.system_transfer_from(unlisted, from, amount),
2671                Err(TempoPrecompileError::TIP20(TIP20Error::Unauthorized(_)))
2672            ));
2673
2674            Ok(())
2675        })
2676    }
2677
2678    #[test]
2679    fn test_initialize_sets_next_quote_token() -> eyre::Result<()> {
2680        let mut storage = HashMapStorageProvider::new(1);
2681        let admin = Address::random();
2682
2683        StorageCtx::enter(&mut storage, || {
2684            let token = TIP20Setup::create("Test", "TST", admin).apply()?;
2685
2686            // Verify both quoteToken and nextQuoteToken are set to the same value
2687            assert_eq!(token.quote_token()?, PATH_USD_ADDRESS);
2688            assert_eq!(token.next_quote_token()?, PATH_USD_ADDRESS);
2689
2690            Ok(())
2691        })
2692    }
2693
2694    #[test]
2695    fn test_update_quote_token() -> eyre::Result<()> {
2696        let mut storage = HashMapStorageProvider::new(1);
2697        let admin = Address::random();
2698
2699        StorageCtx::enter(&mut storage, || {
2700            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
2701
2702            // Create a new USD token to use as the new quote token
2703            let new_quote_token = TIP20Setup::create("New Quote", "NQ", admin).apply()?;
2704            let new_quote_token_address = new_quote_token.address;
2705
2706            // Verify initial quote token is PATH_USD
2707            assert_eq!(token.quote_token()?, PATH_USD_ADDRESS);
2708
2709            // Set next quote token to the new token
2710            token.set_next_quote_token(
2711                admin,
2712                ITIP20::setNextQuoteTokenCall {
2713                    newQuoteToken: new_quote_token_address,
2714                },
2715            )?;
2716
2717            // Verify next quote token was set to the new token
2718            assert_eq!(token.next_quote_token()?, new_quote_token_address);
2719
2720            // Verify event was emitted
2721            assert_eq!(
2722                token.emitted_events().last().unwrap(),
2723                &TIP20Event::next_quote_token_set(admin, new_quote_token_address).into_log_data()
2724            );
2725
2726            Ok(())
2727        })
2728    }
2729
2730    #[test]
2731    fn test_update_quote_token_requires_admin() -> eyre::Result<()> {
2732        let mut storage = HashMapStorageProvider::new(1);
2733        let admin = Address::random();
2734        let non_admin = Address::random();
2735
2736        StorageCtx::enter(&mut storage, || {
2737            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
2738
2739            // Use the token's own quote token for the test
2740            let quote_token_address = token.quote_token()?;
2741
2742            // Try to set next quote token as non-admin
2743            let result = token.set_next_quote_token(
2744                non_admin,
2745                ITIP20::setNextQuoteTokenCall {
2746                    newQuoteToken: quote_token_address,
2747                },
2748            );
2749
2750            assert!(matches!(
2751                result,
2752                Err(TempoPrecompileError::RolesAuthError(
2753                    RolesAuthError::Unauthorized(_)
2754                ))
2755            ));
2756
2757            Ok(())
2758        })
2759    }
2760
2761    #[test]
2762    fn test_update_quote_token_rejects_non_tip20() -> eyre::Result<()> {
2763        let mut storage = HashMapStorageProvider::new(1);
2764        let admin = Address::random();
2765
2766        StorageCtx::enter(&mut storage, || {
2767            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
2768
2769            // Try to set a non-TIP20 address (random address that doesn't match TIP20 pattern)
2770            let non_tip20_address = Address::random();
2771            let result = token.set_next_quote_token(
2772                admin,
2773                ITIP20::setNextQuoteTokenCall {
2774                    newQuoteToken: non_tip20_address,
2775                },
2776            );
2777
2778            assert!(matches!(
2779                result,
2780                Err(TempoPrecompileError::TIP20(TIP20Error::InvalidQuoteToken(
2781                    _
2782                )))
2783            ));
2784
2785            Ok(())
2786        })
2787    }
2788
2789    #[test]
2790    fn test_update_quote_token_rejects_undeployed_token() -> eyre::Result<()> {
2791        let mut storage = HashMapStorageProvider::new(1);
2792        let admin = Address::random();
2793
2794        StorageCtx::enter(&mut storage, || {
2795            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
2796
2797            // Try to set a TIP20 address that hasn't been deployed yet
2798            // This has the correct TIP20 address pattern but hasn't been created
2799            let undeployed_token_address =
2800                Address::from(hex!("20C0000000000000000000000000000000000999"));
2801            let result = token.set_next_quote_token(
2802                admin,
2803                ITIP20::setNextQuoteTokenCall {
2804                    newQuoteToken: undeployed_token_address,
2805                },
2806            );
2807
2808            assert!(matches!(
2809                result,
2810                Err(TempoPrecompileError::TIP20(TIP20Error::InvalidQuoteToken(
2811                    _
2812                )))
2813            ));
2814
2815            Ok(())
2816        })
2817    }
2818
2819    #[test]
2820    fn test_finalize_quote_token_update() -> eyre::Result<()> {
2821        let mut storage = HashMapStorageProvider::new(1);
2822        let admin = Address::random();
2823
2824        StorageCtx::enter(&mut storage, || {
2825            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
2826            let quote_token_address = token.quote_token()?;
2827
2828            // Set next quote token
2829            token.set_next_quote_token(
2830                admin,
2831                ITIP20::setNextQuoteTokenCall {
2832                    newQuoteToken: quote_token_address,
2833                },
2834            )?;
2835
2836            // Complete the update
2837            token.complete_quote_token_update(admin, ITIP20::completeQuoteTokenUpdateCall {})?;
2838
2839            // Verify quote token was updated
2840            assert_eq!(token.quote_token()?, quote_token_address);
2841
2842            // Verify event was emitted
2843            assert_eq!(
2844                token.emitted_events().last().unwrap(),
2845                &TIP20Event::quote_token_update(admin, quote_token_address).into_log_data()
2846            );
2847
2848            Ok(())
2849        })
2850    }
2851
2852    #[test]
2853    fn test_finalize_quote_token_update_detects_loop() -> eyre::Result<()> {
2854        let mut storage = HashMapStorageProvider::new(1);
2855        let admin = Address::random();
2856
2857        StorageCtx::enter(&mut storage, || {
2858            // Create token_b first (links to LINKING_USD)
2859            let mut token_b = TIP20Setup::create("Token B", "TKB", admin).apply()?;
2860            // Create token_a (links to token_b)
2861            let token_a = TIP20Setup::create("Token A", "TKA", admin)
2862                .quote_token(token_b.address)
2863                .apply()?;
2864
2865            // Now try to set token_a as the next quote token for token_b (would create A -> B -> A loop)
2866            token_b.set_next_quote_token(
2867                admin,
2868                ITIP20::setNextQuoteTokenCall {
2869                    newQuoteToken: token_a.address,
2870                },
2871            )?;
2872
2873            // Try to complete the update - should fail due to loop detection
2874            let result =
2875                token_b.complete_quote_token_update(admin, ITIP20::completeQuoteTokenUpdateCall {});
2876
2877            assert!(matches!(
2878                result,
2879                Err(TempoPrecompileError::TIP20(TIP20Error::InvalidQuoteToken(
2880                    _
2881                )))
2882            ));
2883
2884            Ok(())
2885        })
2886    }
2887
2888    #[test]
2889    fn test_finalize_quote_token_update_requires_admin() -> eyre::Result<()> {
2890        let mut storage = HashMapStorageProvider::new(1);
2891        let admin = Address::random();
2892        let non_admin = Address::random();
2893
2894        StorageCtx::enter(&mut storage, || {
2895            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
2896            let quote_token_address = token.quote_token()?;
2897
2898            // Set next quote token as admin
2899            token.set_next_quote_token(
2900                admin,
2901                ITIP20::setNextQuoteTokenCall {
2902                    newQuoteToken: quote_token_address,
2903                },
2904            )?;
2905
2906            // Try to complete update as non-admin
2907            let result = token
2908                .complete_quote_token_update(non_admin, ITIP20::completeQuoteTokenUpdateCall {});
2909
2910            assert!(matches!(
2911                result,
2912                Err(TempoPrecompileError::RolesAuthError(
2913                    RolesAuthError::Unauthorized(_)
2914                ))
2915            ));
2916
2917            Ok(())
2918        })
2919    }
2920
2921    #[test]
2922    fn test_arbitrary_currency() -> eyre::Result<()> {
2923        let mut storage = HashMapStorageProvider::new(1);
2924        let admin = Address::random();
2925
2926        StorageCtx::enter(&mut storage, || {
2927            for _ in 0..50 {
2928                let currency: String = thread_rng()
2929                    .sample_iter(&Alphanumeric)
2930                    .take(31)
2931                    .map(char::from)
2932                    .collect();
2933
2934                // Initialize token with the random currency
2935                let token = TIP20Setup::create("Test", "TST", admin)
2936                    .currency(&currency)
2937                    .apply()?;
2938
2939                // Verify the currency was stored and can be retrieved correctly
2940                let stored_currency = token.currency()?;
2941                assert_eq!(stored_currency, currency,);
2942            }
2943
2944            Ok(())
2945        })
2946    }
2947
2948    #[test]
2949    fn test_validate_logo_uri() {
2950        const MAX: usize = TIP20Token::MAX_LOGO_URI_BYTES;
2951
2952        // Valid: empty, all allowlisted schemes (case-insensitive), and exactly at the 256-byte cap.
2953        let prefix = "https://example.com/";
2954        let at_cap = format!("{prefix}{}", "a".repeat(MAX - prefix.len()));
2955        assert_eq!(at_cap.len(), MAX);
2956        for ok in [
2957            "",
2958            "https://example.com/icon.svg",
2959            "http://example.com/icon.png",
2960            "ipfs://QmXfzKRvjZz3u5JRgC4v5mGVbm9ahrUiB4DgzHBsnWbTMM",
2961            "data:image/svg+xml;base64,PHN2Zy8+",
2962            "HTTPS://example.com/ICON.svg",
2963            "IPFS://Qm123",
2964            &at_cap,
2965        ] {
2966            assert!(
2967                TIP20Token::validate_logo_uri(ok).is_ok(),
2968                "expected Ok for {ok:?}"
2969            );
2970        }
2971
2972        // 257 bytes — one over the limit. Use a syntactically valid URI so
2973        // we exercise the length check, not the URI/scheme check.
2974        let too_long = format!("{prefix}{}", "a".repeat(MAX + 1 - prefix.len()));
2975        assert_eq!(too_long.len(), MAX + 1);
2976        assert!(matches!(
2977            TIP20Token::validate_logo_uri(&too_long),
2978            Err(TempoPrecompileError::TIP20(TIP20Error::LogoURITooLong(_))),
2979        ));
2980
2981        // Disallowed schemes and malformed URIs
2982        for bad in [
2983            "javascript:alert(1)",
2984            "file:///etc/passwd",
2985            "ftp://x.test/icon.png",
2986            "no-scheme-here",
2987            "://missing-scheme.test",
2988            "1https://digit-leading.test",
2989            ":empty-scheme",
2990            " https://leading-space.test",
2991        ] {
2992            assert!(
2993                matches!(
2994                    TIP20Token::validate_logo_uri(bad),
2995                    Err(TempoPrecompileError::TIP20(TIP20Error::InvalidLogoURI(_))),
2996                ),
2997                "expected InvalidLogoURI for {bad:?}"
2998            );
2999        }
3000    }
3001
3002    #[test]
3003    fn test_set_logo_uri_non_admin_reverts() -> eyre::Result<()> {
3004        let mut storage = HashMapStorageProvider::new(1);
3005        let admin = Address::random();
3006        let non_admin = Address::random();
3007
3008        StorageCtx::enter(&mut storage, || {
3009            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
3010
3011            let result = token.set_logo_uri(
3012                non_admin,
3013                ITIP20::setLogoURICall {
3014                    newLogoURI: "https://example.com/icon.svg".to_string(),
3015                },
3016            );
3017
3018            assert!(matches!(
3019                result,
3020                Err(TempoPrecompileError::RolesAuthError(
3021                    RolesAuthError::Unauthorized(_)
3022                ))
3023            ));
3024
3025            // logoURI should remain unchanged (empty)
3026            assert_eq!(token.logo_uri()?, "");
3027
3028            Ok(())
3029        })
3030    }
3031
3032    #[test]
3033    fn test_set_logo_uri_too_long_reverts() -> eyre::Result<()> {
3034        let mut storage = HashMapStorageProvider::new(1);
3035        let admin = Address::random();
3036
3037        StorageCtx::enter(&mut storage, || {
3038            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
3039
3040            // 257 bytes — one over the limit. Use a syntactically valid URI
3041            // so we exercise the length check, not the URI/scheme check.
3042            let prefix = "https://example.com/";
3043            let too_long = format!("{prefix}{}", "a".repeat(257 - prefix.len()));
3044            assert_eq!(too_long.len(), 257);
3045            let result = token.set_logo_uri(
3046                admin,
3047                ITIP20::setLogoURICall {
3048                    newLogoURI: too_long,
3049                },
3050            );
3051
3052            assert!(matches!(
3053                result,
3054                Err(TempoPrecompileError::TIP20(TIP20Error::LogoURITooLong(_)))
3055            ));
3056
3057            // 256 bytes — at the limit, should succeed
3058            let at_limit = format!("{prefix}{}", "a".repeat(256 - prefix.len()));
3059            assert_eq!(at_limit.len(), 256);
3060            token.set_logo_uri(
3061                admin,
3062                ITIP20::setLogoURICall {
3063                    newLogoURI: at_limit.clone(),
3064                },
3065            )?;
3066            assert_eq!(token.logo_uri()?, at_limit);
3067
3068            Ok(())
3069        })
3070    }
3071
3072    #[test]
3073    fn test_set_logo_uri_writes_and_emits() -> eyre::Result<()> {
3074        let mut storage = HashMapStorageProvider::new(1);
3075        let admin = Address::random();
3076
3077        StorageCtx::enter(&mut storage, || {
3078            let mut token = TIP20Setup::create("Test", "TST", admin)
3079                .clear_events()
3080                .apply()?;
3081
3082            // Default is empty for a freshly-created token.
3083            assert_eq!(token.logo_uri()?, "");
3084
3085            let uri = "https://example.com/icon.svg".to_string();
3086            token.set_logo_uri(
3087                admin,
3088                ITIP20::setLogoURICall {
3089                    newLogoURI: uri.clone(),
3090                },
3091            )?;
3092
3093            assert_eq!(token.logo_uri()?, uri);
3094
3095            token.assert_emitted_events(vec![TIP20Event::LogoURIUpdated(ITIP20::LogoURIUpdated {
3096                updater: admin,
3097                newLogoURI: uri,
3098            })]);
3099
3100            Ok(())
3101        })
3102    }
3103
3104    #[test]
3105    fn test_set_logo_uri_empty_clears() -> eyre::Result<()> {
3106        let mut storage = HashMapStorageProvider::new(1);
3107        let admin = Address::random();
3108
3109        StorageCtx::enter(&mut storage, || {
3110            let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
3111
3112            token.set_logo_uri(
3113                admin,
3114                ITIP20::setLogoURICall {
3115                    newLogoURI: "https://example.com/icon.svg".to_string(),
3116                },
3117            )?;
3118            assert_eq!(token.logo_uri()?, "https://example.com/icon.svg");
3119
3120            // Empty string is still valid (clears the URI per spec).
3121            token.set_logo_uri(
3122                admin,
3123                ITIP20::setLogoURICall {
3124                    newLogoURI: String::new(),
3125                },
3126            )?;
3127            assert_eq!(token.logo_uri()?, "");
3128
3129            Ok(())
3130        })
3131    }
3132
3133    #[test]
3134    fn test_from_address() -> eyre::Result<()> {
3135        let mut storage = HashMapStorageProvider::new(1);
3136        let admin = Address::random();
3137
3138        StorageCtx::enter(&mut storage, || {
3139            // Test with factory-created token (hash-derived address)
3140            let token = TIP20Setup::create("Test", "TST", admin).apply()?;
3141            let via_from_address = TIP20Token::from_address(token.address)?.address;
3142
3143            assert_eq!(
3144                via_from_address, token.address,
3145                "from_address should use the provided address directly"
3146            );
3147
3148            // Test with reserved token (pathUSD)
3149            let _path_usd = TIP20Setup::path_usd(admin).apply()?;
3150            let via_from_address_reserved = TIP20Token::from_address(PATH_USD_ADDRESS)?.address;
3151
3152            assert_eq!(
3153                via_from_address_reserved, PATH_USD_ADDRESS,
3154                "from_address should work for reserved addresses too"
3155            );
3156
3157            Ok(())
3158        })
3159    }
3160
3161    #[test]
3162    fn test_new_invalid_quote_token() -> eyre::Result<()> {
3163        let mut storage = HashMapStorageProvider::new(1);
3164        let admin = Address::random();
3165
3166        StorageCtx::enter(&mut storage, || {
3167            let currency: String = thread_rng()
3168                .sample_iter(&Alphanumeric)
3169                .take(31)
3170                .map(char::from)
3171                .collect();
3172
3173            let token = TIP20Setup::create("Token", "T", admin)
3174                .currency(&currency)
3175                .apply()?;
3176
3177            // Try to create a new USD token with the arbitrary token as the quote token, this should fail
3178            TIP20Setup::create("USD Token", "USDT", admin)
3179                .currency(USD_CURRENCY)
3180                .quote_token(token.address)
3181                .expect_tip20_err(TIP20Error::invalid_quote_token());
3182
3183            Ok(())
3184        })
3185    }
3186
3187    #[test]
3188    fn test_new_valid_quote_token() -> eyre::Result<()> {
3189        let mut storage = HashMapStorageProvider::new(1);
3190        let admin = Address::random();
3191
3192        StorageCtx::enter(&mut storage, || {
3193            let usd_token1 = TIP20Setup::create("USD Token", "USDT", admin).apply()?;
3194
3195            // USD token with USD token as quote
3196            let _usd_token2 = TIP20Setup::create("USD Token", "USDT", admin)
3197                .quote_token(usd_token1.address)
3198                .apply()?;
3199
3200            // Create non USD token
3201            let currency_1: String = thread_rng()
3202                .sample_iter(&Alphanumeric)
3203                .take(31)
3204                .map(char::from)
3205                .collect();
3206
3207            let token_1 = TIP20Setup::create("USD Token", "USDT", admin)
3208                .currency(currency_1)
3209                .apply()?;
3210
3211            // Create a non USD token with non USD quote token
3212            let currency_2: String = thread_rng()
3213                .sample_iter(&Alphanumeric)
3214                .take(31)
3215                .map(char::from)
3216                .collect();
3217
3218            let _token_2 = TIP20Setup::create("USD Token", "USDT", admin)
3219                .currency(currency_2)
3220                .quote_token(token_1.address)
3221                .apply()?;
3222
3223            Ok(())
3224        })
3225    }
3226
3227    #[test]
3228    fn test_update_quote_token_invalid_token() -> eyre::Result<()> {
3229        let mut storage = HashMapStorageProvider::new(1);
3230        let admin = Address::random();
3231
3232        StorageCtx::enter(&mut storage, || {
3233            let _path_usd = TIP20Setup::path_usd(admin).apply()?;
3234
3235            let currency: String = thread_rng()
3236                .sample_iter(&Alphanumeric)
3237                .take(31)
3238                .map(char::from)
3239                .collect();
3240
3241            let token_1 = TIP20Setup::create("Token 1", "TK1", admin)
3242                .currency(&currency)
3243                .apply()?;
3244
3245            // Create a new USD token
3246            let mut usd_token = TIP20Setup::create("USD Token", "USDT", admin).apply()?;
3247
3248            // Try to update the USD token's quote token to the arbitrary currency token, this should fail
3249            let result = usd_token.set_next_quote_token(
3250                admin,
3251                ITIP20::setNextQuoteTokenCall {
3252                    newQuoteToken: token_1.address,
3253                },
3254            );
3255
3256            assert!(result.is_err_and(
3257                |err| err == TempoPrecompileError::TIP20(TIP20Error::invalid_quote_token())
3258            ));
3259
3260            Ok(())
3261        })
3262    }
3263
3264    #[test]
3265    fn test_is_tip20_prefix() -> eyre::Result<()> {
3266        let mut storage = HashMapStorageProvider::new(1);
3267        let sender = Address::random();
3268
3269        StorageCtx::enter(&mut storage, || {
3270            let _path_usd = TIP20Setup::path_usd(sender).apply()?;
3271
3272            let created_tip20 = TIP20Factory::new().create_token(
3273                sender,
3274                createTokenCall {
3275                    name: "Test Token".to_string(),
3276                    symbol: "TEST".to_string(),
3277                    currency: "USD".to_string(),
3278                    quoteToken: crate::PATH_USD_ADDRESS,
3279                    admin: sender,
3280                    salt: B256::random(),
3281                },
3282            )?;
3283            let non_tip20 = Address::random();
3284
3285            assert!(PATH_USD_ADDRESS.is_tip20());
3286            assert!(created_tip20.is_tip20());
3287            assert!(!non_tip20.is_tip20());
3288            Ok(())
3289        })
3290    }
3291
3292    #[test]
3293    fn test_initialize_supply_cap() -> eyre::Result<()> {
3294        let mut storage = HashMapStorageProvider::new(1);
3295        let admin = Address::random();
3296
3297        StorageCtx::enter(&mut storage, || {
3298            let token = TIP20Setup::create("Token", "TKN", admin).apply()?;
3299
3300            let supply_cap = token.supply_cap()?;
3301            assert_eq!(supply_cap, U256::from(u128::MAX));
3302
3303            Ok(())
3304        })
3305    }
3306
3307    #[test]
3308    fn test_unable_to_burn_blocked_from_protected_address() -> eyre::Result<()> {
3309        let admin = Address::random();
3310        let burner = Address::random();
3311        let amount = (U256::random() % U256::from(u128::MAX)) / U256::from(8);
3312        let burn_amount = amount / U256::from(2);
3313
3314        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T6);
3315        StorageCtx::enter(&mut storage, || {
3316            let mut token = TIP20Setup::create("Token", "TKN", admin)
3317                .with_issuer(admin)
3318                .with_role(burner, *BURN_BLOCKED_ROLE)
3319                .with_mint(TIP_FEE_MANAGER_ADDRESS, amount)
3320                .with_mint(STABLECOIN_DEX_ADDRESS, amount)
3321                .with_mint(TIP20_CHANNEL_RESERVE_ADDRESS, amount)
3322                .apply()?;
3323
3324            for protected in [
3325                token.address,
3326                TIP_FEE_MANAGER_ADDRESS,
3327                STABLECOIN_DEX_ADDRESS,
3328                RECEIVE_POLICY_GUARD_ADDRESS,
3329                TIP20_CHANNEL_RESERVE_ADDRESS,
3330            ] {
3331                let result = token.burn_blocked(burner, protected, burn_amount, true);
3332                assert_eq!(result.unwrap_err(), TIP20Error::protected_address().into());
3333            }
3334
3335            for minted in [TIP_FEE_MANAGER_ADDRESS, STABLECOIN_DEX_ADDRESS] {
3336                let balance = token.balance_of(ITIP20::balanceOfCall { account: minted })?;
3337                assert_eq!(balance, amount);
3338            }
3339
3340            Ok::<_, TempoPrecompileError>(())
3341        })?;
3342
3343        // Pre-T6: RECEIVE_POLICY_GUARD_ADDRESS is not yet in PROTECTED, so burn_blocked
3344        // actually burns from it (REJECT_ALL satisfies the sender-policy gate).
3345        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5);
3346        StorageCtx::enter(&mut storage, || {
3347            let mut token = TIP20Setup::create("Token", "TKN", admin)
3348                .with_issuer(admin)
3349                .with_role(burner, *BURN_BLOCKED_ROLE)
3350                .apply()?;
3351
3352            for protected in [
3353                token.address,
3354                TIP_FEE_MANAGER_ADDRESS,
3355                STABLECOIN_DEX_ADDRESS,
3356                TIP20_CHANNEL_RESERVE_ADDRESS,
3357            ] {
3358                let result = token.burn_blocked(burner, protected, burn_amount, true);
3359                assert_eq!(result.unwrap_err(), TIP20Error::protected_address().into());
3360            }
3361
3362            token.change_transfer_policy_id(
3363                admin,
3364                ITIP20::changeTransferPolicyIdCall {
3365                    newPolicyId: REJECT_ALL_POLICY_ID,
3366                },
3367            )?;
3368            token.set_balance(RECEIVE_POLICY_GUARD_ADDRESS, amount)?;
3369            token.set_total_supply(token.total_supply()? + amount)?;
3370
3371            token.burn_blocked(burner, RECEIVE_POLICY_GUARD_ADDRESS, burn_amount, true)?;
3372
3373            let balance = token.balance_of(ITIP20::balanceOfCall {
3374                account: RECEIVE_POLICY_GUARD_ADDRESS,
3375            })?;
3376            assert_eq!(balance, amount - burn_amount);
3377
3378            Ok::<_, TempoPrecompileError>(())
3379        })?;
3380
3381        // Pre-T5: TIP20_CHANNEL_RESERVE_ADDRESS and TIP20 address are not yet in PROTECTED,
3382        // so burn_blocked actually burns from it (REJECT_ALL satisfies the sender-policy gate).
3383        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T4);
3384        StorageCtx::enter(&mut storage, || {
3385            let mut token = TIP20Setup::create("Token", "TKN", admin)
3386                .with_issuer(admin)
3387                .with_role(burner, *BURN_BLOCKED_ROLE)
3388                .with_mint(TIP20_CHANNEL_RESERVE_ADDRESS, amount)
3389                .apply()?;
3390
3391            token.change_transfer_policy_id(
3392                admin,
3393                ITIP20::changeTransferPolicyIdCall {
3394                    newPolicyId: REJECT_ALL_POLICY_ID,
3395                },
3396            )?;
3397
3398            // simulate a mint to TIP20 address.
3399            token.set_balance(token.address, amount)?;
3400            token.set_total_supply(token.total_supply()? + amount)?;
3401
3402            for unprotected in [TIP20_CHANNEL_RESERVE_ADDRESS, token.address] {
3403                token.burn_blocked(burner, unprotected, burn_amount, true)?;
3404
3405                let balance = token.balance_of(ITIP20::balanceOfCall {
3406                    account: unprotected,
3407                })?;
3408                assert_eq!(balance, amount - burn_amount);
3409            }
3410
3411            Ok::<_, TempoPrecompileError>(())
3412        })?;
3413
3414        Ok(())
3415    }
3416
3417    #[test]
3418    fn test_initialize_usd_token() -> eyre::Result<()> {
3419        let mut storage = HashMapStorageProvider::new(1);
3420        let admin = Address::random();
3421
3422        StorageCtx::enter(&mut storage, || {
3423            // USD token with zero quote token should succeed
3424            let _token = TIP20Setup::create("TestToken", "TEST", admin).apply()?;
3425
3426            // Non-USD token with zero quote token should succeed
3427            let eur_token = TIP20Setup::create("EuroToken", "EUR", admin)
3428                .currency("EUR")
3429                .apply()?;
3430
3431            // USD token with non-USD quote token should fail
3432            TIP20Setup::create("USDToken", "USD", admin)
3433                .quote_token(eur_token.address)
3434                .expect_tip20_err(TIP20Error::invalid_quote_token());
3435
3436            Ok(())
3437        })
3438    }
3439
3440    #[test]
3441    fn test_change_transfer_policy_id_invalid_policy() -> eyre::Result<()> {
3442        let mut storage = HashMapStorageProvider::new(1);
3443        let admin = Address::random();
3444
3445        StorageCtx::enter(&mut storage, || {
3446            let mut token = TIP20Setup::path_usd(admin).apply()?;
3447
3448            // Initialize the TIP403 registry
3449            let mut registry = TIP403Registry::new();
3450            registry.initialize()?;
3451
3452            // Try to change to a non-existent policy ID (should fail)
3453            let invalid_policy_id = 999u64;
3454            let result = token.change_transfer_policy_id(
3455                admin,
3456                ITIP20::changeTransferPolicyIdCall {
3457                    newPolicyId: invalid_policy_id,
3458                },
3459            );
3460
3461            assert!(matches!(
3462                result.unwrap_err(),
3463                TempoPrecompileError::TIP20(TIP20Error::InvalidTransferPolicyId(_))
3464            ));
3465
3466            Ok(())
3467        })
3468    }
3469
3470    #[test]
3471    fn test_transfer_invalid_recipient() -> eyre::Result<()> {
3472        let mut storage = HashMapStorageProvider::new(1);
3473        let admin = Address::random();
3474        let bob = Address::random();
3475        let amount = U256::random() % U256::from(u128::MAX);
3476
3477        StorageCtx::enter(&mut storage, || {
3478            let mut token = TIP20Setup::create("Token", "TKN", admin)
3479                .with_issuer(admin)
3480                .with_mint(admin, amount)
3481                .with_approval(admin, bob, amount)
3482                .apply()?;
3483
3484            let result = token.transfer(
3485                admin,
3486                ITIP20::transferCall {
3487                    to: Address::ZERO,
3488                    amount,
3489                },
3490            );
3491            assert!(result.is_err_and(|err| err.to_string().contains("InvalidRecipient")));
3492
3493            let result = token.transfer_from(
3494                bob,
3495                ITIP20::transferFromCall {
3496                    from: admin,
3497                    to: Address::ZERO,
3498                    amount,
3499                },
3500            );
3501            assert!(result.is_err_and(|err| err.to_string().contains("InvalidRecipient")));
3502
3503            Ok(())
3504        })
3505    }
3506
3507    #[test]
3508    fn test_change_transfer_policy_id() -> eyre::Result<()> {
3509        let mut storage = HashMapStorageProvider::new(1);
3510        let admin = Address::random();
3511
3512        StorageCtx::enter(&mut storage, || {
3513            let mut token = TIP20Setup::path_usd(admin).apply()?;
3514
3515            // Initialize the TIP403 registry
3516            let mut registry = TIP403Registry::new();
3517            registry.initialize()?;
3518
3519            // Test special policies 0 and 1 (should always work)
3520            token.change_transfer_policy_id(
3521                admin,
3522                ITIP20::changeTransferPolicyIdCall { newPolicyId: 0 },
3523            )?;
3524            assert_eq!(token.transfer_policy_id()?, 0);
3525
3526            token.change_transfer_policy_id(
3527                admin,
3528                ITIP20::changeTransferPolicyIdCall { newPolicyId: 1 },
3529            )?;
3530            assert_eq!(token.transfer_policy_id()?, 1);
3531
3532            // Test random invalid policy IDs should fail
3533            let mut rng = rand_08::thread_rng();
3534            for _ in 0..20 {
3535                let invalid_policy_id = rng.gen_range(2..u64::MAX);
3536                let result = token.change_transfer_policy_id(
3537                    admin,
3538                    ITIP20::changeTransferPolicyIdCall {
3539                        newPolicyId: invalid_policy_id,
3540                    },
3541                );
3542                assert!(matches!(
3543                    result.unwrap_err(),
3544                    TempoPrecompileError::TIP20(TIP20Error::InvalidTransferPolicyId(_))
3545                ));
3546            }
3547
3548            // Create some valid policies
3549            let mut valid_policy_ids = Vec::new();
3550            for i in 0..10 {
3551                let policy_id = registry.create_policy(
3552                    admin,
3553                    ITIP403Registry::createPolicyCall {
3554                        admin,
3555                        policyType: if i % 2 == 0 {
3556                            ITIP403Registry::PolicyType::WHITELIST
3557                        } else {
3558                            ITIP403Registry::PolicyType::BLACKLIST
3559                        },
3560                    },
3561                )?;
3562                valid_policy_ids.push(policy_id);
3563            }
3564
3565            // Test that all created policies can be set
3566            for policy_id in valid_policy_ids {
3567                let result = token.change_transfer_policy_id(
3568                    admin,
3569                    ITIP20::changeTransferPolicyIdCall {
3570                        newPolicyId: policy_id,
3571                    },
3572                );
3573                assert!(result.is_ok());
3574                assert_eq!(token.transfer_policy_id()?, policy_id);
3575            }
3576
3577            Ok(())
3578        })
3579    }
3580
3581    #[test]
3582    fn test_is_transfer_authorized() -> eyre::Result<()> {
3583        use tempo_chainspec::hardfork::TempoHardfork;
3584
3585        let admin = Address::random();
3586        let sender = Address::random();
3587        let recipient = Address::random();
3588
3589        for hardfork in [TempoHardfork::T0, TempoHardfork::T1] {
3590            let mut storage = HashMapStorageProvider::new_with_spec(1, hardfork);
3591
3592            StorageCtx::enter(&mut storage, || {
3593                let token = TIP20Setup::path_usd(admin).apply()?;
3594
3595                // Initialize TIP403 registry and create a whitelist policy
3596                let mut registry = TIP403Registry::new();
3597                registry.initialize()?;
3598
3599                let policy_id = registry.create_policy(
3600                    admin,
3601                    ITIP403Registry::createPolicyCall {
3602                        admin,
3603                        policyType: ITIP403Registry::PolicyType::WHITELIST,
3604                    },
3605                )?;
3606
3607                // Assign token to use this policy
3608                let mut token = token;
3609                token.change_transfer_policy_id(
3610                    admin,
3611                    ITIP20::changeTransferPolicyIdCall {
3612                        newPolicyId: policy_id,
3613                    },
3614                )?;
3615
3616                // Sender not whitelisted, recipient whitelisted
3617                registry.modify_policy_whitelist(
3618                    admin,
3619                    ITIP403Registry::modifyPolicyWhitelistCall {
3620                        policyId: policy_id,
3621                        account: recipient,
3622                        allowed: true,
3623                    },
3624                )?;
3625                assert!(!token.is_transfer_authorized(sender, recipient)?);
3626
3627                // Sender whitelisted, recipient not whitelisted
3628                registry.modify_policy_whitelist(
3629                    admin,
3630                    ITIP403Registry::modifyPolicyWhitelistCall {
3631                        policyId: policy_id,
3632                        account: sender,
3633                        allowed: true,
3634                    },
3635                )?;
3636                registry.modify_policy_whitelist(
3637                    admin,
3638                    ITIP403Registry::modifyPolicyWhitelistCall {
3639                        policyId: policy_id,
3640                        account: recipient,
3641                        allowed: false,
3642                    },
3643                )?;
3644                assert!(!token.is_transfer_authorized(sender, recipient)?);
3645
3646                // Both whitelisted
3647                registry.modify_policy_whitelist(
3648                    admin,
3649                    ITIP403Registry::modifyPolicyWhitelistCall {
3650                        policyId: policy_id,
3651                        account: recipient,
3652                        allowed: true,
3653                    },
3654                )?;
3655                assert!(token.is_transfer_authorized(sender, recipient)?);
3656
3657                Ok::<_, TempoPrecompileError>(())
3658            })?;
3659        }
3660
3661        Ok(())
3662    }
3663
3664    #[test]
3665    fn test_set_next_quote_token_rejects_path_usd() -> eyre::Result<()> {
3666        let mut storage = HashMapStorageProvider::new(1);
3667        let admin = Address::random();
3668
3669        StorageCtx::enter(&mut storage, || {
3670            let mut path_usd = TIP20Setup::path_usd(admin).apply()?;
3671            let other_token = TIP20Setup::create("Test", "T", admin).apply()?;
3672
3673            // pathUSD cannot update its quote token
3674            let result = path_usd.set_next_quote_token(
3675                admin,
3676                ITIP20::setNextQuoteTokenCall {
3677                    newQuoteToken: other_token.address,
3678                },
3679            );
3680            assert!(matches!(
3681                result,
3682                Err(TempoPrecompileError::TIP20(TIP20Error::InvalidQuoteToken(
3683                    _
3684                )))
3685            ));
3686
3687            Ok(())
3688        })
3689    }
3690
3691    #[test]
3692    fn test_non_path_usd_cycle_detection() -> eyre::Result<()> {
3693        let mut storage = HashMapStorageProvider::new(1);
3694        let admin = Address::random();
3695
3696        StorageCtx::enter(&mut storage, || {
3697            TIP20Setup::path_usd(admin).apply()?;
3698
3699            let mut token_b = TIP20Setup::create("TokenB", "TKNB", admin).apply()?;
3700            let token_a = TIP20Setup::create("TokenA", "TKNA", admin)
3701                .quote_token(token_b.address)
3702                .apply()?;
3703
3704            // Verify chain where token_a -> token_b -> PATH_USD
3705            assert_eq!(token_a.quote_token()?, token_b.address);
3706            assert_eq!(token_b.quote_token()?, PATH_USD_ADDRESS);
3707
3708            // Try to create cycle where token_b -> token_a
3709            token_b.set_next_quote_token(
3710                admin,
3711                ITIP20::setNextQuoteTokenCall {
3712                    newQuoteToken: token_a.address,
3713                },
3714            )?;
3715
3716            let result =
3717                token_b.complete_quote_token_update(admin, ITIP20::completeQuoteTokenUpdateCall {});
3718
3719            assert!(matches!(
3720                result,
3721                Err(TempoPrecompileError::TIP20(TIP20Error::InvalidQuoteToken(
3722                    _
3723                )))
3724            ));
3725
3726            // assert that quote tokens are unchanged
3727            assert_eq!(token_a.quote_token()?, token_b.address);
3728            assert_eq!(token_b.quote_token()?, PATH_USD_ADDRESS);
3729
3730            Ok(())
3731        })
3732    }
3733
3734    // ═══════════════════════════════════════════════════════════
3735    //  TIP-1022 Virtual Address Tests
3736    // ═══════════════════════════════════════════════════════════
3737
3738    #[test]
3739    fn test_mint_to_virtual_address_credits_master() -> eyre::Result<()> {
3740        let amount = U256::from(1000);
3741
3742        for hardfork in [TempoHardfork::T2, TempoHardfork::T3] {
3743            let mut storage = HashMapStorageProvider::new_with_spec(1, hardfork);
3744            let admin = Address::random();
3745
3746            StorageCtx::enter(&mut storage, || {
3747                let mut registry = AddressRegistry::new();
3748                let (_, virtual_addr) = register_virtual_master(&mut registry)?;
3749                let credited = if hardfork.is_t3() {
3750                    VIRTUAL_MASTER
3751                } else {
3752                    virtual_addr
3753                };
3754
3755                let mut token = TIP20Setup::create("Test", "TST", admin)
3756                    .with_issuer(admin)
3757                    .clear_events()
3758                    .apply()?;
3759
3760                // mint
3761                token.mint(
3762                    admin,
3763                    ITIP20::mintCall {
3764                        to: virtual_addr,
3765                        amount,
3766                    },
3767                )?;
3768
3769                if hardfork.is_t3() {
3770                    // T3: master is credited, virtual balance stays zero
3771                    assert_eq!(token.get_balance(VIRTUAL_MASTER)?, amount);
3772                    assert_eq!(token.get_balance(virtual_addr)?, U256::ZERO);
3773                    assert_eq!(token.total_supply()?, amount);
3774
3775                    // Events: Transfer(0→virtual) + Mint(virtual) + Transfer(virtual→master)
3776                    token.assert_emitted_events(vec![
3777                        TIP20Event::transfer(Address::ZERO, virtual_addr, amount),
3778                        TIP20Event::mint(virtual_addr, amount),
3779                        TIP20Event::transfer(virtual_addr, VIRTUAL_MASTER, amount),
3780                    ]);
3781                } else {
3782                    // Pre-T3: virtual address treated as literal, balance goes there
3783                    assert_eq!(token.get_balance(virtual_addr)?, amount);
3784                    assert_eq!(token.get_balance(VIRTUAL_MASTER)?, U256::ZERO);
3785                }
3786
3787                // mintWithMemo: same resolution behavior
3788                let pre = token.get_balance(credited)?;
3789                token.mint_with_memo(
3790                    admin,
3791                    ITIP20::mintWithMemoCall {
3792                        to: virtual_addr,
3793                        amount,
3794                        memo: FixedBytes::ZERO,
3795                    },
3796                )?;
3797                assert_eq!(token.get_balance(credited)? - pre, amount);
3798
3799                Ok::<_, TempoPrecompileError>(())
3800            })?;
3801        }
3802        Ok(())
3803    }
3804
3805    #[test]
3806    fn test_transfer_to_virtual_address_credits_master() -> eyre::Result<()> {
3807        let amount = U256::from(500);
3808
3809        for hardfork in [TempoHardfork::T2, TempoHardfork::T3] {
3810            let mut storage = HashMapStorageProvider::new_with_spec(1, hardfork);
3811            let admin = Address::random();
3812            let sender = Address::random();
3813
3814            StorageCtx::enter(&mut storage, || {
3815                let mut registry = AddressRegistry::new();
3816                let (_, virtual_addr) = register_virtual_master(&mut registry)?;
3817                let credited = if hardfork.is_t3() {
3818                    VIRTUAL_MASTER
3819                } else {
3820                    virtual_addr
3821                };
3822
3823                let mut token = TIP20Setup::create("Test", "TST", admin)
3824                    .with_issuer(admin)
3825                    .with_mint(sender, amount * U256::from(2))
3826                    .clear_events()
3827                    .apply()?;
3828
3829                // transfer
3830                token.transfer(
3831                    sender,
3832                    ITIP20::transferCall {
3833                        to: virtual_addr,
3834                        amount,
3835                    },
3836                )?;
3837
3838                if hardfork.is_t3() {
3839                    assert_eq!(token.get_balance(VIRTUAL_MASTER)?, amount);
3840                    assert_eq!(token.get_balance(virtual_addr)?, U256::ZERO);
3841
3842                    // Events: Transfer(sender→virtual) + Transfer(virtual→master)
3843                    token.assert_emitted_events(vec![
3844                        TIP20Event::transfer(sender, virtual_addr, amount),
3845                        TIP20Event::transfer(virtual_addr, VIRTUAL_MASTER, amount),
3846                    ]);
3847                } else {
3848                    assert_eq!(token.get_balance(virtual_addr)?, amount);
3849                    assert_eq!(token.get_balance(VIRTUAL_MASTER)?, U256::ZERO);
3850                }
3851
3852                // transferWithMemo: same resolution behavior
3853                let pre = token.get_balance(credited)?;
3854                token.transfer_with_memo(
3855                    sender,
3856                    ITIP20::transferWithMemoCall {
3857                        to: virtual_addr,
3858                        amount,
3859                        memo: FixedBytes::ZERO,
3860                    },
3861                )?;
3862                assert_eq!(token.get_balance(credited)? - pre, amount);
3863
3864                Ok::<_, TempoPrecompileError>(())
3865            })?;
3866        }
3867        Ok(())
3868    }
3869
3870    #[test]
3871    fn test_transfer_from_to_virtual_address_credits_master() -> eyre::Result<()> {
3872        let amount = U256::from(300);
3873
3874        for hardfork in [TempoHardfork::T2, TempoHardfork::T3] {
3875            let mut storage = HashMapStorageProvider::new_with_spec(1, hardfork);
3876            let admin = Address::random();
3877            let owner = Address::random();
3878            let spender = Address::random();
3879
3880            StorageCtx::enter(&mut storage, || {
3881                let mut registry = AddressRegistry::new();
3882                let (_, virtual_addr) = register_virtual_master(&mut registry)?;
3883                let credited = if hardfork.is_t3() {
3884                    VIRTUAL_MASTER
3885                } else {
3886                    virtual_addr
3887                };
3888
3889                let total = amount * U256::from(2);
3890                let mut token = TIP20Setup::create("Test", "TST", admin)
3891                    .with_issuer(admin)
3892                    .with_mint(owner, total)
3893                    .with_approval(owner, spender, total)
3894                    .clear_events()
3895                    .apply()?;
3896
3897                // transferFrom
3898                token.transfer_from(
3899                    spender,
3900                    ITIP20::transferFromCall {
3901                        from: owner,
3902                        to: virtual_addr,
3903                        amount,
3904                    },
3905                )?;
3906
3907                if hardfork.is_t3() {
3908                    assert_eq!(token.get_balance(VIRTUAL_MASTER)?, amount);
3909                    assert_eq!(token.get_balance(virtual_addr)?, U256::ZERO);
3910                } else {
3911                    assert_eq!(token.get_balance(virtual_addr)?, amount);
3912                    assert_eq!(token.get_balance(VIRTUAL_MASTER)?, U256::ZERO);
3913                }
3914
3915                // transferFromWithMemo: same resolution behavior
3916                let pre = token.get_balance(credited)?;
3917                token.transfer_from_with_memo(
3918                    spender,
3919                    ITIP20::transferFromWithMemoCall {
3920                        from: owner,
3921                        to: virtual_addr,
3922                        amount,
3923                        memo: FixedBytes::ZERO,
3924                    },
3925                )?;
3926                assert_eq!(token.get_balance(credited)? - pre, amount);
3927
3928                Ok::<_, TempoPrecompileError>(())
3929            })?;
3930        }
3931        Ok(())
3932    }
3933
3934    #[test]
3935    #[rustfmt::skip]
3936    fn test_unregistered_virtual_reverts_on_t3() -> eyre::Result<()> {
3937        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T3);
3938        let admin = Address::random();
3939        let sender = Address::random();
3940        let spender = Address::random();
3941        let to = Address::new_virtual(MasterId::ZERO, UserTag::ZERO);
3942        let amount = U256::from(100);
3943        let memo = FixedBytes::ZERO;
3944
3945        StorageCtx::enter(&mut storage, || {
3946            let mut token = TIP20Setup::create("Test", "TST", admin)
3947                .with_issuer(admin)
3948                .with_mint(sender, amount)
3949                .with_approval(sender, spender, amount)
3950                .apply()?;
3951
3952            // All 6 entrypoints should revert for an unregistered virtual address
3953            assert!(token.mint(admin, ITIP20::mintCall { to, amount }).is_err());
3954            assert!(token.mint_with_memo(admin, ITIP20::mintWithMemoCall { to, amount, memo }).is_err());
3955            assert!(token.transfer(sender, ITIP20::transferCall { to, amount }).is_err());
3956            assert!(token.transfer_with_memo(sender, ITIP20::transferWithMemoCall { to, amount, memo }).is_err());
3957            assert!(token.transfer_from(spender, ITIP20::transferFromCall { from: sender, to, amount }).is_err());
3958            assert!(token.transfer_from_with_memo(spender, ITIP20::transferFromWithMemoCall { from: sender, to, amount, memo }).is_err());
3959
3960            Ok(())
3961        })
3962    }
3963
3964    // ═══════════════════════════════════════════════════════════
3965    //  EIP-2612 Permit Tests (TIP-1004)
3966    // ═══════════════════════════════════════════════════════════
3967
3968    mod permit_tests {
3969        use super::*;
3970        use alloy::sol_types::SolValue;
3971        use alloy_signer::SignerSync;
3972        use alloy_signer_local::PrivateKeySigner;
3973        use tempo_chainspec::hardfork::TempoHardfork;
3974
3975        const CHAIN_ID: u64 = 42;
3976
3977        /// Create a T2 storage provider for permit tests
3978        fn setup_t2_storage() -> HashMapStorageProvider {
3979            HashMapStorageProvider::new_with_spec(CHAIN_ID, TempoHardfork::T2)
3980        }
3981
3982        /// Helper to create a valid permit signature
3983        fn sign_permit(
3984            signer: &PrivateKeySigner,
3985            token_name: &str,
3986            token_address: Address,
3987            spender: Address,
3988            value: U256,
3989            nonce: U256,
3990            deadline: U256,
3991        ) -> (u8, B256, B256) {
3992            let domain_separator = compute_domain_separator(token_name, token_address);
3993            let struct_hash = keccak256(
3994                (
3995                    *PERMIT_TYPEHASH,
3996                    signer.address(),
3997                    spender,
3998                    value,
3999                    nonce,
4000                    deadline,
4001                )
4002                    .abi_encode(),
4003            );
4004            let digest = keccak256(
4005                [
4006                    &[0x19, 0x01],
4007                    domain_separator.as_slice(),
4008                    struct_hash.as_slice(),
4009                ]
4010                .concat(),
4011            );
4012
4013            let sig = signer.sign_hash_sync(&digest).unwrap();
4014            let v = u8::from(sig.v()) + 27;
4015            let r: B256 = sig.r().into();
4016            let s: B256 = sig.s().into();
4017            (v, r, s)
4018        }
4019
4020        fn compute_domain_separator(token_name: &str, token_address: Address) -> B256 {
4021            keccak256(
4022                (
4023                    *EIP712_DOMAIN_TYPEHASH,
4024                    keccak256(token_name.as_bytes()),
4025                    *VERSION_HASH,
4026                    U256::from(CHAIN_ID),
4027                    token_address,
4028                )
4029                    .abi_encode(),
4030            )
4031        }
4032
4033        struct PermitFixture {
4034            storage: HashMapStorageProvider,
4035            admin: Address,
4036            signer: PrivateKeySigner,
4037            spender: Address,
4038        }
4039
4040        impl PermitFixture {
4041            fn new() -> Self {
4042                Self {
4043                    storage: setup_t2_storage(),
4044                    admin: Address::random(),
4045                    signer: PrivateKeySigner::random(),
4046                    spender: Address::random(),
4047                }
4048            }
4049        }
4050
4051        fn make_permit_call(
4052            signer: &PrivateKeySigner,
4053            spender: Address,
4054            token_address: Address,
4055            value: U256,
4056            nonce: U256,
4057            deadline: U256,
4058        ) -> ITIP20::permitCall {
4059            let (v, r, s) = sign_permit(
4060                signer,
4061                "Test",
4062                token_address,
4063                spender,
4064                value,
4065                nonce,
4066                deadline,
4067            );
4068            ITIP20::permitCall {
4069                owner: signer.address(),
4070                spender,
4071                value,
4072                deadline,
4073                v,
4074                r,
4075                s,
4076            }
4077        }
4078
4079        #[test]
4080        fn test_permit_happy_path() -> eyre::Result<()> {
4081            let PermitFixture {
4082                mut storage,
4083                admin,
4084                ref signer,
4085                spender,
4086            } = PermitFixture::new();
4087            let owner = signer.address();
4088            let value = U256::from(1000);
4089
4090            StorageCtx::enter(&mut storage, || {
4091                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
4092                let call =
4093                    make_permit_call(signer, spender, token.address, value, U256::ZERO, U256::MAX);
4094                token.permit(call)?;
4095
4096                // Verify allowance was set
4097                let allowance = token.allowance(ITIP20::allowanceCall { owner, spender })?;
4098                assert_eq!(allowance, value);
4099
4100                // Verify nonce was incremented
4101                let nonce = token.nonces(ITIP20::noncesCall { owner })?;
4102                assert_eq!(nonce, U256::from(1));
4103
4104                Ok(())
4105            })
4106        }
4107
4108        #[test]
4109        fn test_permit_expired() -> eyre::Result<()> {
4110            let PermitFixture {
4111                mut storage,
4112                admin,
4113                ref signer,
4114                spender,
4115            } = PermitFixture::new();
4116            let value = U256::from(1000);
4117            // Deadline in the past
4118            let deadline = U256::ZERO;
4119
4120            StorageCtx::enter(&mut storage, || {
4121                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
4122                let call =
4123                    make_permit_call(signer, spender, token.address, value, U256::ZERO, deadline);
4124
4125                let result = token.permit(call);
4126
4127                assert!(matches!(
4128                    result,
4129                    Err(TempoPrecompileError::TIP20(TIP20Error::PermitExpired(_)))
4130                ));
4131
4132                Ok(())
4133            })
4134        }
4135
4136        #[test]
4137        fn test_permit_invalid_signature() -> eyre::Result<()> {
4138            let mut storage = setup_t2_storage();
4139            let admin = Address::random();
4140            let owner = Address::random();
4141            let spender = Address::random();
4142            let value = U256::from(1000);
4143            let deadline = U256::MAX;
4144
4145            StorageCtx::enter(&mut storage, || {
4146                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
4147
4148                // Use garbage signature bytes
4149                let result = token.permit(ITIP20::permitCall {
4150                    owner,
4151                    spender,
4152                    value,
4153                    deadline,
4154                    v: 27,
4155                    r: B256::ZERO,
4156                    s: B256::ZERO,
4157                });
4158
4159                assert!(matches!(
4160                    result,
4161                    Err(TempoPrecompileError::TIP20(TIP20Error::InvalidSignature(_)))
4162                ));
4163
4164                Ok(())
4165            })
4166        }
4167
4168        #[test]
4169        fn test_permit_wrong_signer() -> eyre::Result<()> {
4170            let PermitFixture {
4171                mut storage,
4172                admin,
4173                ref signer,
4174                spender,
4175            } = PermitFixture::new();
4176            let wrong_owner = Address::random(); // Not the signer's address
4177            let value = U256::from(1000);
4178            let deadline = U256::MAX;
4179
4180            StorageCtx::enter(&mut storage, || {
4181                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
4182
4183                // Sign with signer but claim wrong_owner
4184                let (v, r, s) = sign_permit(
4185                    signer,
4186                    "Test",
4187                    token.address,
4188                    spender,
4189                    value,
4190                    U256::ZERO,
4191                    deadline,
4192                );
4193
4194                let result = token.permit(ITIP20::permitCall {
4195                    owner: wrong_owner, // Different from signer
4196                    spender,
4197                    value,
4198                    deadline,
4199                    v,
4200                    r,
4201                    s,
4202                });
4203
4204                assert!(matches!(
4205                    result,
4206                    Err(TempoPrecompileError::TIP20(TIP20Error::InvalidSignature(_)))
4207                ));
4208
4209                Ok(())
4210            })
4211        }
4212
4213        #[test]
4214        fn test_permit_replay_protection() -> eyre::Result<()> {
4215            let PermitFixture {
4216                mut storage,
4217                admin,
4218                ref signer,
4219                spender,
4220            } = PermitFixture::new();
4221            let value = U256::from(1000);
4222
4223            StorageCtx::enter(&mut storage, || {
4224                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
4225                let call =
4226                    make_permit_call(signer, spender, token.address, value, U256::ZERO, U256::MAX);
4227
4228                // First use should succeed
4229                token.permit(call.clone())?;
4230
4231                // Second use of same signature should fail (nonce incremented)
4232                let result = token.permit(call);
4233
4234                assert!(matches!(
4235                    result,
4236                    Err(TempoPrecompileError::TIP20(TIP20Error::InvalidSignature(_)))
4237                ));
4238
4239                Ok(())
4240            })
4241        }
4242
4243        #[test]
4244        fn test_permit_nonce_tracking() -> eyre::Result<()> {
4245            let PermitFixture {
4246                mut storage,
4247                admin,
4248                ref signer,
4249                spender,
4250            } = PermitFixture::new();
4251            let owner = signer.address();
4252
4253            StorageCtx::enter(&mut storage, || {
4254                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
4255
4256                // Initial nonce should be 0
4257                assert_eq!(token.nonces(ITIP20::noncesCall { owner })?, U256::ZERO);
4258
4259                // Do 3 permits, each with correct nonce
4260                for i in 0u64..3 {
4261                    let nonce = U256::from(i);
4262                    let value = U256::from(100 * (i + 1));
4263                    let call =
4264                        make_permit_call(signer, spender, token.address, value, nonce, U256::MAX);
4265                    token.permit(call)?;
4266
4267                    assert_eq!(
4268                        token.nonces(ITIP20::noncesCall { owner })?,
4269                        U256::from(i + 1)
4270                    );
4271                }
4272
4273                Ok(())
4274            })
4275        }
4276
4277        #[test]
4278        fn test_permit_works_when_paused() -> eyre::Result<()> {
4279            let PermitFixture {
4280                mut storage,
4281                admin,
4282                ref signer,
4283                spender,
4284            } = PermitFixture::new();
4285            let owner = signer.address();
4286            let value = U256::from(1000);
4287
4288            StorageCtx::enter(&mut storage, || {
4289                let mut token = TIP20Setup::create("Test", "TST", admin)
4290                    .with_role(admin, *PAUSE_ROLE)
4291                    .apply()?;
4292
4293                // Pause the token
4294                token.pause(admin, ITIP20::pauseCall {})?;
4295                assert!(token.paused()?);
4296
4297                let call =
4298                    make_permit_call(signer, spender, token.address, value, U256::ZERO, U256::MAX);
4299
4300                // Permit should work even when paused
4301                token.permit(call)?;
4302
4303                assert_eq!(
4304                    token.allowance(ITIP20::allowanceCall { owner, spender })?,
4305                    value
4306                );
4307
4308                Ok(())
4309            })
4310        }
4311
4312        #[test]
4313        fn test_permit_domain_separator() -> eyre::Result<()> {
4314            let PermitFixture {
4315                mut storage, admin, ..
4316            } = PermitFixture::new();
4317
4318            StorageCtx::enter(&mut storage, || {
4319                let token = TIP20Setup::create("Test", "TST", admin).apply()?;
4320
4321                let ds = token.domain_separator()?;
4322                let expected = compute_domain_separator("Test", token.address);
4323                assert_eq!(ds, expected);
4324
4325                Ok(())
4326            })
4327        }
4328
4329        #[test]
4330        fn test_permit_max_allowance() -> eyre::Result<()> {
4331            let PermitFixture {
4332                mut storage,
4333                admin,
4334                ref signer,
4335                spender,
4336            } = PermitFixture::new();
4337            let owner = signer.address();
4338
4339            StorageCtx::enter(&mut storage, || {
4340                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
4341                let call = make_permit_call(
4342                    signer,
4343                    spender,
4344                    token.address,
4345                    U256::MAX,
4346                    U256::ZERO,
4347                    U256::MAX,
4348                );
4349                token.permit(call)?;
4350
4351                assert_eq!(
4352                    token.allowance(ITIP20::allowanceCall { owner, spender })?,
4353                    U256::MAX
4354                );
4355
4356                Ok(())
4357            })
4358        }
4359
4360        #[test]
4361        fn test_permit_allowance_override() -> eyre::Result<()> {
4362            let PermitFixture {
4363                mut storage,
4364                admin,
4365                ref signer,
4366                spender,
4367            } = PermitFixture::new();
4368            let owner = signer.address();
4369
4370            StorageCtx::enter(&mut storage, || {
4371                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
4372
4373                // First permit: set allowance to 1000
4374                let call = make_permit_call(
4375                    signer,
4376                    spender,
4377                    token.address,
4378                    U256::from(1000),
4379                    U256::ZERO,
4380                    U256::MAX,
4381                );
4382                token.permit(call)?;
4383                assert_eq!(
4384                    token.allowance(ITIP20::allowanceCall { owner, spender })?,
4385                    U256::from(1000)
4386                );
4387
4388                // Second permit: override to 0
4389                let call = make_permit_call(
4390                    signer,
4391                    spender,
4392                    token.address,
4393                    U256::ZERO,
4394                    U256::from(1),
4395                    U256::MAX,
4396                );
4397                token.permit(call)?;
4398                assert_eq!(
4399                    token.allowance(ITIP20::allowanceCall { owner, spender })?,
4400                    U256::ZERO
4401                );
4402
4403                Ok(())
4404            })
4405        }
4406
4407        #[test]
4408        fn test_permit_invalid_v_values() -> eyre::Result<()> {
4409            let PermitFixture {
4410                mut storage,
4411                admin,
4412                spender,
4413                ..
4414            } = PermitFixture::new();
4415
4416            StorageCtx::enter(&mut storage, || {
4417                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
4418
4419                for v in [0u8, 1] {
4420                    let result = token.permit(ITIP20::permitCall {
4421                        owner: admin,
4422                        spender,
4423                        value: U256::from(1000),
4424                        deadline: U256::MAX,
4425                        v,
4426                        r: B256::ZERO,
4427                        s: B256::ZERO,
4428                    });
4429
4430                    assert!(
4431                        matches!(
4432                            result,
4433                            Err(TempoPrecompileError::TIP20(TIP20Error::InvalidSignature(_)))
4434                        ),
4435                        "v={v} should revert with InvalidSignature"
4436                    );
4437                }
4438
4439                Ok(())
4440            })
4441        }
4442
4443        #[test]
4444        fn test_permit_zero_address_recovery_reverts() -> eyre::Result<()> {
4445            let PermitFixture {
4446                mut storage,
4447                admin,
4448                spender,
4449                ..
4450            } = PermitFixture::new();
4451
4452            StorageCtx::enter(&mut storage, || {
4453                let mut token = TIP20Setup::create("Test", "TST", admin).apply()?;
4454
4455                let result = token.permit(ITIP20::permitCall {
4456                    owner: Address::ZERO,
4457                    spender,
4458                    value: U256::from(1000),
4459                    deadline: U256::MAX,
4460                    v: 27,
4461                    r: B256::ZERO,
4462                    s: B256::ZERO,
4463                });
4464
4465                assert!(matches!(
4466                    result,
4467                    Err(TempoPrecompileError::TIP20(TIP20Error::InvalidSignature(_)))
4468                ));
4469
4470                Ok(())
4471            })
4472        }
4473
4474        #[test]
4475        fn test_permit_domain_separator_changes_with_chain_id() -> eyre::Result<()> {
4476            let PermitFixture { admin, .. } = PermitFixture::new();
4477
4478            let mut storage_a = setup_t2_storage();
4479            let mut storage_b =
4480                HashMapStorageProvider::new_with_spec(CHAIN_ID + 1, TempoHardfork::T2);
4481
4482            let ds_a = StorageCtx::enter(&mut storage_a, || {
4483                TIP20Setup::create("Test", "TST", admin)
4484                    .apply()?
4485                    .domain_separator()
4486            })?;
4487
4488            let ds_b = StorageCtx::enter(&mut storage_b, || {
4489                TIP20Setup::create("Test", "TST", admin)
4490                    .apply()?
4491                    .domain_separator()
4492            })?;
4493
4494            assert_ne!(
4495                ds_a, ds_b,
4496                "domain separator must change when chainId changes"
4497            );
4498
4499            Ok(())
4500        }
4501    }
4502
4503    #[test]
4504    fn test_mint_paused_pre_t6_short_circuits_before_policy_reads() -> eyre::Result<()> {
4505        let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T5);
4506        let (admin, amount) = (Address::random(), U256::random());
4507
4508        StorageCtx::enter(&mut storage, || {
4509            let mut token = TIP20Setup::create("Test", "TST", admin)
4510                .with_issuer(admin)
4511                .with_role(admin, *PAUSE_ROLE)
4512                .apply()?;
4513            token.pause(admin, ITIP20::pauseCall {})?;
4514
4515            token.storage.reset_counters();
4516            let result = token.mint(admin, ITIP20::mintCall { to: admin, amount });
4517
4518            assert_eq!(
4519                result,
4520                Err(TempoPrecompileError::TIP20(TIP20Error::contract_paused()))
4521            );
4522            // Pre-T6 paused mint must stop after issuer-role, pause and total supply reads.
4523            assert_eq!(token.storage.counter_sload(), 3);
4524
4525            Ok::<_, TempoPrecompileError>(())
4526        })?;
4527
4528        Ok(())
4529    }
4530
4531    #[test]
4532    fn test_mint_rejects_when_paused_on_t3() -> eyre::Result<()> {
4533        let to = Address::random();
4534        let amount = U256::from(1000);
4535        let memo = FixedBytes::random();
4536
4537        for hardfork in [TempoHardfork::T2, TempoHardfork::T3] {
4538            let mut storage = HashMapStorageProvider::new_with_spec(1, hardfork);
4539            let admin = Address::random();
4540
4541            StorageCtx::enter(&mut storage, || {
4542                let mut token = TIP20Setup::create("Test", "TST", admin)
4543                    .with_issuer(admin)
4544                    .with_role(admin, *PAUSE_ROLE)
4545                    .apply()?;
4546
4547                token.pause(admin, ITIP20::pauseCall {})?;
4548
4549                let mint_result = token.mint(admin, ITIP20::mintCall { to, amount });
4550                let mint_memo_result =
4551                    token.mint_with_memo(admin, ITIP20::mintWithMemoCall { to, amount, memo });
4552
4553                if hardfork.is_t3() {
4554                    let expected = TempoPrecompileError::TIP20(TIP20Error::contract_paused());
4555                    assert_eq!(mint_result, Err(expected.clone()));
4556                    assert_eq!(mint_memo_result, Err(expected));
4557                } else {
4558                    assert!(mint_result.is_ok());
4559                    assert!(mint_memo_result.is_ok());
4560                }
4561
4562                Ok::<_, TempoPrecompileError>(())
4563            })?;
4564        }
4565        Ok(())
4566    }
4567
4568    #[test]
4569    fn test_burn_rejects_when_paused_on_t3() -> eyre::Result<()> {
4570        let amount = U256::from(500);
4571        let memo = FixedBytes::random();
4572
4573        for hardfork in [TempoHardfork::T2, TempoHardfork::T3] {
4574            let mut storage = HashMapStorageProvider::new_with_spec(1, hardfork);
4575            let admin = Address::random();
4576
4577            StorageCtx::enter(&mut storage, || {
4578                let mut token = TIP20Setup::create("Test", "TST", admin)
4579                    .with_issuer(admin)
4580                    .with_role(admin, *PAUSE_ROLE)
4581                    .with_mint(admin, amount * U256::from(2))
4582                    .apply()?;
4583
4584                token.pause(admin, ITIP20::pauseCall {})?;
4585
4586                let burn_result = token.burn(admin, ITIP20::burnCall { amount });
4587                let burn_memo_result =
4588                    token.burn_with_memo(admin, ITIP20::burnWithMemoCall { amount, memo });
4589
4590                if hardfork.is_t3() {
4591                    let expected = TempoPrecompileError::TIP20(TIP20Error::contract_paused());
4592                    assert_eq!(burn_result, Err(expected.clone()));
4593                    assert_eq!(burn_memo_result, Err(expected));
4594                } else {
4595                    assert!(burn_result.is_ok());
4596                    assert!(burn_memo_result.is_ok());
4597                }
4598
4599                Ok::<_, TempoPrecompileError>(())
4600            })?;
4601        }
4602        Ok(())
4603    }
4604
4605    #[test]
4606    fn test_burn_blocked_rejects_when_paused_on_t3() -> eyre::Result<()> {
4607        let amount = U256::from(500);
4608        let blocked = Address::random();
4609
4610        for hardfork in [TempoHardfork::T2, TempoHardfork::T3] {
4611            let mut storage = HashMapStorageProvider::new_with_spec(1, hardfork);
4612            let admin = Address::random();
4613
4614            StorageCtx::enter(&mut storage, || {
4615                // Create a blacklist policy and block the address
4616                let mut registry = TIP403Registry::new();
4617                registry.initialize()?;
4618                let policy_id = registry.create_policy(
4619                    admin,
4620                    ITIP403Registry::createPolicyCall {
4621                        admin,
4622                        policyType: ITIP403Registry::PolicyType::BLACKLIST,
4623                    },
4624                )?;
4625                registry.modify_policy_blacklist(
4626                    admin,
4627                    ITIP403Registry::modifyPolicyBlacklistCall {
4628                        policyId: policy_id,
4629                        account: blocked,
4630                        restricted: true,
4631                    },
4632                )?;
4633
4634                let mut token = TIP20Setup::create("Test", "TST", admin)
4635                    .with_issuer(admin)
4636                    .with_role(admin, *PAUSE_ROLE)
4637                    .with_role(admin, *BURN_BLOCKED_ROLE)
4638                    .with_mint(blocked, amount)
4639                    .apply()?;
4640
4641                // Point the token's transfer policy at our blacklist
4642                token.change_transfer_policy_id(
4643                    admin,
4644                    ITIP20::changeTransferPolicyIdCall {
4645                        newPolicyId: policy_id,
4646                    },
4647                )?;
4648
4649                // Pause the token
4650                token.pause(admin, ITIP20::pauseCall {})?;
4651
4652                let result = token.burn_blocked(admin, blocked, amount, true);
4653
4654                if hardfork.is_t3() {
4655                    assert_eq!(
4656                        result,
4657                        Err(TempoPrecompileError::TIP20(TIP20Error::contract_paused()))
4658                    );
4659                } else {
4660                    // T2: pause not enforced, burn succeeds
4661                    assert!(result.is_ok());
4662                    assert_eq!(token.get_balance(blocked)?, U256::ZERO);
4663                }
4664
4665                Ok::<_, TempoPrecompileError>(())
4666            })?;
4667        }
4668        Ok(())
4669    }
4670}