Skip to main content

tempo_precompiles/tip_fee_manager/
mod.rs

1//! [Fee manager] precompile for transaction fee collection, distribution, and token swaps.
2//!
3//! [Fee manager]: <https://docs.tempo.xyz/protocol/fees>
4
5pub mod amm;
6pub mod dispatch;
7
8use crate::{
9    error::{Result, TempoPrecompileError},
10    storage::{Handler, Mapping},
11    tip_fee_manager::amm::{Pool, PoolKey, compute_amount_out},
12    tip20::{ITIP20, TIP20Token, validate_usd_currency},
13    tip20_factory::TIP20Factory,
14};
15use alloy::primitives::B256;
16pub use tempo_contracts::precompiles::{
17    DEFAULT_FEE_TOKEN, FeeManagerError, FeeManagerEvent, IFeeManager, ITIPFeeAMM,
18    TIP_FEE_MANAGER_ADDRESS, TIPFeeAMMError, TIPFeeAMMEvent,
19};
20// Re-export PoolKey for backward compatibility with tests
21use alloy::primitives::{Address, U256, uint};
22use tempo_precompiles_macros::contract;
23
24/// Fee manager precompile that handles transaction fee collection and distribution.
25///
26/// Users and validators choose their preferred TIP-20 fee token. When they differ, fees are
27/// swapped through the built-in AMM (`TIPFeeAMM`).
28///
29/// The struct fields define the on-chain storage layout; the `#[contract]` macro generates the
30/// storage handlers which provide an ergonomic way to interact with the EVM state.
31#[contract(addr = TIP_FEE_MANAGER_ADDRESS)]
32pub struct TipFeeManager {
33    validator_tokens: Mapping<Address, Address>,
34    user_tokens: Mapping<Address, Address>,
35    collected_fees: Mapping<Address, Mapping<Address, U256>>,
36    pools: Mapping<B256, Pool>,
37    total_supply: Mapping<B256, U256>,
38    liquidity_balances: Mapping<B256, Mapping<Address, U256>>,
39
40    // WARNING(rusowsky): transient storage slots must always be placed at the very end until the `contract`
41    // macro is refactored and has 2 independent layouts (persistent and transient).
42    // If new (persistent) storage fields need to be added to the precompile, they must go above this one.
43    /// T1C+: Tracks liquidity reserved for a pending fee swap during `collect_fee_pre_tx`.
44    /// Checked by `burn` and `rebalance_swap` to prevent withdrawals that would violate the reservation.
45    pending_fee_swap_reservation: Mapping<B256, u128>,
46}
47
48impl TipFeeManager {
49    /// Swap fee in basis points (0.25%).
50    pub const FEE_BPS: u64 = 25;
51    /// Basis-point denominator (10 000 = 100%).
52    pub const BASIS_POINTS: u64 = 10000;
53    /// Minimum TIP-20 balance required for fee operations (1e9).
54    pub const MINIMUM_BALANCE: U256 = uint!(1_000_000_000_U256);
55
56    /// Initializes the fee manager precompile.
57    pub fn initialize(&mut self) -> Result<()> {
58        self.__initialize()
59    }
60
61    /// Returns the validator's preferred fee token, falling back to [`DEFAULT_FEE_TOKEN`].
62    pub fn get_validator_token(&self, beneficiary: Address) -> Result<Address> {
63        let token = self.validator_tokens[beneficiary].read()?;
64
65        if token.is_zero() {
66            Ok(DEFAULT_FEE_TOKEN)
67        } else {
68            Ok(token)
69        }
70    }
71
72    /// Sets the caller's preferred fee token as a validator.
73    ///
74    /// Rejects the call if `sender` is the current block's beneficiary (prevents mid-block
75    /// fee-token changes) or if the token is not a valid USD-denominated TIP-20 registered in
76    /// [`TIP20Factory`].
77    ///
78    /// # Errors
79    /// - `InvalidToken` — token is not a deployed TIP-20 in [`TIP20Factory`]
80    /// - `CannotChangeWithinBlock` — `sender` equals the current block `beneficiary`
81    /// - `InvalidCurrency` — token is not USD-denominated
82    pub fn set_validator_token(
83        &mut self,
84        sender: Address,
85        call: IFeeManager::setValidatorTokenCall,
86        beneficiary: Address,
87    ) -> Result<()> {
88        // Validate that the token is a valid deployed TIP20
89        if !TIP20Factory::new().is_tip20(call.token)? {
90            return Err(FeeManagerError::invalid_token().into());
91        }
92
93        // Prevent changing within the validator's own block
94        if sender == beneficiary {
95            return Err(FeeManagerError::cannot_change_within_block().into());
96        }
97
98        // Validate that the fee token is USD
99        validate_usd_currency(call.token)?;
100
101        self.validator_tokens[sender].write(call.token)?;
102
103        // Emit ValidatorTokenSet event
104        self.emit_event(FeeManagerEvent::ValidatorTokenSet(
105            IFeeManager::ValidatorTokenSet {
106                validator: sender,
107                token: call.token,
108            },
109        ))
110    }
111
112    /// Sets the caller's preferred fee token as a user. Must be a valid USD-denominated TIP-20
113    /// registered in [`TIP20Factory`].
114    ///
115    /// # Errors
116    /// - `InvalidToken` — token is not a deployed TIP-20 in [`TIP20Factory`]
117    /// - `InvalidCurrency` — token is not USD-denominated
118    pub fn set_user_token(
119        &mut self,
120        sender: Address,
121        call: IFeeManager::setUserTokenCall,
122    ) -> Result<()> {
123        // Validate that the token is a valid deployed TIP20
124        if !TIP20Factory::new().is_tip20(call.token)? {
125            return Err(FeeManagerError::invalid_token().into());
126        }
127
128        // Validate that the fee token is USD
129        validate_usd_currency(call.token)?;
130
131        self.user_tokens[sender].write(call.token)?;
132
133        // Emit UserTokenSet event
134        self.emit_event(FeeManagerEvent::UserTokenSet(IFeeManager::UserTokenSet {
135            user: sender,
136            token: call.token,
137        }))
138    }
139
140    /// Collects fees from `fee_payer` before transaction execution.
141    ///
142    /// Transfers `max_amount` of `user_token` to the fee manager via [`TIP20Token`] and, if the
143    /// validator prefers a different token, verifies sufficient pool liquidity
144    /// (reserving it on T1C+). Returns the user's fee token.
145    ///
146    /// # Errors
147    /// - `InvalidToken` — `user_token` does not have a valid TIP-20 prefix
148    /// - `PolicyForbids` — TIP-403 policy rejects the fee token transfer
149    /// - `InsufficientLiquidity` — AMM pool lacks liquidity for the fee swap
150    pub fn collect_fee_pre_tx(
151        &mut self,
152        fee_payer: Address,
153        user_token: Address,
154        max_amount: U256,
155        beneficiary: Address,
156    ) -> Result<Address> {
157        // Get the validator's token preference
158        let validator_token = self.get_validator_token(beneficiary)?;
159
160        let mut tip20_token = TIP20Token::from_address(user_token)?;
161
162        // Ensure that user and FeeManager are authorized to interact with the token
163        tip20_token.ensure_transfer_authorized(fee_payer, self.address)?;
164        tip20_token.transfer_fee_pre_tx(fee_payer, max_amount)?;
165
166        if user_token != validator_token {
167            let pool_id = PoolKey::new(user_token, validator_token).get_id();
168            let amount_out_needed = self.check_sufficient_liquidity(pool_id, max_amount)?;
169
170            if self.storage.spec().is_t1c() {
171                self.reserve_pool_liquidity(pool_id, amount_out_needed)?;
172            }
173        }
174
175        // Return the user's token preference
176        Ok(user_token)
177    }
178
179    /// Finalizes fee collection after transaction execution.
180    ///
181    /// Refunds unused `user_token` to `fee_payer` via [`TIP20Token`], executes the fee swap
182    /// through the AMM pool if tokens differ, and accumulates fees for the validator.
183    ///
184    /// # Errors
185    /// - `InvalidToken` — `fee_token` does not have a valid TIP-20 prefix
186    /// - `InsufficientLiquidity` — AMM pool lacks liquidity for the fee swap
187    /// - `UnderOverflow` — collected-fee accumulator overflows
188    pub fn collect_fee_post_tx(
189        &mut self,
190        fee_payer: Address,
191        actual_spending: U256,
192        refund_amount: U256,
193        fee_token: Address,
194        beneficiary: Address,
195    ) -> Result<()> {
196        // Refund unused tokens to user
197        let mut tip20_token = TIP20Token::from_address(fee_token)?;
198        tip20_token.transfer_fee_post_tx(fee_payer, refund_amount, actual_spending)?;
199
200        // Execute fee swap and track collected fees
201        let validator_token = self.get_validator_token(beneficiary)?;
202
203        if fee_token != validator_token {
204            // Record the pool if there was a non-zero swap
205            if !actual_spending.is_zero() {
206                // Execute fee swap immediately and accumulate fees
207                self.execute_fee_swap(fee_token, validator_token, actual_spending)?;
208            }
209        }
210
211        let amount = if fee_token == validator_token {
212            actual_spending
213        } else {
214            compute_amount_out(actual_spending)?
215        };
216
217        self.increment_collected_fees(beneficiary, validator_token, amount)?;
218
219        Ok(())
220    }
221
222    /// Increment collected fees for a specific validator and token combination.
223    fn increment_collected_fees(
224        &mut self,
225        validator: Address,
226        token: Address,
227        amount: U256,
228    ) -> Result<()> {
229        if amount.is_zero() {
230            return Ok(());
231        }
232
233        let collected_fees = self.collected_fees[validator][token].read()?;
234        self.collected_fees[validator][token].write(
235            collected_fees
236                .checked_add(amount)
237                .ok_or(TempoPrecompileError::under_overflow())?,
238        )?;
239
240        Ok(())
241    }
242
243    /// Transfers a validator's accumulated fee balance to their address via [`TIP20Token`] and
244    /// zeroes the ledger. No-ops when the balance is zero.
245    ///
246    /// # Errors
247    /// - `InvalidToken` — `token` does not have a valid TIP-20 prefix
248    pub fn distribute_fees(&mut self, validator: Address, token: Address) -> Result<()> {
249        let amount = self.collected_fees[validator][token].read()?;
250        if amount.is_zero() {
251            return Ok(());
252        }
253        self.collected_fees[validator][token].write(U256::ZERO)?;
254
255        // Transfer fees to validator
256        let mut tip20_token = TIP20Token::from_address(token)?;
257        tip20_token.transfer(
258            self.address,
259            ITIP20::transferCall {
260                to: validator,
261                amount,
262            },
263        )?;
264
265        // Emit FeesDistributed event
266        self.emit_event(FeeManagerEvent::FeesDistributed(
267            IFeeManager::FeesDistributed {
268                validator,
269                token,
270                amount,
271            },
272        ))?;
273
274        Ok(())
275    }
276
277    /// Reads the stored fee token preference for a user.
278    pub fn user_tokens(&self, call: IFeeManager::userTokensCall) -> Result<Address> {
279        self.user_tokens[call.user].read()
280    }
281
282    /// Reads the stored fee token preference for a validator, defaulting to [`DEFAULT_FEE_TOKEN`].
283    pub fn validator_tokens(&self, call: IFeeManager::validatorTokensCall) -> Result<Address> {
284        let token = self.validator_tokens[call.validator].read()?;
285
286        if token.is_zero() {
287            Ok(DEFAULT_FEE_TOKEN)
288        } else {
289            Ok(token)
290        }
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use tempo_contracts::precompiles::TIP20Error;
297
298    use super::*;
299    use crate::{
300        TIP_FEE_MANAGER_ADDRESS,
301        error::TempoPrecompileError,
302        storage::{ContractStorage, StorageCtx, hashmap::HashMapStorageProvider},
303        test_util::TIP20Setup,
304        tip20::{ITIP20, TIP20Token},
305    };
306
307    #[test]
308    fn test_set_user_token() -> eyre::Result<()> {
309        let mut storage = HashMapStorageProvider::new(1);
310        let user = Address::random();
311        StorageCtx::enter(&mut storage, || {
312            let token = TIP20Setup::create("Test", "TST", user).apply()?;
313
314            // TODO: loop through and deploy and set user token for some range
315
316            let mut fee_manager = TipFeeManager::new();
317
318            let call = IFeeManager::setUserTokenCall {
319                token: token.address(),
320            };
321            let result = fee_manager.set_user_token(user, call);
322            assert!(result.is_ok());
323
324            let call = IFeeManager::userTokensCall { user };
325            assert_eq!(fee_manager.user_tokens(call)?, token.address());
326
327            Ok(())
328        })
329    }
330
331    #[test]
332    fn test_set_validator_token() -> eyre::Result<()> {
333        let mut storage = HashMapStorageProvider::new(1);
334        let validator = Address::random();
335        let admin = Address::random();
336        let beneficiary = Address::random();
337        StorageCtx::enter(&mut storage, || {
338            let token = TIP20Setup::create("Test", "TST", admin).apply()?;
339            let mut fee_manager = TipFeeManager::new();
340
341            let call = IFeeManager::setValidatorTokenCall {
342                token: token.address(),
343            };
344
345            // Should fail when validator == beneficiary (same block check)
346            let result = fee_manager.set_validator_token(validator, call.clone(), validator);
347            assert_eq!(
348                result,
349                Err(TempoPrecompileError::FeeManagerError(
350                    FeeManagerError::cannot_change_within_block()
351                ))
352            );
353
354            // Should succeed with different beneficiary
355            let result = fee_manager.set_validator_token(validator, call, beneficiary);
356            assert!(result.is_ok());
357
358            let query_call = IFeeManager::validatorTokensCall { validator };
359            let returned_token = fee_manager.validator_tokens(query_call)?;
360            assert_eq!(returned_token, token.address());
361
362            Ok(())
363        })
364    }
365
366    #[test]
367    fn test_set_validator_token_cannot_change_within_block() -> eyre::Result<()> {
368        let mut storage = HashMapStorageProvider::new(1);
369        let validator = Address::random();
370        let beneficiary = Address::random();
371        let admin = Address::random();
372        StorageCtx::enter(&mut storage, || {
373            let token = TIP20Setup::create("Test", "TST", admin).apply()?;
374            let mut fee_manager = TipFeeManager::new();
375
376            let call = IFeeManager::setValidatorTokenCall {
377                token: token.address(),
378            };
379
380            // Setting validator token when not beneficiary should succeed
381            let result = fee_manager.set_validator_token(validator, call.clone(), beneficiary);
382            assert!(result.is_ok());
383
384            // But if validator is the beneficiary, should fail with CannotChangeWithinBlock
385            let result = fee_manager.set_validator_token(validator, call, validator);
386            assert_eq!(
387                result,
388                Err(TempoPrecompileError::FeeManagerError(
389                    FeeManagerError::cannot_change_within_block()
390                ))
391            );
392
393            Ok(())
394        })
395    }
396
397    #[test]
398    fn test_collect_fee_pre_tx() -> eyre::Result<()> {
399        let mut storage = HashMapStorageProvider::new(1);
400        let user = Address::random();
401        let validator = Address::random();
402        let beneficiary = Address::random();
403        StorageCtx::enter(&mut storage, || {
404            let max_amount = U256::from(10000);
405
406            let token = TIP20Setup::create("Test", "TST", user)
407                .with_issuer(user)
408                .with_mint(user, U256::from(u64::MAX))
409                .with_approval(user, TIP_FEE_MANAGER_ADDRESS, U256::MAX)
410                .apply()?;
411
412            let mut fee_manager = TipFeeManager::new();
413
414            // Set validator token (use beneficiary to avoid CannotChangeWithinBlock)
415            fee_manager.set_validator_token(
416                validator,
417                IFeeManager::setValidatorTokenCall {
418                    token: token.address(),
419                },
420                beneficiary,
421            )?;
422
423            // Set user token
424            fee_manager.set_user_token(
425                user,
426                IFeeManager::setUserTokenCall {
427                    token: token.address(),
428                },
429            )?;
430
431            // Call collect_fee_pre_tx directly
432            let result =
433                fee_manager.collect_fee_pre_tx(user, token.address(), max_amount, validator);
434            assert!(result.is_ok());
435            assert_eq!(result?, token.address());
436
437            Ok(())
438        })
439    }
440
441    #[test]
442    fn test_collect_fee_post_tx() -> eyre::Result<()> {
443        let mut storage = HashMapStorageProvider::new(1);
444        let user = Address::random();
445        let admin = Address::random();
446        let validator = Address::random();
447        let beneficiary = Address::random();
448        StorageCtx::enter(&mut storage, || {
449            let actual_used = U256::from(6000);
450            let refund_amount = U256::from(4000);
451
452            // Mint to FeeManager (simulating collect_fee_pre_tx already happened)
453            let token = TIP20Setup::create("Test", "TST", admin)
454                .with_issuer(admin)
455                .with_mint(TIP_FEE_MANAGER_ADDRESS, U256::from(100000000000000_u64))
456                .apply()?;
457
458            let mut fee_manager = TipFeeManager::new();
459
460            // Set validator token (use beneficiary to avoid CannotChangeWithinBlock)
461            fee_manager.set_validator_token(
462                validator,
463                IFeeManager::setValidatorTokenCall {
464                    token: token.address(),
465                },
466                beneficiary,
467            )?;
468
469            // Set user token
470            fee_manager.set_user_token(
471                user,
472                IFeeManager::setUserTokenCall {
473                    token: token.address(),
474                },
475            )?;
476
477            // Call collect_fee_post_tx directly
478            let result = fee_manager.collect_fee_post_tx(
479                user,
480                actual_used,
481                refund_amount,
482                token.address(),
483                validator,
484            );
485            assert!(result.is_ok());
486
487            // Verify fees were tracked
488            let tracked_amount = fee_manager.collected_fees[validator][token.address()].read()?;
489            assert_eq!(tracked_amount, actual_used);
490
491            // Verify user got the refund
492            let balance = token.balance_of(ITIP20::balanceOfCall { account: user })?;
493            assert_eq!(balance, refund_amount);
494
495            Ok(())
496        })
497    }
498
499    #[test]
500    fn test_rejects_non_usd() -> eyre::Result<()> {
501        let mut storage = HashMapStorageProvider::new(1);
502        let admin = Address::random();
503        let user = Address::random();
504        let validator = Address::random();
505        let beneficiary = Address::random();
506        StorageCtx::enter(&mut storage, || {
507            // Create a non-USD token
508            let non_usd_token = TIP20Setup::create("NonUSD", "EUR", admin)
509                .currency("EUR")
510                .apply()?;
511
512            let mut fee_manager = TipFeeManager::new();
513
514            // Try to set non-USD as user token - should fail
515            let call = IFeeManager::setUserTokenCall {
516                token: non_usd_token.address(),
517            };
518            let result = fee_manager.set_user_token(user, call);
519            assert!(matches!(
520                result,
521                Err(TempoPrecompileError::TIP20(TIP20Error::InvalidCurrency(_)))
522            ));
523
524            // Try to set non-USD as validator token - should also fail
525            let call = IFeeManager::setValidatorTokenCall {
526                token: non_usd_token.address(),
527            };
528            let result = fee_manager.set_validator_token(validator, call, beneficiary);
529            assert!(matches!(
530                result,
531                Err(TempoPrecompileError::TIP20(TIP20Error::InvalidCurrency(_)))
532            ));
533
534            Ok(())
535        })
536    }
537
538    /// Test collect_fee_pre_tx with different tokens
539    /// Verifies that liquidity is checked (not reserved) and no swap happens yet
540    #[test]
541    fn test_collect_fee_pre_tx_different_tokens() -> eyre::Result<()> {
542        let mut storage = HashMapStorageProvider::new(1);
543        let admin = Address::random();
544        let user = Address::random();
545        let validator = Address::random();
546
547        StorageCtx::enter(&mut storage, || {
548            // Create two different tokens
549            let user_token = TIP20Setup::create("UserToken", "UTK", admin)
550                .with_issuer(admin)
551                .with_mint(user, U256::from(10000))
552                .with_approval(user, TIP_FEE_MANAGER_ADDRESS, U256::MAX)
553                .apply()?;
554
555            let validator_token = TIP20Setup::create("ValidatorToken", "VTK", admin)
556                .with_issuer(admin)
557                .with_mint(TIP_FEE_MANAGER_ADDRESS, U256::from(10000))
558                .apply()?;
559
560            let mut fee_manager = TipFeeManager::new();
561
562            // Setup pool with liquidity
563            let pool_id = fee_manager.pool_id(user_token.address(), validator_token.address());
564            fee_manager.pools[pool_id].write(crate::tip_fee_manager::amm::Pool {
565                reserve_user_token: 10000,
566                reserve_validator_token: 10000,
567            })?;
568
569            // Set validator's preferred token
570            fee_manager.set_validator_token(
571                validator,
572                IFeeManager::setValidatorTokenCall {
573                    token: validator_token.address(),
574                },
575                Address::random(),
576            )?;
577
578            let max_amount = U256::from(1000);
579
580            // Call collect_fee_pre_tx
581            fee_manager.collect_fee_pre_tx(user, user_token.address(), max_amount, validator)?;
582
583            // With different tokens:
584            // - Liquidity is checked (not reserved)
585            // - No swap happens yet (swap happens in collect_fee_post_tx)
586            // - collected_fees should be zero
587            let collected =
588                fee_manager.collected_fees[validator][validator_token.address()].read()?;
589            assert_eq!(
590                collected,
591                U256::ZERO,
592                "Different tokens: no fees accumulated in pre_tx (swap happens in post_tx)"
593            );
594
595            // Pool reserves should NOT be updated yet
596            let pool = fee_manager.pools[pool_id].read()?;
597            assert_eq!(
598                pool.reserve_user_token, 10000,
599                "Reserves unchanged in pre_tx"
600            );
601            assert_eq!(
602                pool.reserve_validator_token, 10000,
603                "Reserves unchanged in pre_tx"
604            );
605
606            Ok(())
607        })
608    }
609
610    #[test]
611    fn test_collect_fee_post_tx_immediate_swap() -> eyre::Result<()> {
612        let mut storage = HashMapStorageProvider::new(1);
613        let admin = Address::random();
614        let user = Address::random();
615        let validator = Address::random();
616
617        StorageCtx::enter(&mut storage, || {
618            let user_token = TIP20Setup::create("UserToken", "UTK", admin)
619                .with_issuer(admin)
620                .with_mint(user, U256::from(10000))
621                .with_mint(TIP_FEE_MANAGER_ADDRESS, U256::from(10000))
622                .with_approval(user, TIP_FEE_MANAGER_ADDRESS, U256::MAX)
623                .apply()?;
624
625            let validator_token = TIP20Setup::create("ValidatorToken", "VTK", admin)
626                .with_issuer(admin)
627                .with_mint(TIP_FEE_MANAGER_ADDRESS, U256::from(10000))
628                .apply()?;
629
630            let mut fee_manager = TipFeeManager::new();
631
632            let pool_id = fee_manager.pool_id(user_token.address(), validator_token.address());
633            fee_manager.pools[pool_id].write(crate::tip_fee_manager::amm::Pool {
634                reserve_user_token: 10000,
635                reserve_validator_token: 10000,
636            })?;
637
638            fee_manager.set_validator_token(
639                validator,
640                IFeeManager::setValidatorTokenCall {
641                    token: validator_token.address(),
642                },
643                Address::random(),
644            )?;
645
646            let max_amount = U256::from(1000);
647            let actual_spending = U256::from(800);
648            let refund_amount = U256::from(200);
649
650            // First call collect_fee_pre_tx (checks liquidity)
651            fee_manager.collect_fee_pre_tx(user, user_token.address(), max_amount, validator)?;
652
653            // Then call collect_fee_post_tx (executes swap immediately)
654            fee_manager.collect_fee_post_tx(
655                user,
656                actual_spending,
657                refund_amount,
658                user_token.address(),
659                validator,
660            )?;
661
662            // Expected output: 800 * 9970 / 10000 = 797
663            let expected_fee_amount = (actual_spending * U256::from(9970)) / U256::from(10000);
664            let collected =
665                fee_manager.collected_fees[validator][validator_token.address()].read()?;
666            assert_eq!(collected, expected_fee_amount);
667
668            // Pool reserves should be updated
669            let pool = fee_manager.pools[pool_id].read()?;
670            assert_eq!(pool.reserve_user_token, 10000 + 800);
671            assert_eq!(pool.reserve_validator_token, 10000 - 797);
672
673            // User balance: started with 10000, paid 1000 in pre_tx, got 200 refund = 9200
674            let tip20_token = TIP20Token::from_address(user_token.address())?;
675            let user_balance = tip20_token.balance_of(ITIP20::balanceOfCall { account: user })?;
676            assert_eq!(user_balance, U256::from(10000) - max_amount + refund_amount);
677
678            Ok(())
679        })
680    }
681
682    /// Test collect_fee_pre_tx fails with insufficient liquidity
683    #[test]
684    fn test_collect_fee_pre_tx_insufficient_liquidity() -> eyre::Result<()> {
685        let mut storage = HashMapStorageProvider::new(1);
686        let admin = Address::random();
687        let user = Address::random();
688        let validator = Address::random();
689
690        StorageCtx::enter(&mut storage, || {
691            let user_token = TIP20Setup::create("UserToken", "UTK", admin)
692                .with_issuer(admin)
693                .with_mint(user, U256::from(10000))
694                .with_approval(user, TIP_FEE_MANAGER_ADDRESS, U256::MAX)
695                .apply()?;
696
697            let validator_token = TIP20Setup::create("ValidatorToken", "VTK", admin)
698                .with_issuer(admin)
699                .with_mint(TIP_FEE_MANAGER_ADDRESS, U256::from(100))
700                .apply()?;
701
702            let mut fee_manager = TipFeeManager::new();
703
704            let pool_id = fee_manager.pool_id(user_token.address(), validator_token.address());
705            // Pool with very little validator token liquidity
706            fee_manager.pools[pool_id].write(crate::tip_fee_manager::amm::Pool {
707                reserve_user_token: 10000,
708                reserve_validator_token: 100,
709            })?;
710
711            fee_manager.set_validator_token(
712                validator,
713                IFeeManager::setValidatorTokenCall {
714                    token: validator_token.address(),
715                },
716                Address::random(),
717            )?;
718
719            // Try to collect fee that would require more liquidity than available
720            // 1000 * 0.997 = 997 output needed, but only 100 available
721            let max_amount = U256::from(1000);
722
723            let result =
724                fee_manager.collect_fee_pre_tx(user, user_token.address(), max_amount, validator);
725
726            assert!(result.is_err(), "Should fail with insufficient liquidity");
727
728            Ok(())
729        })
730    }
731
732    /// Test distribute_fees with zero balance is a no-op
733    #[test]
734    fn test_distribute_fees_zero_balance() -> eyre::Result<()> {
735        let mut storage = HashMapStorageProvider::new(1);
736        let admin = Address::random();
737        let validator = Address::random();
738
739        StorageCtx::enter(&mut storage, || {
740            let token = TIP20Setup::create("TestToken", "TEST", admin)
741                .with_issuer(admin)
742                .apply()?;
743
744            let mut fee_manager = TipFeeManager::new();
745
746            fee_manager.set_validator_token(
747                validator,
748                IFeeManager::setValidatorTokenCall {
749                    token: token.address(),
750                },
751                Address::random(),
752            )?;
753
754            // collected_fees is zero by default
755            let collected = fee_manager.collected_fees[validator][token.address()].read()?;
756            assert_eq!(collected, U256::ZERO);
757
758            // distribute_fees should be a no-op
759            let result = fee_manager.distribute_fees(validator, token.address());
760            assert!(result.is_ok(), "Should succeed even with zero balance");
761
762            // Validator balance should still be zero
763            let tip20_token = TIP20Token::from_address(token.address())?;
764            let balance = tip20_token.balance_of(ITIP20::balanceOfCall { account: validator })?;
765            assert_eq!(balance, U256::ZERO);
766
767            Ok(())
768        })
769    }
770
771    /// Test distribute_fees transfers accumulated fees to validator
772    #[test]
773    fn test_distribute_fees() -> eyre::Result<()> {
774        let mut storage = HashMapStorageProvider::new(1);
775        let admin = Address::random();
776        let validator = Address::random();
777
778        StorageCtx::enter(&mut storage, || {
779            // Initialize token and give fee manager some tokens
780            let token = TIP20Setup::create("TestToken", "TEST", admin)
781                .with_issuer(admin)
782                .with_mint(TIP_FEE_MANAGER_ADDRESS, U256::from(1000))
783                .apply()?;
784
785            let mut fee_manager = TipFeeManager::new();
786
787            // Set validator's preferred token
788            fee_manager.set_validator_token(
789                validator,
790                IFeeManager::setValidatorTokenCall {
791                    token: token.address(),
792                },
793                Address::random(), // beneficiary != validator
794            )?;
795
796            // Simulate accumulated fees
797            let fee_amount = U256::from(500);
798            fee_manager.collected_fees[validator][token.address()].write(fee_amount)?;
799
800            // Check validator balance before
801            let tip20_token = TIP20Token::from_address(token.address())?;
802            let balance_before =
803                tip20_token.balance_of(ITIP20::balanceOfCall { account: validator })?;
804            assert_eq!(balance_before, U256::ZERO);
805
806            // Distribute fees
807            let mut fee_manager = TipFeeManager::new();
808            fee_manager.distribute_fees(validator, token.address())?;
809
810            // Verify validator received the fees
811            let tip20_token = TIP20Token::from_address(token.address())?;
812            let balance_after =
813                tip20_token.balance_of(ITIP20::balanceOfCall { account: validator })?;
814            assert_eq!(balance_after, fee_amount);
815
816            // Verify collected fees cleared
817            let fee_manager = TipFeeManager::new();
818            let remaining = fee_manager.collected_fees[validator][token.address()].read()?;
819            assert_eq!(remaining, U256::ZERO);
820
821            Ok(())
822        })
823    }
824
825    #[test]
826    fn test_initialize_sets_storage_state() -> eyre::Result<()> {
827        let mut storage = HashMapStorageProvider::new(1);
828        StorageCtx::enter(&mut storage, || {
829            let mut fee_manager = TipFeeManager::new();
830
831            // Before init, should not be initialized
832            assert!(!fee_manager.is_initialized()?);
833
834            // Initialize
835            fee_manager.initialize()?;
836
837            // After init, should be initialized
838            assert!(fee_manager.is_initialized()?);
839
840            // New handle should still see initialized state
841            let fee_manager2 = TipFeeManager::new();
842            assert!(fee_manager2.is_initialized()?);
843
844            Ok(())
845        })
846    }
847}