1pub mod amm;
6pub mod dispatch;
7
8use crate::{
9 error::{Result, TempoPrecompileError},
10 storage::{Handler, Mapping},
11 tip_fee_manager::amm::{FeeRoute, Pool, 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#[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 pending_fee_swap_reservation: Mapping<B256, u128>,
44
45 two_hop_intermediate: Address,
51}
52
53impl TipFeeManager {
54 pub const FEE_BPS: u64 = 25;
56 pub const BASIS_POINTS: u64 = 10000;
58 pub const MINIMUM_BALANCE: U256 = uint!(1_000_000_000_U256);
60
61 pub fn initialize(&mut self) -> Result<()> {
63 self.__initialize()
64 }
65
66 pub fn get_validator_token(&self, beneficiary: Address) -> Result<Address> {
68 let token = self.validator_tokens[beneficiary].read()?;
69
70 if token.is_zero() {
71 Ok(DEFAULT_FEE_TOKEN)
72 } else {
73 Ok(token)
74 }
75 }
76
77 pub fn set_validator_token(
88 &mut self,
89 sender: Address,
90 call: IFeeManager::setValidatorTokenCall,
91 beneficiary: Address,
92 ) -> Result<()> {
93 if !TIP20Factory::new().is_tip20(call.token)? {
95 return Err(FeeManagerError::invalid_token().into());
96 }
97
98 if sender == beneficiary {
100 return Err(FeeManagerError::cannot_change_within_block().into());
101 }
102
103 validate_usd_currency(call.token)?;
105
106 self.validator_tokens[sender].write(call.token)?;
107
108 self.emit_event(FeeManagerEvent::validator_token_set(sender, call.token))
110 }
111
112 pub fn set_user_token(
119 &mut self,
120 sender: Address,
121 call: IFeeManager::setUserTokenCall,
122 ) -> Result<()> {
123 if !TIP20Factory::new().is_tip20(call.token)? {
125 return Err(FeeManagerError::invalid_token().into());
126 }
127
128 validate_usd_currency(call.token)?;
130
131 if self.storage.spec().is_t3() {
134 let current = self.user_tokens[sender].read()?;
135 if current == call.token {
136 return Ok(());
137 }
138 }
139
140 self.user_tokens[sender].write(call.token)?;
141
142 self.emit_event(FeeManagerEvent::user_token_set(sender, call.token))
144 }
145
146 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 let validator_token = self.get_validator_token(beneficiary)?;
167
168 let mut tip20_token = TIP20Token::from_address(user_token)?;
169
170 tip20_token.ensure_transfer_authorized(fee_payer, self.address)?;
172 tip20_token.transfer_fee_pre_tx(fee_payer, max_amount)?;
173
174 if !skip_liquidity_check {
175 let (route, ..) = self.plan_fee_route(user_token, validator_token, max_amount)?;
176 let route = route.ok_or_else(TIPFeeAMMError::insufficient_liquidity)?;
177 self.reserve_fee_liquidity(user_token, validator_token, max_amount, route)?;
178 }
179
180 Ok(user_token)
182 }
183
184 fn reserve_fee_liquidity(
186 &mut self,
187 user_token: Address,
188 validator_token: Address,
189 max_amount: U256,
190 route: FeeRoute,
191 ) -> Result<()> {
192 match route {
193 FeeRoute::SameToken => {}
194 FeeRoute::Direct if self.storage.spec().is_t1c() => {
195 let amount_out: u128 = compute_amount_out(max_amount)?
196 .try_into()
197 .map_err(|_| TempoPrecompileError::under_overflow())?;
198 self.reserve_pool_liquidity(self.pool_id(user_token, validator_token), amount_out)?;
199 }
200 FeeRoute::Direct => {}
201 FeeRoute::TwoHop(intermediate) => {
202 let out1: u128 = compute_amount_out(max_amount)?
204 .try_into()
205 .map_err(|_| TempoPrecompileError::under_overflow())?;
206 let out2: u128 = compute_amount_out(U256::from(out1))?
207 .try_into()
208 .map_err(|_| TempoPrecompileError::under_overflow())?;
209 self.reserve_pool_liquidity(self.pool_id(user_token, intermediate), out1)?;
210 self.reserve_pool_liquidity(self.pool_id(intermediate, validator_token), out2)?;
211 self.two_hop_intermediate.t_write(intermediate)?;
212 }
213 }
214
215 Ok(())
216 }
217
218 pub fn collect_fee_post_tx(
230 &mut self,
231 fee_payer: Address,
232 actual_spending: U256,
233 refund_amount: U256,
234 fee_token: Address,
235 beneficiary: Address,
236 ) -> Result<U256> {
237 let mut tip20_token = TIP20Token::from_address(fee_token)?;
239 tip20_token.transfer_fee_post_tx(fee_payer, refund_amount, actual_spending)?;
240
241 let hop_token = self.two_hop_intermediate.t_read()?;
243 let validator_token = self.get_validator_token(beneficiary)?;
244
245 let amount = if fee_token == validator_token {
246 actual_spending
247 } else if hop_token.is_zero() {
248 if !actual_spending.is_zero() {
250 self.execute_fee_swap(fee_token, validator_token, actual_spending)?;
251 }
252 compute_amount_out(actual_spending)?
253 } else {
254 if !actual_spending.is_zero() {
256 let out1 = self.execute_fee_swap(fee_token, hop_token, actual_spending)?;
257 self.execute_fee_swap(hop_token, validator_token, out1)?;
258 }
259 compute_amount_out(compute_amount_out(actual_spending)?)?
260 };
261
262 self.increment_collected_fees(beneficiary, validator_token, amount)?;
263
264 Ok(amount)
265 }
266
267 fn increment_collected_fees(
269 &mut self,
270 validator: Address,
271 token: Address,
272 amount: U256,
273 ) -> Result<()> {
274 if amount.is_zero() {
275 return Ok(());
276 }
277
278 self.collected_fees[validator][token].sinc(amount)?;
279
280 Ok(())
281 }
282
283 pub fn distribute_fees(&mut self, validator: Address, token: Address) -> Result<()> {
289 let amount = self.collected_fees[validator][token].read()?;
290 if amount.is_zero() {
291 return Ok(());
292 }
293 self.collected_fees[validator][token].write(U256::ZERO)?;
294
295 let mut tip20_token = TIP20Token::from_address(token)?;
297 tip20_token.transfer(
298 self.address,
299 ITIP20::transferCall {
300 to: validator,
301 amount,
302 },
303 )?;
304
305 self.emit_event(FeeManagerEvent::fees_distributed(validator, token, amount))?;
307
308 Ok(())
309 }
310
311 pub fn user_tokens(&self, call: IFeeManager::userTokensCall) -> Result<Address> {
313 self.user_tokens[call.user].read()
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use tempo_chainspec::hardfork::TempoHardfork;
320 use tempo_contracts::precompiles::TIP20Error;
321
322 use super::*;
323 use crate::{
324 TIP_FEE_MANAGER_ADDRESS,
325 error::TempoPrecompileError,
326 storage::{ContractStorage, StorageCtx, hashmap::HashMapStorageProvider},
327 test_util::TIP20Setup,
328 tip20::{ITIP20, TIP20Token},
329 };
330
331 #[test]
332 fn test_set_user_token() -> eyre::Result<()> {
333 let mut storage = HashMapStorageProvider::new(1);
334 let user = Address::random();
335 StorageCtx::enter(&mut storage, || {
336 let token = TIP20Setup::create("Test", "TST", user).apply()?;
337
338 let mut fee_manager = TipFeeManager::new();
341
342 let call = IFeeManager::setUserTokenCall {
343 token: token.address(),
344 };
345 let result = fee_manager.set_user_token(user, call);
346 assert!(result.is_ok());
347
348 let call = IFeeManager::userTokensCall { user };
349 assert_eq!(fee_manager.user_tokens(call)?, token.address());
350
351 Ok(())
352 })
353 }
354
355 #[test]
356 fn test_set_user_token_noop_when_unchanged_pre_t3() -> eyre::Result<()> {
357 let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T2);
358 let user = Address::random();
359 StorageCtx::enter(&mut storage, || {
360 let token = TIP20Setup::create("Test", "TST", user).apply()?;
361 let mut fee_manager = TipFeeManager::new();
362
363 let call = IFeeManager::setUserTokenCall {
364 token: token.address(),
365 };
366
367 fee_manager.set_user_token(user, call.clone())?;
368 fee_manager.set_user_token(user, call)?;
369 let event_count = StorageCtx.get_events(TIP_FEE_MANAGER_ADDRESS).len();
370 assert_eq!(
371 event_count, 2,
372 "pre-T3: event emitted even when token unchanged"
373 );
374
375 Ok(())
376 })
377 }
378
379 #[test]
380 fn test_set_user_token_noop_when_unchanged_t3() -> eyre::Result<()> {
381 let mut storage = HashMapStorageProvider::new_with_spec(1, TempoHardfork::T3);
382 let user = Address::random();
383 StorageCtx::enter(&mut storage, || {
384 let token = TIP20Setup::create("Test", "TST", user).apply()?;
385 let mut fee_manager = TipFeeManager::new();
386
387 let call = IFeeManager::setUserTokenCall {
388 token: token.address(),
389 };
390
391 fee_manager.set_user_token(user, call.clone())?;
392 let event_count = StorageCtx.get_events(TIP_FEE_MANAGER_ADDRESS).len();
393 assert_eq!(event_count, 1, "first set_user_token should emit event");
394
395 fee_manager.set_user_token(user, call)?;
396 let event_count = StorageCtx.get_events(TIP_FEE_MANAGER_ADDRESS).len();
397 assert_eq!(
398 event_count, 1,
399 "T3+: repeated set_user_token with same token should not emit event"
400 );
401
402 Ok(())
403 })
404 }
405
406 #[test]
407 fn test_set_validator_token() -> eyre::Result<()> {
408 let mut storage = HashMapStorageProvider::new(1);
409 let validator = Address::random();
410 let admin = Address::random();
411 let beneficiary = Address::random();
412 StorageCtx::enter(&mut storage, || {
413 let token = TIP20Setup::create("Test", "TST", admin).apply()?;
414 let mut fee_manager = TipFeeManager::new();
415
416 let call = IFeeManager::setValidatorTokenCall {
417 token: token.address(),
418 };
419
420 let result = fee_manager.set_validator_token(validator, call.clone(), validator);
422 assert_eq!(
423 result,
424 Err(TempoPrecompileError::FeeManagerError(
425 FeeManagerError::cannot_change_within_block()
426 ))
427 );
428
429 let result = fee_manager.set_validator_token(validator, call, beneficiary);
431 assert!(result.is_ok());
432
433 let returned_token = fee_manager.get_validator_token(validator)?;
434 assert_eq!(returned_token, token.address());
435
436 Ok(())
437 })
438 }
439
440 #[test]
441 fn test_set_validator_token_cannot_change_within_block() -> eyre::Result<()> {
442 let mut storage = HashMapStorageProvider::new(1);
443 let validator = Address::random();
444 let beneficiary = Address::random();
445 let admin = Address::random();
446 StorageCtx::enter(&mut storage, || {
447 let token = TIP20Setup::create("Test", "TST", admin).apply()?;
448 let mut fee_manager = TipFeeManager::new();
449
450 let call = IFeeManager::setValidatorTokenCall {
451 token: token.address(),
452 };
453
454 let result = fee_manager.set_validator_token(validator, call.clone(), beneficiary);
456 assert!(result.is_ok());
457
458 let result = fee_manager.set_validator_token(validator, call, validator);
460 assert_eq!(
461 result,
462 Err(TempoPrecompileError::FeeManagerError(
463 FeeManagerError::cannot_change_within_block()
464 ))
465 );
466
467 Ok(())
468 })
469 }
470
471 #[test]
472 fn test_collect_fee_pre_tx() -> eyre::Result<()> {
473 let mut storage = HashMapStorageProvider::new(1);
474 let user = Address::random();
475 let validator = Address::random();
476 let beneficiary = Address::random();
477 StorageCtx::enter(&mut storage, || {
478 let max_amount = U256::from(10000);
479
480 let token = TIP20Setup::create("Test", "TST", user)
481 .with_issuer(user)
482 .with_mint(user, U256::from(u64::MAX))
483 .with_approval(user, TIP_FEE_MANAGER_ADDRESS, U256::MAX)
484 .apply()?;
485
486 let mut fee_manager = TipFeeManager::new();
487
488 fee_manager.set_validator_token(
490 validator,
491 IFeeManager::setValidatorTokenCall {
492 token: token.address(),
493 },
494 beneficiary,
495 )?;
496
497 fee_manager.set_user_token(
499 user,
500 IFeeManager::setUserTokenCall {
501 token: token.address(),
502 },
503 )?;
504
505 let result =
507 fee_manager.collect_fee_pre_tx(user, token.address(), max_amount, validator, false);
508 assert!(result.is_ok());
509 assert_eq!(result?, token.address());
510
511 Ok(())
512 })
513 }
514
515 #[test]
516 fn test_collect_fee_post_tx() -> eyre::Result<()> {
517 let mut storage = HashMapStorageProvider::new(1);
518 let user = Address::random();
519 let admin = Address::random();
520 let validator = Address::random();
521 let beneficiary = Address::random();
522 StorageCtx::enter(&mut storage, || {
523 let actual_used = U256::from(6000);
524 let refund_amount = U256::from(4000);
525
526 let token = TIP20Setup::create("Test", "TST", admin)
528 .with_issuer(admin)
529 .with_mint(TIP_FEE_MANAGER_ADDRESS, U256::from(100000000000000_u64))
530 .apply()?;
531
532 let mut fee_manager = TipFeeManager::new();
533
534 fee_manager.set_validator_token(
536 validator,
537 IFeeManager::setValidatorTokenCall {
538 token: token.address(),
539 },
540 beneficiary,
541 )?;
542
543 fee_manager.set_user_token(
545 user,
546 IFeeManager::setUserTokenCall {
547 token: token.address(),
548 },
549 )?;
550
551 let credited = fee_manager.collect_fee_post_tx(
553 user,
554 actual_used,
555 refund_amount,
556 token.address(),
557 validator,
558 )?;
559 assert_eq!(credited, actual_used);
560
561 let tracked_amount = fee_manager.collected_fees[validator][token.address()].read()?;
563 assert_eq!(tracked_amount, actual_used);
564
565 let balance = token.balance_of(ITIP20::balanceOfCall { account: user })?;
567 assert_eq!(balance, refund_amount);
568
569 Ok(())
570 })
571 }
572
573 #[test]
574 fn test_rejects_non_usd() -> eyre::Result<()> {
575 let mut storage = HashMapStorageProvider::new(1);
576 let admin = Address::random();
577 let user = Address::random();
578 let validator = Address::random();
579 let beneficiary = Address::random();
580 StorageCtx::enter(&mut storage, || {
581 let non_usd_token = TIP20Setup::create("NonUSD", "EUR", admin)
583 .currency("EUR")
584 .apply()?;
585
586 let mut fee_manager = TipFeeManager::new();
587
588 let call = IFeeManager::setUserTokenCall {
590 token: non_usd_token.address(),
591 };
592 let result = fee_manager.set_user_token(user, call);
593 assert!(matches!(
594 result,
595 Err(TempoPrecompileError::TIP20(TIP20Error::InvalidCurrency(_)))
596 ));
597
598 let call = IFeeManager::setValidatorTokenCall {
600 token: non_usd_token.address(),
601 };
602 let result = fee_manager.set_validator_token(validator, call, beneficiary);
603 assert!(matches!(
604 result,
605 Err(TempoPrecompileError::TIP20(TIP20Error::InvalidCurrency(_)))
606 ));
607
608 Ok(())
609 })
610 }
611
612 #[test]
615 fn test_collect_fee_pre_tx_different_tokens() -> eyre::Result<()> {
616 let mut storage = HashMapStorageProvider::new(1);
617 let admin = Address::random();
618 let user = Address::random();
619 let validator = Address::random();
620
621 StorageCtx::enter(&mut storage, || {
622 let user_token = TIP20Setup::create("UserToken", "UTK", admin)
624 .with_issuer(admin)
625 .with_mint(user, U256::from(10000))
626 .with_approval(user, TIP_FEE_MANAGER_ADDRESS, U256::MAX)
627 .apply()?;
628
629 let validator_token = TIP20Setup::create("ValidatorToken", "VTK", admin)
630 .with_issuer(admin)
631 .with_mint(TIP_FEE_MANAGER_ADDRESS, U256::from(10000))
632 .apply()?;
633
634 let mut fee_manager = TipFeeManager::new();
635
636 let pool_id = fee_manager.pool_id(user_token.address(), validator_token.address());
638 fee_manager.pools[pool_id].write(crate::tip_fee_manager::amm::Pool {
639 reserve_user_token: 10000,
640 reserve_validator_token: 10000,
641 })?;
642
643 fee_manager.set_validator_token(
645 validator,
646 IFeeManager::setValidatorTokenCall {
647 token: validator_token.address(),
648 },
649 Address::random(),
650 )?;
651
652 let max_amount = U256::from(1000);
653
654 fee_manager.collect_fee_pre_tx(
656 user,
657 user_token.address(),
658 max_amount,
659 validator,
660 false,
661 )?;
662
663 let collected =
668 fee_manager.collected_fees[validator][validator_token.address()].read()?;
669 assert_eq!(
670 collected,
671 U256::ZERO,
672 "Different tokens: no fees accumulated in pre_tx (swap happens in post_tx)"
673 );
674
675 let pool = fee_manager.pools[pool_id].read()?;
677 assert_eq!(
678 pool.reserve_user_token, 10000,
679 "Reserves unchanged in pre_tx"
680 );
681 assert_eq!(
682 pool.reserve_validator_token, 10000,
683 "Reserves unchanged in pre_tx"
684 );
685
686 Ok(())
687 })
688 }
689
690 #[test]
691 fn test_collect_fee_post_tx_immediate_swap() -> eyre::Result<()> {
692 let mut storage = HashMapStorageProvider::new(1);
693 let admin = Address::random();
694 let user = Address::random();
695 let validator = Address::random();
696
697 StorageCtx::enter(&mut storage, || {
698 let user_token = TIP20Setup::create("UserToken", "UTK", admin)
699 .with_issuer(admin)
700 .with_mint(user, U256::from(10000))
701 .with_mint(TIP_FEE_MANAGER_ADDRESS, U256::from(10000))
702 .with_approval(user, TIP_FEE_MANAGER_ADDRESS, U256::MAX)
703 .apply()?;
704
705 let validator_token = TIP20Setup::create("ValidatorToken", "VTK", admin)
706 .with_issuer(admin)
707 .with_mint(TIP_FEE_MANAGER_ADDRESS, U256::from(10000))
708 .apply()?;
709
710 let mut fee_manager = TipFeeManager::new();
711
712 let pool_id = fee_manager.pool_id(user_token.address(), validator_token.address());
713 fee_manager.pools[pool_id].write(crate::tip_fee_manager::amm::Pool {
714 reserve_user_token: 10000,
715 reserve_validator_token: 10000,
716 })?;
717
718 fee_manager.set_validator_token(
719 validator,
720 IFeeManager::setValidatorTokenCall {
721 token: validator_token.address(),
722 },
723 Address::random(),
724 )?;
725
726 let max_amount = U256::from(1000);
727 let actual_spending = U256::from(800);
728 let refund_amount = U256::from(200);
729
730 fee_manager.collect_fee_pre_tx(
732 user,
733 user_token.address(),
734 max_amount,
735 validator,
736 false,
737 )?;
738
739 let credited = fee_manager.collect_fee_post_tx(
741 user,
742 actual_spending,
743 refund_amount,
744 user_token.address(),
745 validator,
746 )?;
747
748 let expected_fee_amount = (actual_spending * U256::from(9970)) / U256::from(10000);
750 assert_eq!(credited, expected_fee_amount);
751 let collected =
752 fee_manager.collected_fees[validator][validator_token.address()].read()?;
753 assert_eq!(collected, expected_fee_amount);
754
755 let pool = fee_manager.pools[pool_id].read()?;
757 assert_eq!(pool.reserve_user_token, 10000 + 800);
758 assert_eq!(pool.reserve_validator_token, 10000 - 797);
759
760 let tip20_token = TIP20Token::from_address(user_token.address())?;
762 let user_balance = tip20_token.balance_of(ITIP20::balanceOfCall { account: user })?;
763 assert_eq!(user_balance, U256::from(10000) - max_amount + refund_amount);
764
765 Ok(())
766 })
767 }
768
769 #[test]
771 fn test_collect_fee_pre_tx_insufficient_liquidity() -> eyre::Result<()> {
772 let mut storage = HashMapStorageProvider::new(1);
773 let admin = Address::random();
774 let user = Address::random();
775 let validator = Address::random();
776
777 StorageCtx::enter(&mut storage, || {
778 let user_token = TIP20Setup::create("UserToken", "UTK", admin)
779 .with_issuer(admin)
780 .with_mint(user, U256::from(10000))
781 .with_approval(user, TIP_FEE_MANAGER_ADDRESS, U256::MAX)
782 .apply()?;
783
784 let validator_token = TIP20Setup::create("ValidatorToken", "VTK", admin)
785 .with_issuer(admin)
786 .with_mint(TIP_FEE_MANAGER_ADDRESS, U256::from(100))
787 .apply()?;
788
789 let mut fee_manager = TipFeeManager::new();
790
791 let pool_id = fee_manager.pool_id(user_token.address(), validator_token.address());
792 fee_manager.pools[pool_id].write(crate::tip_fee_manager::amm::Pool {
794 reserve_user_token: 10000,
795 reserve_validator_token: 100,
796 })?;
797
798 fee_manager.set_validator_token(
799 validator,
800 IFeeManager::setValidatorTokenCall {
801 token: validator_token.address(),
802 },
803 Address::random(),
804 )?;
805
806 let max_amount = U256::from(1000);
809
810 let result = fee_manager.collect_fee_pre_tx(
811 user,
812 user_token.address(),
813 max_amount,
814 validator,
815 false,
816 );
817
818 assert!(result.is_err(), "Should fail with insufficient liquidity");
819
820 Ok(())
821 })
822 }
823
824 #[test]
827 fn test_collect_fee_pre_tx_skip_liquidity_check() -> eyre::Result<()> {
828 let mut storage = HashMapStorageProvider::new(1);
829 let admin = Address::random();
830 let user = Address::random();
831 let validator = Address::random();
832
833 StorageCtx::enter(&mut storage, || {
834 let user_token = TIP20Setup::create("UserToken", "UTK", admin)
835 .with_issuer(admin)
836 .with_mint(user, U256::from(10000))
837 .with_approval(user, TIP_FEE_MANAGER_ADDRESS, U256::MAX)
838 .apply()?;
839
840 let validator_token = TIP20Setup::create("ValidatorToken", "VTK", admin)
841 .with_issuer(admin)
842 .apply()?;
843
844 let mut fee_manager = TipFeeManager::new();
845 fee_manager.set_validator_token(
846 validator,
847 IFeeManager::setValidatorTokenCall {
848 token: validator_token.address(),
849 },
850 Address::random(),
851 )?;
852
853 let result = fee_manager.collect_fee_pre_tx(
855 user,
856 user_token.address(),
857 U256::from(1000),
858 validator,
859 false,
860 );
861 assert!(
862 result.is_err(),
863 "Should fail without liquidity, got: {result:?}"
864 );
865
866 let result = fee_manager.collect_fee_pre_tx(
868 user,
869 user_token.address(),
870 U256::from(1000),
871 validator,
872 true,
873 );
874 assert!(result.is_ok());
875 assert_eq!(result?, user_token.address());
876
877 Ok(())
878 })
879 }
880
881 #[test]
883 fn test_distribute_fees_zero_balance() -> eyre::Result<()> {
884 let mut storage = HashMapStorageProvider::new(1);
885 let admin = Address::random();
886 let validator = Address::random();
887
888 StorageCtx::enter(&mut storage, || {
889 let token = TIP20Setup::create("TestToken", "TEST", admin)
890 .with_issuer(admin)
891 .apply()?;
892
893 let mut fee_manager = TipFeeManager::new();
894
895 fee_manager.set_validator_token(
896 validator,
897 IFeeManager::setValidatorTokenCall {
898 token: token.address(),
899 },
900 Address::random(),
901 )?;
902
903 let collected = fee_manager.collected_fees[validator][token.address()].read()?;
905 assert_eq!(collected, U256::ZERO);
906
907 let result = fee_manager.distribute_fees(validator, token.address());
909 assert!(result.is_ok(), "Should succeed even with zero balance");
910
911 let tip20_token = TIP20Token::from_address(token.address())?;
913 let balance = tip20_token.balance_of(ITIP20::balanceOfCall { account: validator })?;
914 assert_eq!(balance, U256::ZERO);
915
916 Ok(())
917 })
918 }
919
920 #[test]
922 fn test_distribute_fees() -> eyre::Result<()> {
923 let mut storage = HashMapStorageProvider::new(1);
924 let admin = Address::random();
925 let validator = Address::random();
926
927 StorageCtx::enter(&mut storage, || {
928 let token = TIP20Setup::create("TestToken", "TEST", admin)
930 .with_issuer(admin)
931 .with_mint(TIP_FEE_MANAGER_ADDRESS, U256::from(1000))
932 .apply()?;
933
934 let mut fee_manager = TipFeeManager::new();
935
936 fee_manager.set_validator_token(
938 validator,
939 IFeeManager::setValidatorTokenCall {
940 token: token.address(),
941 },
942 Address::random(), )?;
944
945 let fee_amount = U256::from(500);
947 fee_manager.collected_fees[validator][token.address()].write(fee_amount)?;
948
949 let tip20_token = TIP20Token::from_address(token.address())?;
951 let balance_before =
952 tip20_token.balance_of(ITIP20::balanceOfCall { account: validator })?;
953 assert_eq!(balance_before, U256::ZERO);
954
955 let mut fee_manager = TipFeeManager::new();
957 fee_manager.distribute_fees(validator, token.address())?;
958
959 let tip20_token = TIP20Token::from_address(token.address())?;
961 let balance_after =
962 tip20_token.balance_of(ITIP20::balanceOfCall { account: validator })?;
963 assert_eq!(balance_after, fee_amount);
964
965 let fee_manager = TipFeeManager::new();
967 let remaining = fee_manager.collected_fees[validator][token.address()].read()?;
968 assert_eq!(remaining, U256::ZERO);
969
970 Ok(())
971 })
972 }
973
974 #[test]
975 fn test_initialize_sets_storage_state() -> eyre::Result<()> {
976 let mut storage = HashMapStorageProvider::new(1);
977 StorageCtx::enter(&mut storage, || {
978 let mut fee_manager = TipFeeManager::new();
979
980 assert!(!fee_manager.is_initialized()?);
982
983 fee_manager.initialize()?;
985
986 assert!(fee_manager.is_initialized()?);
988
989 let fee_manager2 = TipFeeManager::new();
991 assert!(fee_manager2.is_initialized()?);
992
993 Ok(())
994 })
995 }
996
997 struct TwoHopTokens {
998 user: Address,
999 hop: Address,
1000 validator: Address,
1001 }
1002
1003 fn with_two_hop_env<F>(spec: TempoHardfork, hop_quote_is_val: bool, f: F) -> eyre::Result<()>
1008 where
1009 F: FnOnce(&mut TipFeeManager, &TwoHopTokens, Address, Address, Address) -> eyre::Result<()>,
1010 {
1011 let mut storage = HashMapStorageProvider::new_with_spec(1, spec);
1012 let admin = Address::random();
1013 let user = Address::random();
1014 let validator = Address::random();
1015 StorageCtx::enter(&mut storage, || {
1016 let hop_token = TIP20Setup::create("HopToken", "HTK", admin)
1017 .with_issuer(admin)
1018 .apply()?
1019 .address();
1020 let validator_token = TIP20Setup::create("ValidatorToken", "VTK", admin)
1021 .with_issuer(admin)
1022 .apply()?
1023 .address();
1024 let quote_token = if hop_quote_is_val {
1025 validator_token
1026 } else {
1027 hop_token
1028 };
1029 let user_token = TIP20Setup::create("UserToken", "UTK", admin)
1030 .with_issuer(admin)
1031 .quote_token(quote_token)
1032 .with_mint(user, U256::from(u64::MAX))
1033 .with_approval(user, TIP_FEE_MANAGER_ADDRESS, U256::MAX)
1034 .apply()?
1035 .address();
1036
1037 let mut fee_manager = TipFeeManager::new();
1038 fee_manager.set_validator_token(
1039 validator,
1040 IFeeManager::setValidatorTokenCall {
1041 token: validator_token,
1042 },
1043 Address::random(),
1044 )?;
1045
1046 let tokens = TwoHopTokens {
1047 user: user_token,
1048 hop: hop_token,
1049 validator: validator_token,
1050 };
1051 f(&mut fee_manager, &tokens, user, validator, admin)
1052 })
1053 }
1054
1055 fn write_pool(
1057 fm: &mut TipFeeManager,
1058 a: Address,
1059 b: Address,
1060 validator_reserve: u128,
1061 ) -> Result<()> {
1062 let pid = fm.pool_id(a, b);
1063 fm.pools[pid].write(crate::tip_fee_manager::amm::Pool {
1064 reserve_user_token: validator_reserve.max(1),
1065 reserve_validator_token: validator_reserve,
1066 })
1067 }
1068
1069 #[test]
1070 fn test_collect_fee_pre_tx_two_hop_hardfork_gating() -> eyre::Result<()> {
1071 let setup_pools = |fm: &mut TipFeeManager, t: &TwoHopTokens| -> Result<()> {
1073 write_pool(fm, t.user, t.validator, 0)?;
1074 write_pool(fm, t.user, t.hop, 100_000)?;
1075 write_pool(fm, t.hop, t.validator, 100_000)?;
1076 Ok(())
1077 };
1078
1079 with_two_hop_env(
1081 TempoHardfork::T4,
1082 false,
1083 |fm, t, user, validator, _admin| {
1084 setup_pools(fm, t)?;
1085 let res = fm.collect_fee_pre_tx(user, t.user, U256::from(1_000), validator, false);
1086 assert_eq!(
1087 res.unwrap_err(),
1088 TIPFeeAMMError::insufficient_liquidity().into(),
1089 "T4: expected InsufficientLiquidity",
1090 );
1091 Ok(())
1092 },
1093 )?;
1094
1095 with_two_hop_env(
1097 TempoHardfork::T5,
1098 false,
1099 |fm, t, user, validator, _admin| {
1100 setup_pools(fm, t)?;
1101
1102 fm.collect_fee_pre_tx(user, t.user, U256::from(1_000), validator, false)?;
1103 assert_eq!(
1104 fm.pending_fee_swap_reservation[fm.pool_id(t.user, t.hop)].t_read()?,
1105 997 );
1107 assert_eq!(
1108 fm.pending_fee_swap_reservation[fm.pool_id(t.hop, t.validator)].t_read()?,
1109 994 );
1111 assert_eq!(
1112 fm.pending_fee_swap_reservation[fm.pool_id(t.user, t.validator)].t_read()?,
1113 0 );
1115 Ok(())
1116 },
1117 )
1118 }
1119
1120 #[test]
1121 fn test_collect_fee_pre_tx_two_hop_no_side_effects() -> eyre::Result<()> {
1122 let cases: &[(&str, bool, bool, u128, u128, u128)] = &[
1124 ("direct pool sufficient", false, false, 100_000, 0, 0),
1125 ("skip_liquidity_check bypass", false, true, 0, 0, 0),
1126 ("1st hop empty", false, false, 0, 0, 100_000),
1127 ("2nd hop too small", false, false, 0, 100_000, 50),
1128 ("quote == validator", true, false, 0, 100_000, 100_000),
1130 ];
1131
1132 for &(label, hop_quote_is_val, skip, direct, r1, r2) in cases {
1133 with_two_hop_env(
1134 TempoHardfork::T5,
1135 hop_quote_is_val,
1136 |fm, t, user, validator, _admin| {
1137 write_pool(fm, t.user, t.validator, direct)?;
1138 write_pool(fm, t.user, t.hop, r1)?;
1139 write_pool(fm, t.hop, t.validator, r2)?;
1140
1141 let res =
1142 fm.collect_fee_pre_tx(user, t.user, U256::from(1_000), validator, skip);
1143 assert_eq!(
1144 res.is_ok(),
1145 direct > 0 || skip,
1146 "{label}: succeeds iff the two-hop fallback isn't needed, got {res:?}",
1147 );
1148
1149 for (a, b) in [(t.user, t.hop), (t.hop, t.validator)] {
1152 assert_eq!(
1153 fm.pending_fee_swap_reservation[fm.pool_id(a, b)].t_read()?,
1154 0,
1155 "{label}: hop pool reservation leaked for ({a}, {b})",
1156 );
1157 }
1158 assert!(
1159 fm.two_hop_intermediate.t_read()?.is_zero(),
1160 "{label}: intermediate cache leaked",
1161 );
1162 Ok(())
1163 },
1164 )?;
1165 }
1166 Ok(())
1167 }
1168
1169 #[test]
1170 fn test_collect_fee_post_tx_two_hop_compound_fee() -> eyre::Result<()> {
1171 let cases: &[(u128, u128, u128)] = &[
1174 (123_456_789, 123_086_418, 122_717_158),
1175 (987_654_123, 984_691_160, 981_737_086),
1176 (456_321_789, 454_952_823, 453_587_964),
1177 ];
1178
1179 let assert_sequential_diverges_from_combined = |amount: U256| {
1180 const COMBINED: U256 = uint!(99_400_900_U256); const SCALE: U256 = uint!(100_000_000_U256); let combined = amount * COMBINED / SCALE;
1183
1184 let sequential = compute_amount_out(compute_amount_out(amount).unwrap()).unwrap();
1185 assert_ne!(
1186 sequential, combined,
1187 "amount={amount}: pick another value for sequential to not match combined fee math"
1188 );
1189 };
1190
1191 for &(amount, expected_out1, expected_out2) in cases {
1192 assert_sequential_diverges_from_combined(U256::from(amount));
1193
1194 with_two_hop_env(
1195 TempoHardfork::T5,
1196 false,
1197 |fm, t, user, validator, _admin| {
1198 let reserve = 10 * amount;
1201 write_pool(fm, t.user, t.validator, 0)?;
1202 write_pool(fm, t.user, t.hop, reserve)?;
1203 write_pool(fm, t.hop, t.validator, reserve)?;
1204
1205 let amount_u = U256::from(amount);
1206 fm.collect_fee_pre_tx(user, t.user, amount_u, validator, false)?;
1207 let credited =
1208 fm.collect_fee_post_tx(user, amount_u, U256::ZERO, t.user, validator)?;
1209 let one_hop_amount = compute_amount_out(amount_u)?;
1210 assert!(
1211 credited < one_hop_amount,
1212 "amount={amount}: two-hop credit ({credited}) should be less than one-hop credit ({one_hop_amount})",
1213 );
1214
1215 assert_eq!(
1216 fm.collected_fees[validator][t.validator].read()?,
1217 U256::from(expected_out2),
1218 "amount={amount}: post-tx MUST accumulate sequential floor(floor(N*M)*M)",
1219 );
1220
1221 let p1 = fm.pools[fm.pool_id(t.user, t.hop)].read()?;
1223 assert_eq!(
1224 (p1.reserve_user_token, p1.reserve_validator_token),
1225 (reserve + amount, reserve - expected_out1),
1226 "amount={amount}: pool1 reserves must move by (amount, out1)",
1227 );
1228 let p2 = fm.pools[fm.pool_id(t.hop, t.validator)].read()?;
1230 assert_eq!(
1231 (p2.reserve_user_token, p2.reserve_validator_token),
1232 (reserve + expected_out1, reserve - expected_out2),
1233 "amount={amount}: pool2 reserves must move by (out1, out2)",
1234 );
1235 Ok(())
1236 },
1237 )?;
1238 }
1239 Ok(())
1240 }
1241
1242 #[test]
1247 fn test_collect_fee_two_hop_route_immutable_under_quote_rotation() -> eyre::Result<()> {
1248 with_two_hop_env(TempoHardfork::T5, false, |fm, t, user, validator, admin| {
1249 let reserve: u128 = 1_000_000;
1251 write_pool(fm, t.user, t.validator, 0)?;
1252 write_pool(fm, t.user, t.hop, reserve)?;
1253 write_pool(fm, t.hop, t.validator, reserve)?;
1254
1255 let amount = U256::from(1_000);
1256 fm.collect_fee_pre_tx(user, t.user, amount, validator, false)?;
1257 assert_eq!(fm.two_hop_intermediate.t_read()?, t.hop);
1258
1259 let mut user_token = TIP20Token::from_address(t.user)?;
1263 user_token.set_next_quote_token(
1264 admin,
1265 ITIP20::setNextQuoteTokenCall {
1266 newQuoteToken: t.validator,
1267 },
1268 )?;
1269 user_token
1270 .complete_quote_token_update(admin, ITIP20::completeQuoteTokenUpdateCall {})?;
1271 assert_eq!(
1272 user_token.quote_token()?,
1273 t.validator,
1274 "rotation took effect"
1275 );
1276
1277 fm.collect_fee_post_tx(user, amount, U256::ZERO, t.user, validator)?;
1279
1280 let out1: u128 = compute_amount_out(amount)?.try_into().unwrap();
1281 let out2: u128 = compute_amount_out(U256::from(out1))?.try_into().unwrap();
1282 assert_eq!(
1283 fm.collected_fees[validator][t.validator].read()?,
1284 U256::from(out2),
1285 "post-tx must apply two-hop sequential fee math via cached intermediate",
1286 );
1287
1288 let p1 = fm.pools[fm.pool_id(t.user, t.hop)].read()?;
1290 assert_eq!(
1291 (p1.reserve_user_token, p1.reserve_validator_token),
1292 (reserve + 1_000, reserve - out1),
1293 "pool1 (user, hop) reserves must reflect 1st-hop swap",
1294 );
1295 let p2 = fm.pools[fm.pool_id(t.hop, t.validator)].read()?;
1296 assert_eq!(
1297 (p2.reserve_user_token, p2.reserve_validator_token),
1298 (reserve + out1, reserve - out2),
1299 "pool2 (hop, validator) reserves must reflect 2nd-hop swap",
1300 );
1301 let direct = fm.pools[fm.pool_id(t.user, t.validator)].read()?;
1304 assert_eq!(
1305 (direct.reserve_user_token, direct.reserve_validator_token),
1306 (1, 0),
1307 "direct (user, validator) pool must NOT be used for settlement",
1308 );
1309
1310 Ok(())
1311 })
1312 }
1313
1314 #[test]
1318 fn test_two_hop_intermediate_does_not_survive_across_tx() -> eyre::Result<()> {
1319 with_two_hop_env(
1320 TempoHardfork::T5,
1321 false,
1322 |fm, t, user, validator, _admin| {
1323 let reserve: u128 = 1_000_000;
1325 write_pool(fm, t.user, t.validator, 0)?;
1326 write_pool(fm, t.user, t.hop, reserve)?;
1327 write_pool(fm, t.hop, t.validator, reserve)?;
1328
1329 let amount = U256::from(1_000);
1330 fm.collect_fee_pre_tx(user, t.user, amount, validator, false)?;
1331 assert_eq!(fm.two_hop_intermediate.t_read()?, t.hop, "tx1: cached");
1332 fm.collect_fee_post_tx(user, amount, U256::ZERO, t.user, validator)?;
1333 assert_eq!(
1335 fm.two_hop_intermediate.t_read()?,
1336 t.hop,
1337 "tx1: intermediate persists in-tx until EOT",
1338 );
1339
1340 fm.storage_mut().clear_transient();
1342 assert!(
1343 fm.two_hop_intermediate.t_read()?.is_zero(),
1344 "post-EOT: intermediate must be cleared",
1345 );
1346
1347 write_pool(fm, t.user, t.validator, reserve)?;
1349 write_pool(fm, t.user, t.hop, 0)?;
1352 write_pool(fm, t.hop, t.validator, 0)?;
1353
1354 fm.collect_fee_pre_tx(user, t.user, amount, validator, false)?;
1355 assert!(
1356 fm.two_hop_intermediate.t_read()?.is_zero(),
1357 "tx2: pre_tx took direct route, must not set intermediate",
1358 );
1359 fm.collect_fee_post_tx(user, amount, U256::ZERO, t.user, validator)?;
1360
1361 let out_single: U256 = compute_amount_out(amount)?;
1363 let out1: u128 = compute_amount_out(amount)?.try_into().unwrap();
1364 let out2: u128 = compute_amount_out(U256::from(out1))?.try_into().unwrap();
1365 assert_eq!(
1367 fm.collected_fees[validator][t.validator].read()?,
1368 U256::from(out2) + out_single,
1369 "tx2 settled via single-hop direct pool",
1370 );
1371
1372 Ok(())
1373 },
1374 )
1375 }
1376
1377 #[test]
1381 fn test_two_hop_reservation_blocks_mid_tx_burn_on_both_hops() -> eyre::Result<()> {
1382 with_two_hop_env(TempoHardfork::T5, false, |fm, t, user, validator, admin| {
1383 let reserve: u128 = 100_000;
1385 write_pool(fm, t.user, t.validator, 0)?;
1386 write_pool(fm, t.user, t.hop, reserve)?;
1387 write_pool(fm, t.hop, t.validator, reserve)?;
1388
1389 let supply = U256::from(reserve);
1393 for (a, b) in [(t.user, t.hop), (t.hop, t.validator)] {
1394 let pid = fm.pool_id(a, b);
1395 fm.total_supply[pid].write(supply)?;
1396 fm.liquidity_balances[pid][admin].write(supply)?;
1397 }
1398
1399 let amount = U256::from(1_000);
1401 fm.collect_fee_pre_tx(user, t.user, amount, validator, false)?;
1402 let r1 = fm.pending_fee_swap_reservation[fm.pool_id(t.user, t.hop)].t_read()?;
1403 let r2 = fm.pending_fee_swap_reservation[fm.pool_id(t.hop, t.validator)].t_read()?;
1404 assert!(r1 > 0 && r2 > 0, "both hop pools must be reserved");
1405
1406 let res1 = fm.burn(admin, t.user, t.hop, supply, admin);
1408 assert!(
1409 matches!(
1410 res1,
1411 Err(TempoPrecompileError::TIPFeeAMMError(
1412 TIPFeeAMMError::InsufficientLiquidity(_)
1413 ))
1414 ),
1415 "hop1 full burn must revert: got {res1:?}",
1416 );
1417
1418 let res2 = fm.burn(admin, t.hop, t.validator, supply, admin);
1420 assert!(
1421 matches!(
1422 res2,
1423 Err(TempoPrecompileError::TIPFeeAMMError(
1424 TIPFeeAMMError::InsufficientLiquidity(_)
1425 ))
1426 ),
1427 "hop2 full burn must revert: got {res2:?}",
1428 );
1429
1430 assert_eq!(
1432 fm.pending_fee_swap_reservation[fm.pool_id(t.user, t.hop)].t_read()?,
1433 r1,
1434 );
1435 assert_eq!(
1436 fm.pending_fee_swap_reservation[fm.pool_id(t.hop, t.validator)].t_read()?,
1437 r2,
1438 );
1439
1440 Ok(())
1441 })
1442 }
1443}