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