Skip to main content

tempo_precompiles/tip20_factory/
mod.rs

1//! [TIP-20] token factory precompile — deploys new [TIP-20] tokens at deterministic addresses.
2//!
3//! [TIP-20]: <https://docs.tempo.xyz/protocol/tip20>
4
5pub mod dispatch;
6
7pub use tempo_contracts::precompiles::{
8    ITIP20Factory, TIP20FactoryError, TIP20FactoryEvent, createTokenCall, createTokenWithLogoCall,
9};
10use tempo_precompiles_macros::contract;
11
12use crate::{
13    PATH_USD_ADDRESS, TIP20_FACTORY_ADDRESS,
14    error::{Result, TempoPrecompileError},
15    tip20::{TIP20Error, TIP20Token, USD_CURRENCY},
16};
17use alloy::{
18    primitives::{Address, B256, keccak256},
19    sol_types::SolValue,
20};
21use tempo_primitives::TempoAddressExt;
22use tracing::trace;
23
24/// Number of reserved addresses (0 to RESERVED_SIZE-1) that cannot be deployed via factory
25const RESERVED_SIZE: u64 = 1024;
26
27/// TIP20 token address prefix (12 bytes): 0x20C000000000000000000000
28const TIP20_PREFIX_BYTES: [u8; 12] = [
29    0x20, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
30];
31
32/// Factory contract for deploying new TIP-20 tokens at deterministic addresses.
33///
34/// Tokens are deployed at `TIP20_PREFIX || keccak256(sender, salt)[..8]`.
35/// The first 1024 addresses are reserved for protocol-deployed tokens.
36///
37/// The struct fields define the on-chain storage layout; the `#[contract]` macro generates the
38/// storage handlers which provide an ergonomic way to interact with the EVM state.
39#[contract(addr = TIP20_FACTORY_ADDRESS)]
40pub struct TIP20Factory {}
41
42/// Computes the deterministic TIP20 address from sender and salt.
43/// Returns the address and the lower bytes used for derivation.
44#[cfg_attr(test, allow(dead_code))]
45pub(crate) fn compute_tip20_address(sender: Address, salt: B256) -> (Address, u64) {
46    let hash = keccak256((sender, salt).abi_encode());
47
48    // Take first 8 bytes of hash as lower bytes
49    let mut padded = [0u8; 8];
50    padded.copy_from_slice(&hash[..8]);
51    let lower_bytes = u64::from_be_bytes(padded);
52
53    // Construct the address: TIP20_PREFIX (12 bytes) || hash[..8] (8 bytes)
54    let mut address_bytes = [0u8; 20];
55    address_bytes[..12].copy_from_slice(&TIP20_PREFIX_BYTES);
56    address_bytes[12..].copy_from_slice(&hash[..8]);
57
58    (Address::from(address_bytes), lower_bytes)
59}
60
61// Precompile functions
62impl TIP20Factory {
63    /// Initializes the TIP-20 factory precompile.
64    pub fn initialize(&mut self) -> Result<()> {
65        self.__initialize()
66    }
67
68    /// Computes the deterministic address for a token given `sender` and `salt`. Reverts if the
69    /// derived address falls within the reserved range (lower 8 bytes < `RESERVED_SIZE`).
70    ///
71    /// # Errors
72    /// - `AddressReserved` — the derived address is in the reserved range
73    pub fn get_token_address(&self, call: ITIP20Factory::getTokenAddressCall) -> Result<Address> {
74        let (address, lower_bytes) = compute_tip20_address(call.sender, call.salt);
75
76        // Check if address would be in reserved range
77        if lower_bytes < RESERVED_SIZE {
78            return Err(TempoPrecompileError::TIP20Factory(
79                TIP20FactoryError::address_reserved(),
80            ));
81        }
82
83        Ok(address)
84    }
85
86    /// Returns `true` if `token` has the correct TIP-20 prefix and has code deployed.
87    pub fn is_tip20(&self, token: Address) -> Result<bool> {
88        if !token.is_tip20() {
89            return Ok(false);
90        }
91        // Check if the token has code deployed (non-empty code hash)
92        self.storage
93            .with_account_info(token, |info| Ok(!info.is_empty_code_hash()))
94    }
95
96    /// Deploys a new TIP-20 token at a deterministic address derived from `sender` and `salt`.
97    ///
98    /// Validates that the token does not already exist, the quote token is a deployed TIP-20 of
99    /// a compatible currency, and the derived address is outside the reserved range. Initializes
100    /// the token via [`TIP20Token::initialize`].
101    ///
102    /// # Errors
103    /// - `TokenAlreadyExists` — a TIP-20 is already deployed at the derived address
104    /// - `InvalidQuoteToken` — quote token is not a deployed TIP-20 or has incompatible currency
105    /// - `AddressReserved` — the derived address is in the reserved range
106    pub fn create_token(&mut self, sender: Address, call: createTokenCall) -> Result<Address> {
107        trace!(%sender, ?call, "Create token");
108
109        // Compute the deterministic address from sender and salt
110        let (token_address, lower_bytes) = compute_tip20_address(sender, call.salt);
111
112        if self.is_tip20(token_address)? {
113            return Err(TempoPrecompileError::TIP20Factory(
114                TIP20FactoryError::token_already_exists(token_address),
115            ));
116        }
117
118        // Ensure that the quote token is a valid TIP20 that is currently deployed.
119        if !self.is_tip20(call.quoteToken)? {
120            return Err(TIP20Error::invalid_quote_token().into());
121        }
122
123        // If token is USD, its quote token must also be USD
124        if call.currency == USD_CURRENCY
125            && TIP20Token::from_address(call.quoteToken)?.currency()? != USD_CURRENCY
126        {
127            return Err(TIP20Error::invalid_quote_token().into());
128        }
129
130        // Check if address is in reserved range
131        if lower_bytes < RESERVED_SIZE {
132            return Err(TempoPrecompileError::TIP20Factory(
133                TIP20FactoryError::address_reserved(),
134            ));
135        }
136
137        TIP20Token::from_address(token_address)?.initialize(
138            sender,
139            &call.name,
140            &call.symbol,
141            &call.currency,
142            call.quoteToken,
143            call.admin,
144        )?;
145
146        self.emit_event(TIP20FactoryEvent::token_created(
147            token_address,
148            call.name,
149            call.symbol,
150            call.currency,
151            call.quoteToken,
152            call.admin,
153            call.salt,
154        ))?;
155
156        Ok(token_address)
157    }
158
159    /// Creates a token and atomically sets its `logoURI` (TIP-1026).
160    ///
161    /// Behaves identically to [`Self::create_token`] plus, when `logoURI` is
162    /// non-empty, writes the URI to the new token's storage and emits
163    /// `LogoURIUpdated` from the new token's address with `updater = sender`.
164    ///
165    /// # Errors
166    /// - All errors from [`Self::create_token`]
167    /// - `LogoURITooLong` — `bytes(logoURI).length > 256`
168    /// - `InvalidLogoURI` — `logoURI` is non-empty and fails validation
169    pub fn create_token_with_logo(
170        &mut self,
171        sender: Address,
172        call: createTokenWithLogoCall,
173    ) -> Result<Address> {
174        // Validate the logo URI up-front so a bad URI does not leave a partially-created token.
175        if !call.logoURI.is_empty() {
176            crate::tip20::TIP20Token::validate_logo_uri(&call.logoURI)?;
177        }
178
179        let token_address = self.create_token(
180            sender,
181            createTokenCall {
182                name: call.name,
183                symbol: call.symbol,
184                currency: call.currency,
185                quoteToken: call.quoteToken,
186                admin: call.admin,
187                salt: call.salt,
188            },
189        )?;
190
191        if !call.logoURI.is_empty() {
192            TIP20Token::from_address(token_address)?.write_logo_uri(sender, call.logoURI)?;
193        }
194
195        Ok(token_address)
196    }
197
198    /// Deploys a TIP-20 token at a reserved address (lower 8 bytes < `RESERVED_SIZE`). Used
199    /// during genesis or hardforks to bootstrap protocol tokens like pathUSD.
200    ///
201    /// # Errors
202    /// - `InvalidToken` — `address` does not have the TIP-20 prefix
203    /// - `TokenAlreadyExists` — a TIP-20 is already deployed at `address`
204    /// - `InvalidQuoteToken` — quote token is invalid, not deployed, or has incompatible
205    ///   currency; pathUSD must use `Address::ZERO` as quote token
206    /// - `AddressNotReserved` — the address is outside the reserved range
207    pub fn create_token_reserved_address(
208        &mut self,
209        address: Address,
210        name: &str,
211        symbol: &str,
212        currency: &str,
213        quote_token: Address,
214        admin: Address,
215    ) -> Result<Address> {
216        // Validate that the address has a TIP20 prefix
217        if !address.is_tip20() {
218            return Err(TIP20Error::invalid_token().into());
219        }
220
221        // Validate that the address is not already deployed
222        if self.is_tip20(address)? {
223            return Err(TempoPrecompileError::TIP20Factory(
224                TIP20FactoryError::token_already_exists(address),
225            ));
226        }
227
228        // quote_token must be address(0) or a valid TIP20
229        if !quote_token.is_zero() {
230            // pathUSD must set address(0) as the quote token
231            // or the tip20 must be a valid deployed token
232            if address == PATH_USD_ADDRESS || !self.is_tip20(quote_token)? {
233                return Err(TIP20Error::invalid_quote_token().into());
234            }
235            // If token is USD, its quote token must also be USD
236            if currency == USD_CURRENCY
237                && TIP20Token::from_address(quote_token)?.currency()? != USD_CURRENCY
238            {
239                return Err(TIP20Error::invalid_quote_token().into());
240            }
241        }
242
243        // Validate that the address is within the reserved range
244        // Reserved addresses have their last 8 bytes represent a value < RESERVED_SIZE
245        let mut padded = [0u8; 8];
246        padded.copy_from_slice(&address.as_slice()[12..]);
247        let lower_bytes = u64::from_be_bytes(padded);
248        if lower_bytes >= RESERVED_SIZE {
249            return Err(TempoPrecompileError::TIP20Factory(
250                TIP20FactoryError::address_not_reserved(),
251            ));
252        }
253
254        let mut token = TIP20Token::from_address(address)?;
255        token.initialize(admin, name, symbol, currency, quote_token, admin)?;
256
257        self.emit_event(TIP20FactoryEvent::token_created(
258            address,
259            name.into(),
260            symbol.into(),
261            currency.into(),
262            quote_token,
263            admin,
264            B256::ZERO,
265        ))?;
266
267        Ok(address)
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::{
275        PATH_USD_ADDRESS,
276        error::TempoPrecompileError,
277        storage::{ContractStorage, StorageCtx, hashmap::HashMapStorageProvider},
278        test_util::TIP20Setup,
279    };
280    use alloy::primitives::{Address, address};
281
282    #[test]
283    fn test_is_initialized() -> eyre::Result<()> {
284        let mut storage = HashMapStorageProvider::new(1);
285
286        StorageCtx::enter(&mut storage, || {
287            let mut factory = TIP20Factory::new();
288
289            // Factory should not be initialized before initialize() call
290            assert!(!factory.is_initialized()?);
291
292            // After initialize(), factory should be initialized
293            factory.initialize()?;
294            assert!(factory.is_initialized()?);
295
296            // Creating a new handle should still see initialized state
297            let factory2 = TIP20Factory::new();
298            assert!(factory2.is_initialized()?);
299
300            Ok(())
301        })
302    }
303
304    #[test]
305    fn test_is_tip20() -> eyre::Result<()> {
306        let mut storage = HashMapStorageProvider::new(1);
307        let sender = Address::random();
308
309        StorageCtx::enter(&mut storage, || {
310            // Initialize pathUSD
311            let _path_usd = TIP20Setup::path_usd(sender).apply()?;
312
313            let factory = TIP20Factory::new();
314
315            // PATH_USD should be valid (has code deployed)
316            assert!(factory.is_tip20(PATH_USD_ADDRESS)?);
317
318            // Address with TIP20 prefix but no code should be invalid
319            let no_code_tip20 = address!("20C0000000000000000000000000000000000002");
320            assert!(!factory.is_tip20(no_code_tip20)?);
321
322            // Random address (wrong prefix) should be invalid
323            assert!(!factory.is_tip20(Address::random())?);
324
325            // Create a token via factory and verify it's valid
326            let token = TIP20Setup::create("Test", "TST", sender).apply()?;
327            assert!(factory.is_tip20(token.address())?);
328
329            Ok(())
330        })
331    }
332
333    #[test]
334    fn test_get_token_address() -> eyre::Result<()> {
335        let mut storage = HashMapStorageProvider::new(1);
336
337        StorageCtx::enter(&mut storage, || {
338            let factory = TIP20Factory::new();
339            let sender = Address::random();
340            let salt = B256::random();
341
342            // get_token_address should return same address as compute_tip20_address
343            let call = ITIP20Factory::getTokenAddressCall { sender, salt };
344            let address = factory.get_token_address(call)?;
345            let (expected, _) = compute_tip20_address(sender, salt);
346            assert_eq!(address, expected);
347
348            // Calling with same params should be deterministic
349            let call2 = ITIP20Factory::getTokenAddressCall { sender, salt };
350            assert_eq!(factory.get_token_address(call2)?, address);
351
352            Ok(())
353        })
354    }
355
356    #[test]
357    fn test_compute_tip20_address_deterministic() {
358        let sender1 = Address::random();
359        let sender2 = Address::random();
360        let salt1 = B256::random();
361        let salt2 = B256::random();
362
363        let (addr0, lower0) = compute_tip20_address(sender1, salt1);
364        let (addr1, lower1) = compute_tip20_address(sender1, salt1);
365        assert_eq!(addr0, addr1);
366        assert_eq!(lower0, lower1);
367
368        // Same salt with different senders should produce different addresses
369        let (addr2, lower2) = compute_tip20_address(sender1, salt1);
370        let (addr3, lower3) = compute_tip20_address(sender2, salt1);
371        assert_ne!(addr2, addr3);
372        assert_ne!(lower2, lower3);
373
374        // Same sender with different salts should produce different addresses
375        let (addr4, lower4) = compute_tip20_address(sender1, salt1);
376        let (addr5, lower5) = compute_tip20_address(sender1, salt2);
377        assert_ne!(addr4, addr5);
378        assert_ne!(lower4, lower5);
379
380        // All addresses should have TIP20 prefix
381        assert!(addr1.is_tip20());
382        assert!(addr2.is_tip20());
383        assert!(addr3.is_tip20());
384        assert!(addr4.is_tip20());
385        assert!(addr5.is_tip20());
386    }
387
388    #[test]
389    fn test_create_token() -> eyre::Result<()> {
390        let mut storage = HashMapStorageProvider::new(1);
391        let sender = Address::random();
392        StorageCtx::enter(&mut storage, || {
393            let mut factory = TIP20Setup::factory()?;
394            let path_usd = TIP20Setup::path_usd(sender).apply()?;
395            factory.clear_emitted_events();
396
397            let salt1 = B256::random();
398            let salt2 = B256::random();
399            let call1 = createTokenCall {
400                name: "Test Token 1".to_string(),
401                symbol: "TEST1".to_string(),
402                currency: "USD".to_string(),
403                quoteToken: path_usd.address(),
404                admin: sender,
405                salt: salt1,
406            };
407            let call2 = createTokenCall {
408                name: "Test Token 2".to_string(),
409                symbol: "TEST2".to_string(),
410                currency: "USD".to_string(),
411                quoteToken: path_usd.address(),
412                admin: sender,
413                salt: salt2,
414            };
415
416            let token_addr_1 = factory.create_token(sender, call1.clone())?;
417            let token_addr_2 = factory.create_token(sender, call2.clone())?;
418
419            // Verify addresses are different
420            assert_ne!(token_addr_1, token_addr_2);
421
422            // Verify addresses have TIP20 prefix
423            assert!(token_addr_1.is_tip20());
424            assert!(token_addr_2.is_tip20());
425
426            // Verify tokens are valid TIP20s
427            assert!(factory.is_tip20(token_addr_1)?);
428            assert!(factory.is_tip20(token_addr_2)?);
429
430            // Verify event emission
431            factory.assert_emitted_events(vec![
432                TIP20FactoryEvent::token_created(
433                    token_addr_1,
434                    call1.name,
435                    call1.symbol,
436                    call1.currency,
437                    call1.quoteToken,
438                    call1.admin,
439                    call1.salt,
440                ),
441                TIP20FactoryEvent::token_created(
442                    token_addr_2,
443                    call2.name,
444                    call2.symbol,
445                    call2.currency,
446                    call2.quoteToken,
447                    call2.admin,
448                    call2.salt,
449                ),
450            ]);
451
452            Ok(())
453        })
454    }
455
456    #[test]
457    fn test_create_token_selector_and_event_unchanged() {
458        use alloy::sol_types::{SolCall, SolEvent};
459
460        assert_eq!(
461            createTokenCall::SELECTOR,
462            [0x68, 0x13, 0x04, 0x45],
463            "createToken selector must remain 0x68130445"
464        );
465
466        assert_eq!(
467            ITIP20Factory::TokenCreated::SIGNATURE_HASH,
468            alloy::primitives::b256!(
469                "44f7b8011db3e3647a530b4ff635726de5fafc8fa8ad10f0f31c0eb9dd52fc65"
470            ),
471            "TokenCreated topic0 must remain unchanged"
472        );
473    }
474
475    #[test]
476    fn test_create_token_with_logo() -> eyre::Result<()> {
477        use alloy::sol_types::SolEvent;
478        use tempo_contracts::precompiles::ITIP20;
479
480        let mut storage = HashMapStorageProvider::new(1);
481        let sender = Address::random();
482        // Use a distinct `admin` to lock in the spec-mandated
483        // `updater = msg.sender` semantics for the deploy-time
484        // `LogoURIUpdated` event (TIP-1026).
485        let admin = Address::random();
486        assert_ne!(sender, admin);
487
488        StorageCtx::enter(&mut storage, || {
489            let mut factory = TIP20Setup::factory()?;
490            let path_usd = TIP20Setup::path_usd(sender).apply()?;
491            factory.clear_emitted_events();
492
493            let salt = B256::random();
494            let logo_uri = "https://example.com/icon.svg".to_string();
495            let call = createTokenWithLogoCall {
496                name: "Logo Token".to_string(),
497                symbol: "LOGO".to_string(),
498                currency: "USD".to_string(),
499                quoteToken: path_usd.address(),
500                admin,
501                salt,
502                logoURI: logo_uri.clone(),
503            };
504
505            let token_addr = factory.create_token_with_logo(sender, call.clone())?;
506
507            // Token deployed correctly
508            assert!(token_addr.is_tip20());
509            assert!(factory.is_tip20(token_addr)?);
510
511            // logoURI is stored on the new token
512            let token = TIP20Token::from_address(token_addr)?;
513            assert_eq!(token.logo_uri()?, logo_uri);
514
515            // The deploy-time LogoURIUpdated event uses `updater = msg.sender`
516            // per TIP-1026.
517            let logo_topic = ITIP20::LogoURIUpdated::SIGNATURE_HASH;
518            let logo_event = token
519                .emitted_events()
520                .iter()
521                .find(|e| e.topics().first() == Some(&logo_topic))
522                .expect("LogoURIUpdated event missing")
523                .clone();
524            let decoded = ITIP20::LogoURIUpdated::decode_log_data(&logo_event).expect("decode log");
525            assert_eq!(decoded.updater, sender);
526            assert_eq!(decoded.newLogoURI, logo_uri);
527
528            // Factory emits TokenCreated (unchanged signature)
529            factory.assert_emitted_events(vec![TIP20FactoryEvent::TokenCreated(
530                ITIP20Factory::TokenCreated {
531                    token: token_addr,
532                    name: call.name,
533                    symbol: call.symbol,
534                    currency: call.currency,
535                    quoteToken: call.quoteToken,
536                    admin: call.admin,
537                    salt: call.salt,
538                },
539            )]);
540
541            Ok(())
542        })
543    }
544
545    #[test]
546    fn test_create_token_with_logo_empty_uri_skips_event() -> eyre::Result<()> {
547        use alloy::sol_types::SolEvent;
548        use tempo_contracts::precompiles::ITIP20;
549
550        let mut storage = HashMapStorageProvider::new(1);
551        let sender = Address::random();
552
553        StorageCtx::enter(&mut storage, || {
554            let mut factory = TIP20Setup::factory()?;
555            let path_usd = TIP20Setup::path_usd(sender).apply()?;
556            factory.clear_emitted_events();
557
558            let token_addr = factory.create_token_with_logo(
559                sender,
560                createTokenWithLogoCall {
561                    name: "Empty Logo".to_string(),
562                    symbol: "EMPTY".to_string(),
563                    currency: "USD".to_string(),
564                    quoteToken: path_usd.address(),
565                    admin: sender,
566                    salt: B256::random(),
567                    logoURI: String::new(),
568                },
569            )?;
570
571            // logoURI remains the default (empty)
572            let token = TIP20Token::from_address(token_addr)?;
573            assert_eq!(token.logo_uri()?, "");
574
575            // No LogoURIUpdated event was emitted on the new token
576            let logo_topic = ITIP20::LogoURIUpdated::SIGNATURE_HASH;
577            assert!(
578                !token
579                    .emitted_events()
580                    .iter()
581                    .any(|e| e.topics().first() == Some(&logo_topic)),
582                "LogoURIUpdated should not be emitted when logoURI is empty"
583            );
584
585            Ok(())
586        })
587    }
588
589    #[test]
590    fn test_create_token_with_logo_rejects_atomically() -> eyre::Result<()> {
591        let mut storage = HashMapStorageProvider::new(1);
592        let sender = Address::random();
593
594        StorageCtx::enter(&mut storage, || {
595            let mut factory = TIP20Setup::factory()?;
596            let path_usd = TIP20Setup::path_usd(sender).apply()?;
597            let salt = B256::random();
598
599            let call = |logo_uri: &str| createTokenWithLogoCall {
600                name: "Tok".to_string(),
601                symbol: "TOK".to_string(),
602                currency: "USD".to_string(),
603                quoteToken: path_usd.address(),
604                admin: sender,
605                salt,
606                logoURI: logo_uri.to_string(),
607            };
608
609            // (a1) Length cap: 257 bytes — one over the limit. Valid scheme
610            // so we exercise the length check, not the URI/scheme check.
611            let prefix = "https://example.com/";
612            let too_long = format!("{prefix}{}", "a".repeat(257 - prefix.len()));
613            assert_eq!(too_long.len(), 257);
614            assert!(matches!(
615                factory.create_token_with_logo(sender, call(&too_long)),
616                Err(TempoPrecompileError::TIP20(TIP20Error::LogoURITooLong(_)))
617            ));
618
619            // (a2) Disallowed scheme — `javascript:` is the canonical example
620            // from the spec's security considerations.
621            assert!(matches!(
622                factory.create_token_with_logo(sender, call("javascript:alert(1)")),
623                Err(TempoPrecompileError::TIP20(TIP20Error::InvalidLogoURI(_)))
624            ));
625
626            // (b) Atomicity: the same salt is reusable with a valid URI,
627            // proving no partial token was left behind by either rejection.
628            let token =
629                factory.create_token_with_logo(sender, call("https://example.com/icon.svg"))?;
630            assert!(factory.is_tip20(token)?);
631
632            Ok(())
633        })
634    }
635
636    #[test]
637    fn test_create_token_invalid_quote_token() -> eyre::Result<()> {
638        let mut storage = HashMapStorageProvider::new(1);
639        let sender = Address::random();
640        StorageCtx::enter(&mut storage, || {
641            let mut factory = TIP20Setup::factory()?;
642            TIP20Setup::path_usd(sender).apply()?;
643
644            let invalid_call = createTokenCall {
645                name: "Test Token".to_string(),
646                symbol: "TEST".to_string(),
647                currency: "USD".to_string(),
648                quoteToken: Address::random(),
649                admin: sender,
650                salt: B256::random(),
651            };
652
653            let result = factory.create_token(sender, invalid_call);
654            assert_eq!(
655                result.unwrap_err(),
656                TempoPrecompileError::TIP20(TIP20Error::invalid_quote_token())
657            );
658            Ok(())
659        })
660    }
661
662    #[test]
663    fn test_create_token_usd_with_non_usd_quote() -> eyre::Result<()> {
664        let mut storage = HashMapStorageProvider::new(1);
665        let sender = Address::random();
666        StorageCtx::enter(&mut storage, || {
667            let mut factory = TIP20Setup::factory()?;
668            let _path_usd = TIP20Setup::path_usd(sender).apply()?;
669            let eur_token = TIP20Setup::create("EUR Token", "EUR", sender)
670                .currency("EUR")
671                .apply()?;
672
673            let invalid_call = createTokenCall {
674                name: "USD Token".to_string(),
675                symbol: "USDT".to_string(),
676                currency: "USD".to_string(),
677                quoteToken: eur_token.address(),
678                admin: sender,
679                salt: B256::random(),
680            };
681
682            let result = factory.create_token(sender, invalid_call);
683            assert_eq!(
684                result.unwrap_err(),
685                TempoPrecompileError::TIP20(TIP20Error::invalid_quote_token())
686            );
687            Ok(())
688        })
689    }
690
691    #[test]
692    fn test_create_token_quote_token_not_deployed() -> eyre::Result<()> {
693        let mut storage = HashMapStorageProvider::new(1);
694        let sender = Address::random();
695        StorageCtx::enter(&mut storage, || {
696            let mut factory = TIP20Setup::factory()?;
697            TIP20Setup::path_usd(sender).apply()?;
698
699            // Create an address with TIP20 prefix but no code
700            let non_existent_tip20 =
701                Address::from(alloy::hex!("20C0000000000000000000000000000000009999"));
702            let invalid_call = createTokenCall {
703                name: "Test Token".to_string(),
704                symbol: "TEST".to_string(),
705                currency: "USD".to_string(),
706                quoteToken: non_existent_tip20,
707                admin: sender,
708                salt: B256::random(),
709            };
710
711            let result = factory.create_token(sender, invalid_call);
712            assert_eq!(
713                result.unwrap_err(),
714                TempoPrecompileError::TIP20(TIP20Error::invalid_quote_token())
715            );
716            Ok(())
717        })
718    }
719
720    #[test]
721    fn test_create_token_already_deployed() -> eyre::Result<()> {
722        let mut storage = HashMapStorageProvider::new(1);
723        let sender = Address::random();
724        StorageCtx::enter(&mut storage, || {
725            let mut factory = TIP20Setup::factory()?;
726            TIP20Setup::path_usd(sender).apply()?;
727
728            let salt = B256::random();
729            let create_token_call = createTokenCall {
730                name: "Test Token".to_string(),
731                symbol: "TEST".to_string(),
732                currency: "USD".to_string(),
733                quoteToken: PATH_USD_ADDRESS,
734                admin: sender,
735                salt,
736            };
737
738            let token = factory.create_token(sender, create_token_call.clone())?;
739            let result = factory.create_token(sender, create_token_call);
740            assert_eq!(
741                result.unwrap_err(),
742                TempoPrecompileError::TIP20Factory(TIP20FactoryError::TokenAlreadyExists(
743                    ITIP20Factory::TokenAlreadyExists { token }
744                ))
745            );
746
747            Ok(())
748        })
749    }
750
751    #[test]
752    fn test_create_token_reserved_address_rejects_invalid_prefix() -> eyre::Result<()> {
753        let mut storage = HashMapStorageProvider::new(1);
754        let admin = Address::random();
755
756        StorageCtx::enter(&mut storage, || {
757            let mut factory = TIP20Factory::new();
758            factory.initialize()?;
759
760            let result = factory.create_token_reserved_address(
761                Address::random(), // No TIP20 prefix
762                "Test",
763                "TST",
764                "USD",
765                Address::ZERO,
766                admin,
767            );
768
769            assert_eq!(
770                result.unwrap_err(),
771                TempoPrecompileError::TIP20(TIP20Error::invalid_token())
772            );
773
774            Ok(())
775        })
776    }
777
778    #[test]
779    fn test_create_token_reserved_address_rejects_already_deployed() -> eyre::Result<()> {
780        let mut storage = HashMapStorageProvider::new(1);
781        let admin = Address::random();
782
783        StorageCtx::enter(&mut storage, || {
784            let mut factory = TIP20Factory::new();
785            factory.initialize()?;
786
787            factory.create_token_reserved_address(
788                PATH_USD_ADDRESS,
789                "pathUSD",
790                "pathUSD",
791                "USD",
792                Address::ZERO,
793                admin,
794            )?;
795
796            let result = factory.create_token_reserved_address(
797                PATH_USD_ADDRESS,
798                "pathUSD",
799                "pathUSD",
800                "USD",
801                Address::ZERO,
802                admin,
803            );
804
805            assert_eq!(
806                result.unwrap_err(),
807                TempoPrecompileError::TIP20Factory(TIP20FactoryError::token_already_exists(
808                    PATH_USD_ADDRESS
809                ))
810            );
811
812            Ok(())
813        })
814    }
815
816    #[test]
817    fn test_create_token_reserved_address_rejects_non_usd_quote_for_usd_token() -> eyre::Result<()>
818    {
819        let mut storage = HashMapStorageProvider::new(1);
820        let admin = Address::random();
821
822        StorageCtx::enter(&mut storage, || {
823            let eur_token = TIP20Setup::create("EUR Token", "EUR", admin)
824                .currency("EUR")
825                .apply()?;
826
827            let mut factory = TIP20Factory::new();
828
829            let result = factory.create_token_reserved_address(
830                address!("20C0000000000000000000000000000000000001"), // reserved address
831                "Test USD",
832                "TUSD",
833                "USD",
834                eur_token.address(),
835                admin,
836            );
837
838            assert_eq!(
839                result.unwrap_err(),
840                TempoPrecompileError::TIP20(TIP20Error::invalid_quote_token())
841            );
842
843            Ok(())
844        })
845    }
846
847    #[test]
848    fn test_create_token_reserved_address_rejects_non_reserved_address() -> eyre::Result<()> {
849        let mut storage = HashMapStorageProvider::new(1);
850        let admin = Address::random();
851
852        StorageCtx::enter(&mut storage, || {
853            let _path_usd = TIP20Setup::path_usd(admin).apply()?;
854            let mut factory = TIP20Factory::new();
855
856            // 0x9999 = 39321 > 1024 (RESERVED_SIZE)
857            let non_reserved = address!("20C0000000000000000000000000000000009999");
858
859            let result = factory.create_token_reserved_address(
860                non_reserved,
861                "Test",
862                "TST",
863                "USD",
864                PATH_USD_ADDRESS,
865                admin,
866            );
867
868            assert_eq!(
869                result.unwrap_err(),
870                TempoPrecompileError::TIP20Factory(TIP20FactoryError::address_not_reserved())
871            );
872
873            Ok(())
874        })
875    }
876
877    #[test]
878    fn test_create_token_reserved_address_requires_zero_addr_as_first_quote() -> eyre::Result<()> {
879        let mut storage = HashMapStorageProvider::new(1);
880        let admin = Address::random();
881
882        StorageCtx::enter(&mut storage, || {
883            let mut factory = TIP20Factory::new();
884            factory.initialize()?;
885
886            // Try to create PATH_USD with a non-deployed TIP20 as quote_token
887            let result = factory.create_token_reserved_address(
888                PATH_USD_ADDRESS,
889                "pathUSD",
890                "pathUSD",
891                "USD",
892                address!("20C0000000000000000000000000000000000001"),
893                admin,
894            );
895            assert!(matches!(
896                result,
897                Err(TempoPrecompileError::TIP20(TIP20Error::InvalidQuoteToken(
898                    _
899                )))
900            ));
901
902            // Only possible to deploy PATH_USD (the first token) without a quote token
903            factory.create_token_reserved_address(
904                PATH_USD_ADDRESS,
905                "pathUSD",
906                "pathUSD",
907                "USD",
908                Address::ZERO,
909                admin,
910            )?;
911
912            Ok(())
913        })
914    }
915
916    #[test]
917    fn test_path_usd_requires_zero_quote_token() -> eyre::Result<()> {
918        let mut storage = HashMapStorageProvider::new(1);
919        let admin = Address::random();
920
921        StorageCtx::enter(&mut storage, || {
922            let mut factory = TIP20Factory::new();
923            factory.initialize()?;
924
925            let other_usd = factory.create_token_reserved_address(
926                address!("20C0000000000000000000000000000000000001"),
927                "testUSD",
928                "testUSD",
929                "USD",
930                Address::ZERO,
931                admin,
932            )?;
933
934            let result = factory.create_token_reserved_address(
935                PATH_USD_ADDRESS,
936                "pathUSD",
937                "pathUSD",
938                "USD",
939                other_usd,
940                admin,
941            );
942            assert!(matches!(
943                result,
944                Err(TempoPrecompileError::TIP20(TIP20Error::InvalidQuoteToken(
945                    _
946                )))
947            ));
948
949            factory.create_token_reserved_address(
950                PATH_USD_ADDRESS,
951                "pathUSD",
952                "pathUSD",
953                "USD",
954                Address::ZERO,
955                admin,
956            )?;
957
958            assert!(TIP20Token::from_address(PATH_USD_ADDRESS)?.is_initialized()?);
959
960            Ok(())
961        })
962    }
963
964    #[test]
965    fn test_compute_tip20_address_returns_non_default() {
966        let sender = Address::random();
967        let salt = B256::random();
968
969        let (address, lower_bytes) = compute_tip20_address(sender, salt);
970
971        // Address should NOT be default
972        assert_ne!(address, Address::ZERO);
973
974        // Address should have TIP20 prefix
975        assert!(address.is_tip20());
976
977        // Same inputs should produce same outputs (deterministic)
978        let (address2, lower_bytes2) = compute_tip20_address(sender, salt);
979        assert_eq!(address, address2);
980        assert_eq!(lower_bytes, lower_bytes2);
981
982        // Different sender should produce different outputs
983        let (address3, _) = compute_tip20_address(Address::random(), salt);
984        assert_ne!(address, address3);
985
986        // Different salt should produce different outputs
987        let (address4, _) = compute_tip20_address(sender, B256::random());
988        assert_ne!(address, address4);
989    }
990
991    #[test]
992    fn test_get_token_address_returns_correct_address() -> eyre::Result<()> {
993        let mut storage = HashMapStorageProvider::new(1);
994        let sender = Address::random();
995
996        StorageCtx::enter(&mut storage, || {
997            let factory = TIP20Factory::new();
998
999            // Use a salt that produces non-reserved address
1000            let salt = B256::repeat_byte(0xFF);
1001
1002            let address =
1003                factory.get_token_address(ITIP20Factory::getTokenAddressCall { sender, salt })?;
1004
1005            // Address should NOT be default
1006            assert_ne!(address, Address::ZERO);
1007
1008            // Should have TIP20 prefix
1009            assert!(address.is_tip20());
1010
1011            // Should be deterministic
1012            let address2 =
1013                factory.get_token_address(ITIP20Factory::getTokenAddressCall { sender, salt })?;
1014            assert_eq!(address, address2);
1015
1016            Ok(())
1017        })
1018    }
1019
1020    #[test]
1021    fn test_is_tip20_returns_correct_boolean() -> eyre::Result<()> {
1022        let mut storage = HashMapStorageProvider::new(1);
1023        let admin = Address::random();
1024
1025        StorageCtx::enter(&mut storage, || {
1026            let factory = TIP20Factory::new();
1027
1028            // Non-TIP20 address should return false
1029            let non_tip20 = Address::random();
1030            assert!(
1031                !factory.is_tip20(non_tip20)?,
1032                "Non-TIP20 address should return false"
1033            );
1034
1035            // PATH_USD before deployment should return false (no code)
1036            assert!(
1037                !factory.is_tip20(PATH_USD_ADDRESS)?,
1038                "Undeployed TIP20 should return false"
1039            );
1040
1041            // Deploy pathUSD
1042            TIP20Setup::path_usd(admin).apply()?;
1043
1044            // Now PATH_USD should return true
1045            assert!(
1046                factory.is_tip20(PATH_USD_ADDRESS)?,
1047                "Deployed TIP20 should return true"
1048            );
1049
1050            Ok(())
1051        })
1052    }
1053
1054    #[test]
1055    fn test_get_token_address_reserved_boundary() {
1056        let sender = Address::ZERO;
1057        let salt = B256::repeat_byte(0xAB);
1058        let (_, lower_bytes) = compute_tip20_address(sender, salt);
1059        assert!(
1060            lower_bytes >= RESERVED_SIZE,
1061            "compute_tip20_address should produce non-reserved addresses for typical salts"
1062        );
1063    }
1064}