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::{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/// 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    /// T5+: Intermediate token for two-hop fee swap routing ([TIP-1033]).
46    /// Set by `collect_fee_pre_tx` when the direct `(userToken, validatorToken)` pool has
47    /// insufficient liquidity and the swap falls back through `userToken.quoteToken()`.
48    ///
49    /// [TIP-1033]: <https://docs.tempo.xyz/protocol/tips/tip-1033>
50    two_hop_intermediate: Address,
51}
52
53impl TipFeeManager {
54    /// Swap fee in basis points (0.25%).
55    pub const FEE_BPS: u64 = 25;
56    /// Basis-point denominator (10 000 = 100%).
57    pub const BASIS_POINTS: u64 = 10000;
58    /// Minimum TIP-20 balance required for fee operations (1e9).
59    pub const MINIMUM_BALANCE: U256 = uint!(1_000_000_000_U256);
60
61    /// Initializes the fee manager precompile.
62    pub fn initialize(&mut self) -> Result<()> {
63        self.__initialize()
64    }
65
66    /// Returns the validator's preferred fee token, falling back to [`DEFAULT_FEE_TOKEN`].
67    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    /// Sets the caller's preferred fee token as a validator.
78    ///
79    /// Rejects the call if `sender` is the current block's beneficiary (prevents mid-block
80    /// fee-token changes) or if the token is not a valid USD-denominated TIP-20 registered in
81    /// [`TIP20Factory`].
82    ///
83    /// # Errors
84    /// - `InvalidToken` — token is not a deployed TIP-20 in [`TIP20Factory`]
85    /// - `CannotChangeWithinBlock` — `sender` equals the current block `beneficiary`
86    /// - `InvalidCurrency` — token is not USD-denominated
87    pub fn set_validator_token(
88        &mut self,
89        sender: Address,
90        call: IFeeManager::setValidatorTokenCall,
91        beneficiary: Address,
92    ) -> Result<()> {
93        // Validate that the token is a valid deployed TIP20
94        if !TIP20Factory::new().is_tip20(call.token)? {
95            return Err(FeeManagerError::invalid_token().into());
96        }
97
98        // Prevent changing within the validator's own block
99        if sender == beneficiary {
100            return Err(FeeManagerError::cannot_change_within_block().into());
101        }
102
103        // Validate that the fee token is USD
104        validate_usd_currency(call.token)?;
105
106        self.validator_tokens[sender].write(call.token)?;
107
108        // Emit ValidatorTokenSet event
109        self.emit_event(FeeManagerEvent::validator_token_set(sender, call.token))
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        // T3+: skip write and event if the token is already set to the requested value.
132        // Prevents permissionless callers from forcing redundant pool invalidation scans.
133        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        // Emit UserTokenSet event
143        self.emit_event(FeeManagerEvent::user_token_set(sender, call.token))
144    }
145
146    /// Collects fees from `fee_payer` before transaction execution.
147    ///
148    /// Transfers `max_amount` of `user_token` to the fee manager via [`TIP20Token`] and, if the
149    /// validator prefers a different token, verifies sufficient pool liquidity.
150    /// Reserves liquidity on T1C+, with a two-hop fallback through `userToken.quoteToken()` on T5+.
151    /// 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 (T5+: with two-hop fallback)
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 !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        // Return the user's token preference
181        Ok(user_token)
182    }
183
184    /// Reserves AMM liquidity needed to settle the selected fee route after transaction execution.
185    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                // T5+ implies T1C+, so reservation is always required here.
203                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    /// Finalizes fee collection after transaction execution.
219    ///
220    /// Refunds unused `user_token` to `fee_payer` via [`TIP20Token`], executes the fee swap
221    /// through the AMM pool if tokens differ, and accumulates fees for the validator. Returns
222    /// the validator-credited amount (post-feeAMM haircut, in the validator's fee token), which
223    /// is used by the payload builder to score blocks by actual proposer revenue.
224    ///
225    /// # Errors
226    /// - `InvalidToken` — `fee_token` does not have a valid TIP-20 prefix
227    /// - `InsufficientLiquidity` — AMM pool lacks liquidity for the fee swap
228    /// - `UnderOverflow` — collected-fee accumulator overflows
229    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        // Refund unused tokens to user
238        let mut tip20_token = TIP20Token::from_address(fee_token)?;
239        tip20_token.transfer_fee_post_tx(fee_payer, refund_amount, actual_spending)?;
240
241        // Execute fee swap and track collected fees
242        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            // Single-hop (direct) swap
249            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            // Two-hop swap (only in T5+): each hop applies M = 9970/10000 sequentially
255            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    /// Increment collected fees for a specific validator and token combination.
268    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    /// Transfers a validator's accumulated fee balance to their address via [`TIP20Token`] and
284    /// zeroes the ledger. No-ops when the balance is zero.
285    ///
286    /// # Errors
287    /// - `InvalidToken` — `token` does not have a valid TIP-20 prefix
288    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        // Transfer fees to validator
296        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        // Emit FeesDistributed event
306        self.emit_event(FeeManagerEvent::fees_distributed(validator, token, amount))?;
307
308        Ok(())
309    }
310
311    /// Reads the stored fee token preference for a user.
312    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            // TODO: loop through and deploy and set user token for some range
339
340            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            // Should fail when validator == beneficiary (same block check)
421            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            // Should succeed with different beneficiary
430            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            // Setting validator token when not beneficiary should succeed
455            let result = fee_manager.set_validator_token(validator, call.clone(), beneficiary);
456            assert!(result.is_ok());
457
458            // But if validator is the beneficiary, should fail with CannotChangeWithinBlock
459            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            // Set validator token (use beneficiary to avoid CannotChangeWithinBlock)
489            fee_manager.set_validator_token(
490                validator,
491                IFeeManager::setValidatorTokenCall {
492                    token: token.address(),
493                },
494                beneficiary,
495            )?;
496
497            // Set user token
498            fee_manager.set_user_token(
499                user,
500                IFeeManager::setUserTokenCall {
501                    token: token.address(),
502                },
503            )?;
504
505            // Call collect_fee_pre_tx directly
506            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            // Mint to FeeManager (simulating collect_fee_pre_tx already happened)
527            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            // Set validator token (use beneficiary to avoid CannotChangeWithinBlock)
535            fee_manager.set_validator_token(
536                validator,
537                IFeeManager::setValidatorTokenCall {
538                    token: token.address(),
539                },
540                beneficiary,
541            )?;
542
543            // Set user token
544            fee_manager.set_user_token(
545                user,
546                IFeeManager::setUserTokenCall {
547                    token: token.address(),
548                },
549            )?;
550
551            // Call collect_fee_post_tx directly
552            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            // Verify fees were tracked
562            let tracked_amount = fee_manager.collected_fees[validator][token.address()].read()?;
563            assert_eq!(tracked_amount, actual_used);
564
565            // Verify user got the refund
566            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            // Create a non-USD token
582            let non_usd_token = TIP20Setup::create("NonUSD", "EUR", admin)
583                .currency("EUR")
584                .apply()?;
585
586            let mut fee_manager = TipFeeManager::new();
587
588            // Try to set non-USD as user token - should fail
589            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            // Try to set non-USD as validator token - should also fail
599            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 collect_fee_pre_tx with different tokens
613    /// Verifies that liquidity is checked (not reserved) and no swap happens yet
614    #[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            // Create two different tokens
623            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            // Setup pool with liquidity
637            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            // Set validator's preferred token
644            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            // Call collect_fee_pre_tx
655            fee_manager.collect_fee_pre_tx(
656                user,
657                user_token.address(),
658                max_amount,
659                validator,
660                false,
661            )?;
662
663            // With different tokens:
664            // - Liquidity is checked (not reserved)
665            // - No swap happens yet (swap happens in collect_fee_post_tx)
666            // - collected_fees should be zero
667            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            // Pool reserves should NOT be updated yet
676            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            // First call collect_fee_pre_tx (checks liquidity)
731            fee_manager.collect_fee_pre_tx(
732                user,
733                user_token.address(),
734                max_amount,
735                validator,
736                false,
737            )?;
738
739            // Then call collect_fee_post_tx (executes swap immediately)
740            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            // Expected output: 800 * 9970 / 10000 = 797
749            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            // Pool reserves should be updated
756            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            // User balance: started with 10000, paid 1000 in pre_tx, got 200 refund = 9200
761            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 collect_fee_pre_tx fails with insufficient liquidity
770    #[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            // Pool with very little validator token liquidity
793            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            // Try to collect fee that would require more liquidity than available
807            // 1000 * 0.997 = 997 output needed, but only 100 available
808            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 that `skip_liquidity_check = true` bypasses the insufficient-liquidity error
825    /// when `user_token != validator_token`.
826    #[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            // Skip liquidity check = false should fail
854            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            // Skip liquidity check = true should pass
867            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 distribute_fees with zero balance is a no-op
882    #[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            // collected_fees is zero by default
904            let collected = fee_manager.collected_fees[validator][token.address()].read()?;
905            assert_eq!(collected, U256::ZERO);
906
907            // distribute_fees should be a no-op
908            let result = fee_manager.distribute_fees(validator, token.address());
909            assert!(result.is_ok(), "Should succeed even with zero balance");
910
911            // Validator balance should still be zero
912            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 distribute_fees transfers accumulated fees to validator
921    #[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            // Initialize token and give fee manager some tokens
929            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            // Set validator's preferred token
937            fee_manager.set_validator_token(
938                validator,
939                IFeeManager::setValidatorTokenCall {
940                    token: token.address(),
941                },
942                Address::random(), // beneficiary != validator
943            )?;
944
945            // Simulate accumulated fees
946            let fee_amount = U256::from(500);
947            fee_manager.collected_fees[validator][token.address()].write(fee_amount)?;
948
949            // Check validator balance before
950            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            // Distribute fees
956            let mut fee_manager = TipFeeManager::new();
957            fee_manager.distribute_fees(validator, token.address())?;
958
959            // Verify validator received the fees
960            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            // Verify collected fees cleared
966            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            // Before init, should not be initialized
981            assert!(!fee_manager.is_initialized()?);
982
983            // Initialize
984            fee_manager.initialize()?;
985
986            // After init, should be initialized
987            assert!(fee_manager.is_initialized()?);
988
989            // New handle should still see initialized state
990            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    /// Builds the standard 3-token environment used by all TIP-1033 tests.
1004    ///
1005    /// The closure receives the fee manager, the three tokens, and the `user` / `validator` /
1006    /// `admin` addresses (admin holds `DEFAULT_ADMIN_ROLE` and `ISSUER_ROLE` on every token).
1007    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    /// Writes a pool with `validator_reserve` on both sides.
1056    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        // Direct pool empty, both hop pools deep — the only fee path is the two-hop fallback.
1072        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        // Pre-T5: fallback disabled — must revert.
1080        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        // T5: same setup — fallback engages successfully.
1096        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 // 1st hop: floor(1000 * 9970/10000) = 997
1106                );
1107                assert_eq!(
1108                    fm.pending_fee_swap_reservation[fm.pool_id(t.hop, t.validator)].t_read()?,
1109                    994 // 2nd hop: floor(997 * 9970/10000) = 994
1110                );
1111                assert_eq!(
1112                    fm.pending_fee_swap_reservation[fm.pool_id(t.user, t.validator)].t_read()?,
1113                    0 // direct pool is NOT reserved
1114                );
1115                Ok(())
1116            },
1117        )
1118    }
1119
1120    #[test]
1121    fn test_collect_fee_pre_tx_two_hop_no_side_effects() -> eyre::Result<()> {
1122        // (label, hop_quote_is_val, skip, direct, first_hop, second_hop)
1123        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            // `userToken.quoteToken() == validatorToken` degenerates failed direct pair.
1129            ("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                    // Two-hop fallback must never half-commit: neither hop pool is
1150                    // reserved and no intermediate token is cached.
1151                    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        // TIP-1033 states two-hop fee math MUST apply M = 9970/10000 sequentially
1172        // (amount_in, expected_out1, expected_out2):
1173        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); // M * M
1181            const SCALE: U256 = uint!(100_000_000_U256); // SCALE * SCALE
1182            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                    // Reserves are deep enough that liquidity never bounds the result;
1199                    // any deviation in `collected_fees` is purely a fee-math bug.
1200                    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                    // pool1 (user, hop): user-side gained `amount`, hop-side lost `out1`.
1222                    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                    // pool2 (hop, validator): hop-side gained `out1`, validator-side lost `out2`.
1229                    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    /// TIP-1033 FEE15 (route immutability): once `collect_fee_pre_tx` caches the two-hop
1243    /// intermediate, `collect_fee_post_tx` MUST settle through that cached path even if
1244    /// `userToken.quoteToken()` is rotated mid-transaction. The freshly rotated quote token
1245    /// must NOT reroute the post-tx swap.
1246    #[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            // Direct pool empty + deep hop pools ⇒ pre_tx selects two-hop via `hop`.
1250            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            // Mid-tx: rotate user.quoteToken from hop → validator. After this rotation,
1260            // a freshly re-resolved route would degenerate (intermediate == validator),
1261            // so any post_tx that re-resolves would silently break (or revert).
1262            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            // Post-tx MUST use the cached two_hop_intermediate (hop), not the new quote token.
1278            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            // Both hop pool reserves must have moved (proves two-hop swap actually executed).
1289            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            // Direct pool was seeded empty (`write_pool` clamps user-side to 1) and must
1302            // remain untouched — settlement went through the cached two-hop route.
1303            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    /// TIP-1033 FEE14 (transient hygiene): `two_hop_intermediate` is transient — it MUST
1315    /// NOT survive across transaction boundaries. A subsequent transaction whose direct pool
1316    /// has liquidity must take the single-hop path with `two_hop_intermediate == 0`.
1317    #[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                // tx1: two-hop only — sets the transient intermediate.
1324                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                // Note: post_tx leaves the slot non-zero in-tx; EVM clears it at tx boundary.
1334                assert_eq!(
1335                    fm.two_hop_intermediate.t_read()?,
1336                    t.hop,
1337                    "tx1: intermediate persists in-tx until EOT",
1338                );
1339
1340                // Simulate tx boundary (EVM clears all transient storage).
1341                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                // tx2: direct pool now has liquidity — single-hop path; intermediate must stay zero.
1348                write_pool(fm, t.user, t.validator, reserve)?;
1349                // Drain hop pools so a stale intermediate would route through dry pools and revert,
1350                // making any FEE14 violation observable rather than silently consistent.
1351                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                // tx2 settled via direct pool: validator received single-hop fee.
1362                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                // tx1 collected two-hop out2; tx2 added single-hop out_single.
1366                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    /// TIP-1033 FEE12 (reservation enforcement, two-hop): a pending two-hop fee swap reserves
1378    /// liquidity on BOTH hop pools. Burns or rebalance swaps that would deplete either pool
1379    /// past its reservation MUST revert with `InsufficientLiquidity`.
1380    #[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            // Tight hop pools — full burn would dip below the pending reservation.
1384            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            // Seed admin LP balance + total supply for both hop pools so `burn` reaches
1390            // the reservation check (not gated by LP-balance check). The reservation check
1391            // fires before any token transfer, so we don't need real AMM-held balances.
1392            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            // pre_tx reserves: hop1 = 997, hop2 = 994 (computed from 1_000 input).
1400            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            // Full-supply burn on hop1 would zero the reserve → must trip reservation.
1407            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            // Same for hop2.
1419            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            // Sanity: reservations are unchanged by the failed burns.
1431            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}