tempo_precompiles/tip20_factory/
mod.rs

1// Module for tip20_factory precompile
2pub mod dispatch;
3
4pub use tempo_contracts::precompiles::{ITIP20Factory, TIP20FactoryEvent};
5use tempo_precompiles_macros::contract;
6
7use crate::{
8    TIP20_FACTORY_ADDRESS,
9    error::{Result, TempoPrecompileError},
10    storage::Handler,
11    tip20::{
12        TIP20Error, TIP20Token, address_to_token_id_unchecked, is_tip20_prefix, token_id_to_address,
13    },
14};
15use alloy::primitives::{Address, U256};
16use tracing::trace;
17
18#[contract(addr = TIP20_FACTORY_ADDRESS)]
19pub struct TIP20Factory {
20    // TODO: It would be nice to have a `#[initial_value=`n`]` macro
21    // to mimic setting an initial value in solidity
22    token_id_counter: U256,
23}
24
25// Precompile functions
26impl TIP20Factory {
27    /// Initializes the TIP20 factory contract.
28    pub fn initialize(&mut self) -> Result<()> {
29        // must ensure the account is not empty, by setting some code
30        self.__initialize()
31    }
32
33    /// Returns true if the factory has been initialized (has code set).
34    pub fn is_initialized(&self) -> Result<bool> {
35        self.storage
36            .with_account_info(TIP20_FACTORY_ADDRESS, |info| Ok(info.code.is_some()))
37    }
38
39    /// Returns true if the address is a valid TIP20 token.
40    ///
41    /// Post-AllegroModerato: Matches the Solidity implementation which checks both:
42    /// 1. The address has the correct TIP20 prefix
43    /// 2. The token ID (lower 8 bytes) is less than tokenIdCounter
44    ///
45    /// Pre-AllegroModerato: Only checks the address prefix for backwards compatibility.
46    pub fn is_tip20(&self, token: Address) -> Result<bool> {
47        if !is_tip20_prefix(token) {
48            return Ok(false);
49        }
50        // Post-AllegroModerato: also check that token ID < tokenIdCounter
51        if self.storage.spec().is_allegro_moderato() {
52            let token_id = U256::from(address_to_token_id_unchecked(token));
53            return Ok(token_id < self.token_id_counter()?);
54        }
55        Ok(true)
56    }
57
58    pub fn create_token(
59        &mut self,
60        sender: Address,
61        call: ITIP20Factory::createTokenCall,
62    ) -> Result<Address> {
63        // TODO: We should update `token_id_counter` to be u64 in storage if we assume we can cast
64        // to u64 here. Or we should update `token_id_to_address` to take a larger value
65        let token_id = self
66            .token_id_counter()?
67            .try_into()
68            .map_err(|_| TempoPrecompileError::under_overflow())?;
69
70        trace!(%sender, %token_id, ?call, "Create token");
71
72        // Ensure that the quote token is a valid TIP20 that is currently deployed.
73        // Note that the token Id increments on each deployment.
74
75        // Post-Allegretto, require that the first TIP20 deployed has a quote token of address(0)
76        if self.storage.spec().is_allegretto() && token_id == 0 {
77            if !call.quoteToken.is_zero() {
78                return Err(TIP20Error::invalid_quote_token().into());
79            }
80        } else if self.storage.spec().is_moderato() {
81            // Post-Moderato: Fixed validation - quote token id must be < current token_id (strictly less than).
82            if !is_tip20_prefix(call.quoteToken)
83                || address_to_token_id_unchecked(call.quoteToken) >= token_id
84            {
85                return Err(TIP20Error::invalid_quote_token().into());
86            }
87        } else {
88            // Pre-Moderato: Original validation with off-by-one bug for consensus compatibility.
89            // The buggy check allowed quote_token_id == token_id to pass.
90            if !is_tip20_prefix(call.quoteToken)
91                || address_to_token_id_unchecked(call.quoteToken) > token_id
92            {
93                return Err(TIP20Error::invalid_quote_token().into());
94            }
95        }
96
97        // Initialize with default fee_recipient (Address::ZERO)
98        // Fee recipient can be set later via setFeeRecipient()
99        TIP20Token::new(token_id).initialize(
100            &call.name,
101            &call.symbol,
102            &call.currency,
103            call.quoteToken,
104            call.admin,
105            Address::ZERO,
106        )?;
107
108        let token_address = token_id_to_address(token_id);
109        let token_id = U256::from(token_id);
110        self.emit_event(TIP20FactoryEvent::TokenCreated(
111            ITIP20Factory::TokenCreated {
112                token: token_address,
113                tokenId: token_id,
114                name: call.name,
115                symbol: call.symbol,
116                currency: call.currency,
117                quoteToken: call.quoteToken,
118                admin: call.admin,
119            },
120        ))?;
121
122        // increase the token counter
123        self.token_id_counter.write(
124            token_id
125                .checked_add(U256::ONE)
126                .ok_or(TempoPrecompileError::under_overflow())?,
127        )?;
128
129        Ok(token_address)
130    }
131
132    pub fn token_id_counter(&self) -> Result<U256> {
133        let counter = self.token_id_counter.read()?;
134
135        // Pre Allegreto, start the counter at 1
136        if !self.storage.spec().is_allegretto() && counter.is_zero() {
137            Ok(U256::ONE)
138        } else {
139            Ok(counter)
140        }
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::{
148        error::TempoPrecompileError,
149        storage::{ContractStorage, StorageCtx, hashmap::HashMapStorageProvider},
150        test_util::TIP20Setup,
151        tip20::tests::initialize_path_usd,
152    };
153    use alloy::primitives::Address;
154    use tempo_chainspec::hardfork::TempoHardfork;
155
156    #[test]
157    fn test_create_token() -> eyre::Result<()> {
158        let mut storage = HashMapStorageProvider::new(1);
159        let sender = Address::random();
160        StorageCtx::enter(&mut storage, || {
161            let mut factory = TIP20Setup::factory()?;
162            let path_usd = TIP20Setup::path_usd(sender).apply()?;
163
164            let call = ITIP20Factory::createTokenCall {
165                name: "Test Token".to_string(),
166                symbol: "TEST".to_string(),
167                currency: "USD".to_string(),
168                quoteToken: path_usd.address(),
169                admin: sender,
170            };
171
172            let token_addr_0 = factory.create_token(sender, call.clone())?;
173            let token_addr_1 = factory.create_token(sender, call)?;
174
175            let token_id_0 = address_to_token_id_unchecked(token_addr_0);
176            let token_id_1 = address_to_token_id_unchecked(token_addr_1);
177            let expected = vec![
178                TIP20FactoryEvent::TokenCreated(ITIP20Factory::TokenCreated {
179                    token: path_usd.address(),
180                    tokenId: U256::ZERO,
181                    name: "PathUSD".to_string(),
182                    symbol: "PUSD".to_string(),
183                    currency: "USD".to_string(),
184                    quoteToken: Address::ZERO,
185                    admin: sender,
186                }),
187                TIP20FactoryEvent::TokenCreated(ITIP20Factory::TokenCreated {
188                    token: token_addr_0,
189                    tokenId: U256::from(token_id_0),
190                    name: "Test Token".to_string(),
191                    symbol: "TEST".to_string(),
192                    currency: "USD".to_string(),
193                    quoteToken: path_usd.address(),
194                    admin: sender,
195                }),
196                TIP20FactoryEvent::TokenCreated(ITIP20Factory::TokenCreated {
197                    token: token_addr_1,
198                    tokenId: U256::from(token_id_1),
199                    name: "Test Token".to_string(),
200                    symbol: "TEST".to_string(),
201                    currency: "USD".to_string(),
202                    quoteToken: path_usd.address(),
203                    admin: sender,
204                }),
205            ];
206            factory.assert_emitted_events(expected);
207
208            Ok(())
209        })
210    }
211
212    #[test]
213    fn test_create_token_invalid_quote_token_post_moderato() -> eyre::Result<()> {
214        let mut storage = HashMapStorageProvider::new(1).with_spec(TempoHardfork::Moderato);
215        let sender = Address::random();
216        StorageCtx::enter(&mut storage, || {
217            let mut factory = TIP20Setup::factory()?;
218
219            let invalid_call = ITIP20Factory::createTokenCall {
220                name: "Test Token".to_string(),
221                symbol: "TEST".to_string(),
222                currency: "USD".to_string(),
223                quoteToken: Address::random(),
224                admin: sender,
225            };
226
227            let result = factory.create_token(sender, invalid_call);
228            assert_eq!(
229                result.unwrap_err(),
230                TempoPrecompileError::TIP20(TIP20Error::invalid_quote_token())
231            );
232            Ok(())
233        })
234    }
235
236    #[test]
237    fn test_create_token_quote_token_not_deployed_post_moderato() -> eyre::Result<()> {
238        let mut storage = HashMapStorageProvider::new(1).with_spec(TempoHardfork::Moderato);
239        let sender = Address::random();
240        StorageCtx::enter(&mut storage, || {
241            let mut factory = TIP20Setup::factory()?;
242
243            let non_existent_tip20 = token_id_to_address(5);
244            let invalid_call = ITIP20Factory::createTokenCall {
245                name: "Test Token".to_string(),
246                symbol: "TEST".to_string(),
247                currency: "USD".to_string(),
248                quoteToken: non_existent_tip20,
249                admin: sender,
250            };
251
252            let result = factory.create_token(sender, invalid_call);
253            assert_eq!(
254                result.unwrap_err(),
255                TempoPrecompileError::TIP20(TIP20Error::invalid_quote_token())
256            );
257            Ok(())
258        })
259    }
260
261    #[test]
262    fn test_create_token_off_by_one_rejected_post_moderato() -> eyre::Result<()> {
263        let mut storage = HashMapStorageProvider::new(1).with_spec(TempoHardfork::Moderato);
264        let sender = Address::random();
265        StorageCtx::enter(&mut storage, || {
266            // Test the off-by-one bug fix: using token_id as quote token should be rejected post-Moderato
267            let mut factory = TIP20Setup::factory()?;
268
269            // Get the current token_id (should be 1)
270            let current_token_id = factory.token_id_counter()?;
271            assert_eq!(current_token_id, U256::from(1));
272
273            // Try to use token_id 1 (the token being created) as the quote token
274            // This should be rejected because token 1 doesn't exist yet
275            let same_id_quote_token = token_id_to_address(1);
276            let call = ITIP20Factory::createTokenCall {
277                name: "Test Token".to_string(),
278                symbol: "TEST".to_string(),
279                currency: "USD".to_string(),
280                quoteToken: same_id_quote_token,
281                admin: sender,
282            };
283
284            let result = factory.create_token(sender, call);
285            // Should fail with InvalidQuoteToken error because token 1 doesn't exist yet (off-by-one)
286            assert_eq!(
287                result.unwrap_err(),
288                TempoPrecompileError::TIP20(TIP20Error::invalid_quote_token())
289            );
290            Ok(())
291        })
292    }
293
294    #[test]
295    fn test_create_token_future_quote_token_pre_moderato() -> eyre::Result<()> {
296        let mut storage = HashMapStorageProvider::new(1).with_spec(TempoHardfork::Moderato);
297        let sender = Address::random();
298        StorageCtx::enter(&mut storage, || {
299            // Test that pre-Moderato SHOULD still validate that quote tokens exist
300            // Using a TIP20 address with ID > current token_id should fail (not yet created)
301            let mut factory = TIP20Setup::factory()?;
302
303            // Current token_id should be 1
304            assert_eq!(factory.token_id_counter()?, U256::from(1));
305
306            // Try to use token ID 5 as quote token (doesn't exist yet)
307            // This should fail factory validation even pre-Moderato
308            let future_quote_token = token_id_to_address(5);
309            let call = ITIP20Factory::createTokenCall {
310                name: "Test Token".to_string(),
311                symbol: "TEST".to_string(),
312                currency: "EUR".to_string(), // Use non-USD to avoid TIP20Token::initialize validation
313                quoteToken: future_quote_token,
314                admin: sender,
315            };
316
317            let result = factory.create_token(sender, call);
318
319            // This should fail with InvalidQuoteToken from factory validation
320            // Currently this test will PASS (not fail) because factory validation is skipped pre-Moderato
321            assert!(
322                result.is_err(),
323                "Should fail when using a not-yet-created token as quote token"
324            );
325            if let Err(e) = result {
326                assert_eq!(
327                    e,
328                    TempoPrecompileError::TIP20(TIP20Error::invalid_quote_token()),
329                    "Should fail with InvalidQuoteToken from factory validation"
330                );
331            }
332            Ok(())
333        })
334    }
335
336    #[test]
337    fn test_create_token_off_by_one_allowed_pre_moderato() -> eyre::Result<()> {
338        let mut storage = HashMapStorageProvider::new(1).with_spec(TempoHardfork::Adagio);
339        let sender = Address::random();
340        StorageCtx::enter(&mut storage, || {
341            let mut factory = TIP20Setup::factory()?;
342
343            // Get the current token_id (should be 1)
344            let current_token_id = factory.token_id_counter()?;
345            assert_eq!(current_token_id, U256::from(1));
346
347            // Try to use token_id 1 (the token being created) as the quote token
348            // Pre-Moderato, the old buggy validation (> instead of >=) allows this to pass
349            let same_id_quote_token = token_id_to_address(1);
350            let call = ITIP20Factory::createTokenCall {
351                name: "Test Token".to_string(),
352                symbol: "TEST".to_string(),
353                currency: "USD".to_string(),
354                quoteToken: same_id_quote_token,
355                admin: sender,
356            };
357
358            let result = factory.create_token(sender, call);
359
360            // Pre-Moderato: the old buggy validation (> token_id) allows quote_token_id == token_id
361            // The operation may succeed or fail with a different error later, but it should NOT
362            // fail with InvalidQuoteToken from validation
363            match result {
364                Ok(_) => {
365                    // Operation succeeded - the buggy validation allowed it through
366                }
367                Err(e) => {
368                    // If it fails, it should NOT be due to InvalidQuoteToken validation
369                    assert!(
370                        !matches!(
371                            e,
372                            TempoPrecompileError::TIP20(TIP20Error::InvalidQuoteToken(_))
373                        ),
374                        "Pre-Moderato should not reject with InvalidQuoteToken when quote_token_id == token_id (buggy > logic)"
375                    );
376                }
377            }
378            Ok(())
379        })
380    }
381
382    #[test]
383    fn test_token_id_post_allegretto() -> eyre::Result<()> {
384        let mut storage = HashMapStorageProvider::new(1).with_spec(TempoHardfork::Allegretto);
385        StorageCtx::enter(&mut storage, || {
386            let factory = TIP20Setup::factory()?;
387
388            let current_token_id = factory.token_id_counter()?;
389            assert_eq!(current_token_id, U256::ZERO);
390            Ok(())
391        })
392    }
393
394    #[test]
395    fn test_create_token_post_allegretto() -> eyre::Result<()> {
396        let mut storage = HashMapStorageProvider::new(1).with_spec(TempoHardfork::Allegretto);
397        let sender = Address::random();
398        StorageCtx::enter(&mut storage, || {
399            let mut factory = TIP20Setup::factory()?;
400
401            let call_fail = ITIP20Factory::createTokenCall {
402                name: "Test".to_string(),
403                symbol: "Test".to_string(),
404                currency: "USD".to_string(),
405                quoteToken: token_id_to_address(0),
406                admin: sender,
407            };
408
409            let result = factory.create_token(sender, call_fail);
410            assert_eq!(
411                result.unwrap_err(),
412                TempoPrecompileError::TIP20(TIP20Error::invalid_quote_token())
413            );
414
415            let call = ITIP20Factory::createTokenCall {
416                name: "Test".to_string(),
417                symbol: "Test".to_string(),
418                currency: "USD".to_string(),
419                quoteToken: Address::ZERO,
420                admin: sender,
421            };
422
423            factory.create_token(sender, call)?;
424            Ok(())
425        })
426    }
427
428    #[test]
429    fn test_is_tip20_post_allegro_moderato() -> eyre::Result<()> {
430        let mut storage = HashMapStorageProvider::new(1).with_spec(TempoHardfork::AllegroModerato);
431        let sender = Address::random();
432
433        StorageCtx::enter(&mut storage, || {
434            // initialize_path_usd deploys PathUSD via factory for post-Allegretto specs,
435            // which properly increments tokenIdCounter to 1
436            initialize_path_usd(sender)?;
437
438            let mut factory = TIP20Factory::new();
439            factory.initialize()?;
440
441            // Verify tokenIdCounter was set by factory deployment
442            assert_eq!(factory.token_id_counter()?, U256::from(1));
443
444            // PATH_USD (token ID 0) should be valid since 0 < 1
445            assert!(factory.is_tip20(crate::PATH_USD_ADDRESS)?);
446
447            // Token ID >= tokenIdCounter should be invalid
448            let token_id_counter: u64 = factory.token_id_counter()?.to();
449            let non_existent_tip20 = token_id_to_address(token_id_counter + 100);
450            assert!(!factory.is_tip20(non_existent_tip20)?);
451
452            // Non-TIP20 address should be invalid
453            assert!(!factory.is_tip20(Address::random())?);
454
455            Ok(())
456        })
457    }
458
459    #[test]
460    fn test_is_tip20_pre_allegro_moderato() -> eyre::Result<()> {
461        // Pre-AllegroModerato: only check prefix, not tokenIdCounter
462        let mut storage = HashMapStorageProvider::new(1).with_spec(TempoHardfork::Allegretto);
463        let sender = Address::random();
464
465        StorageCtx::enter(&mut storage, || {
466            initialize_path_usd(sender)?;
467
468            let mut factory = TIP20Factory::new();
469            factory.initialize()?;
470
471            // PATH_USD (token ID 0) should be valid
472            assert!(factory.is_tip20(crate::PATH_USD_ADDRESS)?);
473
474            // Token ID >= tokenIdCounter should still be valid (only checks prefix pre-AllegroModerato)
475            let token_id_counter: u64 = factory.token_id_counter()?.to();
476            let non_existent_tip20 = token_id_to_address(token_id_counter + 100);
477            assert!(
478                factory.is_tip20(non_existent_tip20)?,
479                "Pre-AllegroModerato: should only check prefix"
480            );
481
482            // Non-TIP20 address should still be invalid (wrong prefix)
483            assert!(!factory.is_tip20(Address::random())?);
484
485            Ok(())
486        })
487    }
488
489    #[test]
490    fn test_is_tip20_prefix() -> eyre::Result<()> {
491        let mut storage = HashMapStorageProvider::new(1);
492
493        StorageCtx::enter(&mut storage, || {
494            // Valid TIP20 address
495            let token_id = rand::random::<u64>();
496            let token = token_id_to_address(token_id);
497            assert!(is_tip20_prefix(token));
498
499            // Random address is not TIP20
500            let random = Address::random();
501            assert!(!is_tip20_prefix(random));
502
503            Ok(())
504        })
505    }
506}