Skip to main content

tycho_simulation/evm/protocol/uniswap_v4/
state.rs

1use std::{any::Any, collections::HashMap, fmt};
2
3use alloy::primitives::{Address, Sign, I256, U256};
4use num_bigint::BigUint;
5use num_traits::{CheckedSub, ToPrimitive, Zero};
6use revm::primitives::I128;
7use tracing::trace;
8use tycho_common::{
9    dto::ProtocolStateDelta,
10    models::token::Token,
11    simulation::{
12        errors::{SimulationError, TransitionError},
13        protocol_sim::{
14            Balances, GetAmountOutResult, PoolSwap, ProtocolSim, QueryPoolSwapParams,
15            SwapConstraint,
16        },
17    },
18    Bytes,
19};
20
21use super::hooks::utils::{has_permission, HookOptions};
22use crate::{
23    evm::protocol::{
24        clmm::clmm_swap_to_price,
25        safe_math::{safe_add_u256, safe_sub_u256},
26        u256_num::{u256_to_biguint, u256_to_f64},
27        uniswap_v4::hooks::{
28            hook_handler::HookHandler,
29            models::{
30                AfterSwapParameters, BalanceDelta, BeforeSwapDelta, BeforeSwapParameters,
31                StateContext, SwapParams,
32            },
33        },
34        utils::{
35            add_fee_markup,
36            uniswap::{
37                i24_be_bytes_to_i32, liquidity_math,
38                lp_fee::{self, is_dynamic},
39                sqrt_price_math::{get_amount0_delta, get_amount1_delta, sqrt_price_q96_to_f64},
40                swap_math,
41                tick_list::{TickInfo, TickList, TickListErrorKind},
42                tick_math::{
43                    get_sqrt_ratio_at_tick, get_tick_at_sqrt_ratio, MAX_SQRT_RATIO, MAX_TICK,
44                    MIN_SQRT_RATIO, MIN_TICK,
45                },
46                StepComputation, SwapResults, SwapState,
47            },
48        },
49        vm::constants::EXTERNAL_ACCOUNT,
50    },
51    impl_non_serializable_protocol,
52};
53
54// Fixed overhead per swap: covers router overhead, executor preamble (decode, sync,
55// unlock/callback pattern), and token transfer-in.
56const SWAP_BASE_GAS: u64 = 185_000;
57// Per loop: PoolManager.swap bitmap lookup + sqrt math + computeSwapStep.
58// V4's singleton PoolManager hits warmer storage than V3 standalone pools: ~3,500/loop.
59const GAS_PER_BITMAP_LOOKUP: u64 = 3_500;
60// Initialized tick crossing: _updateTick() updates feeGrowthOutside0/1 (2 SSTOREs).
61// Warm ≈ 10–17k, cold ≈ 40–52k. We use a blended estimate that
62// weights toward cold costs.
63const GAS_PER_TICK: u64 = 29_000;
64// Settlement overhead within swapExactInputSingle: _settle() + _getFullCredit() + misc.
65const V4_CALLBACK_SETTLEMENT_GAS: u64 = 30_000;
66// PoolManager Hooks.sol wrapper overhead per hook call: ABI encode params,
67// external CALL dispatch, decode return, validate selector, and process
68// the returned BeforeSwapDelta / AfterSwapDelta.
69const PM_PER_HOOK_CALL_OVERHEAD: u64 = 25_000;
70// Conservative max gas budget for a single swap (Ethereum transaction gas limit)
71const MAX_SWAP_GAS: u64 = 16_700_000;
72const MAX_TICKS_CROSSED: u64 = (MAX_SWAP_GAS - SWAP_BASE_GAS) / GAS_PER_TICK;
73// Decimal exponent of the output amount a hook is probed with to read off its fee rate.
74// Hooks floor each term of their fee, so the rate is only exact in the limit of a large amount;
75// 1e30 makes the rounding smaller than an f64 can hold and still leaves room under U256::MAX for
76// the hook's own intermediate products.
77const HOOK_FEE_PROBE_EXP: u64 = 30;
78
79#[derive(Clone)]
80pub struct UniswapV4State {
81    liquidity: u128,
82    sqrt_price: U256,
83    fees: UniswapV4Fees,
84    tick: i32,
85    ticks: TickList,
86    tick_spacing: i32,
87    pub hook: Option<Box<dyn HookHandler>>,
88}
89
90impl_non_serializable_protocol!(UniswapV4State, "not supported due vm state deps");
91
92impl fmt::Debug for UniswapV4State {
93    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
94        f.debug_struct("UniswapV4State")
95            .field("liquidity", &self.liquidity)
96            .field("sqrt_price", &self.sqrt_price)
97            .field("fees", &self.fees)
98            .field("tick", &self.tick)
99            .field("tick_spacing", &self.tick_spacing)
100            .finish_non_exhaustive()
101    }
102}
103
104impl PartialEq for UniswapV4State {
105    fn eq(&self, other: &Self) -> bool {
106        match (&self.hook, &other.hook) {
107            (Some(a), Some(b)) => a.is_equal(&**b),
108            (None, None) => true,
109            _ => false,
110        }
111    }
112}
113
114impl Eq for UniswapV4State {}
115
116#[derive(Clone, Debug, PartialEq, Eq)]
117pub struct UniswapV4Fees {
118    // Protocol fees in the zero for one direction
119    pub zero_for_one: u32,
120    // Protocol fees in the one for zero direction
121    pub one_for_zero: u32,
122    // Liquidity providers fees
123    pub lp_fee: u32,
124}
125
126impl UniswapV4Fees {
127    pub fn new(zero_for_one: u32, one_for_zero: u32, lp_fee: u32) -> Self {
128        Self { zero_for_one, one_for_zero, lp_fee }
129    }
130
131    fn calculate_swap_fees_pips(&self, zero_for_one: bool, lp_fee_override: Option<u32>) -> u32 {
132        let protocol_fee = if zero_for_one { self.zero_for_one } else { self.one_for_zero };
133        let lp_fee = lp_fee_override.unwrap_or_else(|| {
134            // If a protocol has dynamic fees,
135            if is_dynamic(self.lp_fee) {
136                0
137            } else {
138                self.lp_fee
139            }
140        });
141
142        // UniswapV4 formula: protocolFee + lpFee - (protocolFee * lpFee / 1_000_000)
143        // Source: https://raw.githubusercontent.com/Uniswap/v4-core/main/src/libraries/ProtocolFeeLibrary.sol
144        // This accounts for the fact that protocol fee is taken first, then LP fee applies to
145        // remainder
146        protocol_fee + lp_fee - ((protocol_fee as u64 * lp_fee as u64 / 1_000_000) as u32)
147    }
148}
149
150impl UniswapV4State {
151    /// Creates a new `UniswapV4State` with specified values.
152    pub fn new(
153        liquidity: u128,
154        sqrt_price: U256,
155        fees: UniswapV4Fees,
156        tick: i32,
157        tick_spacing: i32,
158        ticks: Vec<TickInfo>,
159    ) -> Result<Self, SimulationError> {
160        let tick_spacing_u16 = tick_spacing.try_into().map_err(|_| {
161            // even though it's given as int24, tick_spacing must be positive, see here:
162            // https://github.com/Uniswap/v4-core/blob/a22414e4d7c0d0b0765827fe0a6c20dfd7f96291/src/libraries/TickMath.sol#L25-L28
163            SimulationError::FatalError(format!(
164                "tick_spacing {} must be positive (int24 -> u16 conversion failed)",
165                tick_spacing
166            ))
167        })?;
168        let tick_list = TickList::from(tick_spacing_u16, ticks)?;
169        Ok(UniswapV4State {
170            liquidity,
171            sqrt_price,
172            fees,
173            tick,
174            ticks: tick_list,
175            tick_spacing,
176            hook: None,
177        })
178    }
179
180    fn swap(
181        &self,
182        zero_for_one: bool,
183        amount_specified: I256,
184        sqrt_price_limit: Option<U256>,
185        lp_fee_override: Option<u32>,
186    ) -> Result<SwapResults, SimulationError> {
187        if amount_specified == I256::ZERO {
188            return Ok(SwapResults {
189                amount_calculated: I256::ZERO,
190                amount_specified: I256::ZERO,
191                amount_remaining: I256::ZERO,
192                sqrt_price: self.sqrt_price,
193                liquidity: self.liquidity,
194                tick: self.tick,
195                gas_used: U256::from(3_000), // baseline gas cost for no-op swap
196            });
197        }
198
199        if self.liquidity == 0 {
200            return Err(SimulationError::RecoverableError("No liquidity".to_string()));
201        }
202        let price_limit = if let Some(limit) = sqrt_price_limit {
203            limit
204        } else if zero_for_one {
205            safe_add_u256(MIN_SQRT_RATIO, U256::from(1u64))?
206        } else {
207            safe_sub_u256(MAX_SQRT_RATIO, U256::from(1u64))?
208        };
209
210        let price_limit_valid = if zero_for_one {
211            price_limit > MIN_SQRT_RATIO && price_limit < self.sqrt_price
212        } else {
213            price_limit < MAX_SQRT_RATIO && price_limit > self.sqrt_price
214        };
215        if !price_limit_valid {
216            return Err(SimulationError::InvalidInput("Price limit out of range".into(), None));
217        }
218
219        let exact_input = amount_specified < I256::ZERO;
220
221        let mut state = SwapState {
222            amount_remaining: amount_specified,
223            amount_calculated: I256::ZERO,
224            sqrt_price: self.sqrt_price,
225            tick: self.tick,
226            liquidity: self.liquidity,
227        };
228        let mut gas_used = U256::from(SWAP_BASE_GAS);
229
230        while state.amount_remaining != I256::ZERO && state.sqrt_price != price_limit {
231            let (mut next_tick, initialized) = match self
232                .ticks
233                .next_initialized_tick_within_one_word(state.tick, zero_for_one)
234            {
235                Ok((tick, init)) => {
236                    gas_used = safe_add_u256(gas_used, U256::from(GAS_PER_BITMAP_LOOKUP))?;
237                    (tick, init)
238                }
239                Err(tick_err) => match tick_err.kind {
240                    TickListErrorKind::TicksExeeded => {
241                        let mut new_state = self.clone();
242                        new_state.liquidity = state.liquidity;
243                        new_state.tick = state.tick;
244                        new_state.sqrt_price = state.sqrt_price;
245                        return Err(SimulationError::InvalidInput(
246                            "Ticks exceeded".into(),
247                            Some(GetAmountOutResult::new(
248                                u256_to_biguint(state.amount_calculated.abs().into_raw()),
249                                u256_to_biguint(gas_used),
250                                Box::new(new_state),
251                            )),
252                        ));
253                    }
254                    _ => return Err(SimulationError::FatalError("Unknown error".to_string())),
255                },
256            };
257
258            next_tick = next_tick.clamp(MIN_TICK, MAX_TICK);
259
260            let sqrt_price_next = get_sqrt_ratio_at_tick(next_tick)?;
261            let fee_pips = self
262                .fees
263                .calculate_swap_fees_pips(zero_for_one, lp_fee_override);
264
265            let sqrt_price_start = state.sqrt_price;
266            let (sqrt_price, amount_in, amount_out, fee_amount) = swap_math::compute_swap_step(
267                state.sqrt_price,
268                UniswapV4State::get_sqrt_ratio_target(sqrt_price_next, price_limit, zero_for_one),
269                state.liquidity,
270                // The core univ4 swap logic assumes that if the amount is > 0 it's exact in, and
271                // if it's < 0 it's exact out. The compute_swap_step assumes the
272                // opposite (it's like that for univ3).
273                -state.amount_remaining,
274                fee_pips,
275            )?;
276            state.sqrt_price = sqrt_price;
277
278            let step = StepComputation {
279                sqrt_price_start,
280                tick_next: next_tick,
281                initialized,
282                sqrt_price_next,
283                amount_in,
284                amount_out,
285                fee_amount,
286            };
287            if exact_input {
288                state.amount_remaining += I256::checked_from_sign_and_abs(
289                    Sign::Positive,
290                    safe_add_u256(step.amount_in, step.fee_amount)?,
291                )
292                .unwrap();
293                state.amount_calculated -=
294                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
295            } else {
296                state.amount_remaining -=
297                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
298                state.amount_calculated += I256::checked_from_sign_and_abs(
299                    Sign::Positive,
300                    safe_add_u256(step.amount_in, step.fee_amount)?,
301                )
302                .unwrap();
303            }
304            if state.sqrt_price == step.sqrt_price_next {
305                if step.initialized {
306                    let liquidity_raw = self
307                        .ticks
308                        .get_tick(step.tick_next)
309                        .unwrap()
310                        .net_liquidity;
311                    let liquidity_net = if zero_for_one { -liquidity_raw } else { liquidity_raw };
312                    state.liquidity =
313                        liquidity_math::add_liquidity_delta(state.liquidity, liquidity_net)?;
314                    gas_used = safe_add_u256(gas_used, U256::from(GAS_PER_TICK))?;
315                }
316                state.tick = if zero_for_one { step.tick_next - 1 } else { step.tick_next };
317            } else if state.sqrt_price != step.sqrt_price_start {
318                state.tick = get_tick_at_sqrt_ratio(state.sqrt_price)?;
319            }
320        }
321
322        Ok(SwapResults {
323            amount_calculated: state.amount_calculated,
324            amount_specified,
325            amount_remaining: state.amount_remaining,
326            sqrt_price: state.sqrt_price,
327            liquidity: state.liquidity,
328            tick: state.tick,
329            gas_used: safe_add_u256(gas_used, U256::from(V4_CALLBACK_SETTLEMENT_GAS))?,
330        })
331    }
332
333    pub fn set_hook_handler(&mut self, handler: Box<dyn HookHandler>) {
334        self.hook = Some(handler);
335    }
336
337    fn get_sqrt_ratio_target(
338        sqrt_price_next: U256,
339        sqrt_price_limit: U256,
340        zero_for_one: bool,
341    ) -> U256 {
342        let cond1 = if zero_for_one {
343            sqrt_price_next < sqrt_price_limit
344        } else {
345            sqrt_price_next > sqrt_price_limit
346        };
347
348        if cond1 {
349            sqrt_price_limit
350        } else {
351            sqrt_price_next
352        }
353    }
354
355    fn find_limits_experimentally(
356        &self,
357        token_in: Bytes,
358        token_out: Bytes,
359    ) -> Result<(BigUint, BigUint), SimulationError> {
360        // Create dummy token objects with proper addresses. This is fine since `get_amount_out`
361        // only uses the token addresses.
362        let token_in_obj =
363            Token::new(&token_in, "TOKEN_IN", 18, 0, &[Some(10_000)], Default::default(), 100);
364        let token_out_obj =
365            Token::new(&token_out, "TOKEN_OUT", 18, 0, &[Some(10_000)], Default::default(), 100);
366
367        self.find_max_amount(&token_in_obj, &token_out_obj)
368    }
369
370    /// Finds max amount by performing exponential search.
371    ///
372    /// Reasoning:
373    /// - get_amount_out(I256::MAX) will almost always fail, so this will waste time checking values
374    ///   unrealistically high.
375    /// - If you were to start binary search from 1 to 10^76, you'd need hundreds of iterations.
376    ///
377    /// More about exponential search: https://en.wikipedia.org/wiki/Exponential_search
378    ///
379    /// # Returns
380    ///
381    /// Returns a tuple containing the max amount in and max amount out respectively.
382    fn find_max_amount(
383        &self,
384        token_in: &Token,
385        token_out: &Token,
386    ) -> Result<(BigUint, BigUint), SimulationError> {
387        let mut low = BigUint::from(1u64);
388
389        // The max you can swap on a USV4 is I256::MAX is 5.7e76, since input amount is I256.
390        // So start with something much smaller to search for a reasonable upper bound.
391        let mut high = BigUint::from(10u64).pow(18); // 1 ether in wei
392        let mut last_successful_amount_in = BigUint::from(1u64);
393        let mut last_successful_amount_out = BigUint::from(0u64);
394
395        // First, find an upper bound where the swap fails using exponential search.
396        // Save and return both the amount in and amount out.
397        while let Ok(result) = self.get_amount_out(high.clone(), token_in, token_out) {
398            // We haven't found the upper bound yet, increase the attempted upper bound
399            // by order of magnitude and store the last success as the lower bound.
400            low = last_successful_amount_in.clone();
401            last_successful_amount_in = high.clone();
402            last_successful_amount_out = result.amount;
403            high *= BigUint::from(10u64);
404
405            // Stop if we're getting too large for I256 (about 10^75)
406            if high > BigUint::from(10u64).pow(75) {
407                return Ok((last_successful_amount_in, last_successful_amount_out));
408            }
409        }
410
411        // Use binary search to narrow down value between low and high
412        while &high - &low > BigUint::from(1u64) {
413            let mid = (&low + &high) / BigUint::from(2u64);
414
415            match self.get_amount_out(mid.clone(), token_in, token_out) {
416                Ok(result) => {
417                    last_successful_amount_in = mid.clone();
418                    last_successful_amount_out = result.amount;
419                    low = mid;
420                }
421                Err(_) => {
422                    high = mid;
423                }
424            }
425        }
426
427        Ok((last_successful_amount_in, last_successful_amount_out))
428    }
429
430    /// Helper method to check if there are no initialized ticks in either direction
431    fn has_no_initialized_ticks(&self) -> bool {
432        !self.ticks.has_initialized_ticks()
433    }
434
435    /// The pool's own spot buy price for `base` in units of `quote`: the amount of `quote` one
436    /// `base` costs at the current price, marked up by the pool's swap fee and ignoring any hook.
437    fn core_spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
438        let base_is_currency0 = base < quote;
439        let fee_pips = self
440            .fees
441            .calculate_swap_fees_pips(base_is_currency0, None);
442        let fee = fee_pips as f64 / 1_000_000.0;
443
444        let price = if base_is_currency0 {
445            sqrt_price_q96_to_f64(self.sqrt_price, base.decimals, quote.decimals)?
446        } else {
447            1.0f64 / sqrt_price_q96_to_f64(self.sqrt_price, quote.decimals, base.decimals)?
448        };
449
450        Ok(add_fee_markup(price, fee))
451    }
452
453    /// The share of a swap's output that the pool's hook keeps, as a fraction of one. `None`
454    /// when the pool has no hook, or when its hook does not model its fee analytically and has
455    /// to be simulated instead.
456    ///
457    /// Fails if the hook would keep the whole output or more, which is not a rate a price can be
458    /// marked up by.
459    fn hook_fee_rate(&self, zero_for_one: bool) -> Result<Option<f64>, SimulationError> {
460        let Some(hook) = &self.hook else { return Ok(None) };
461
462        let probe = U256::from(10u64).pow(U256::from(HOOK_FEE_PROBE_EXP));
463        let Some(fee) = hook.unspecified_fee_amount(probe, zero_for_one)? else {
464            return Ok(None);
465        };
466
467        let rate = u256_to_f64(fee)? / u256_to_f64(probe)?;
468        if rate >= 1.0 {
469            return Err(SimulationError::FatalError(format!(
470                "Hook {} keeps {rate} of the output, leaving no price to quote",
471                hook.address()
472            )));
473        }
474
475        Ok(Some(rate))
476    }
477}
478
479#[typetag::serde]
480impl ProtocolSim for UniswapV4State {
481    // Not possible to implement correctly with the current interface because we need to know the
482    // swap direction.
483    fn fee(&self) -> f64 {
484        todo!()
485    }
486
487    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
488        if let Some(hook) = &self.hook {
489            // Buying `base` means selling `quote` into the pool, so the hook sees a swap whose
490            // input is `quote` and whose output, the leg it charges, is `base`. A hook that
491            // prices that cut analytically needs no simulation: its price is the pool's own buy
492            // price marked up by the cut.
493            if let Some(rate) = self.hook_fee_rate(quote < base)? {
494                return Ok(add_fee_markup(self.core_spot_price(base, quote)?, rate));
495            }
496
497            match hook.spot_price(base, quote) {
498                Ok(price) => return Ok(price),
499                Err(SimulationError::RecoverableError(_)) => {
500                    // Calculate spot price by swapping two amounts and use the approximation
501                    // to get the derivative, following the pattern from vm/state.rs
502
503                    // Calculate the first sell amount (x1) as a small amount
504                    let x1 = BigUint::from(10u64).pow(base.decimals) / BigUint::from(100u64); // 0.01 token
505
506                    // Calculate the second sell amount (x2) as x1 + 1% of x1
507                    let x2 = &x1 + (&x1 / BigUint::from(100u64));
508
509                    // Perform swaps to get the received amounts
510                    let y1 = self.get_amount_out(x1.clone(), base, quote)?;
511                    let y2 = self.get_amount_out(x2.clone(), base, quote)?;
512
513                    // Calculate the marginal price
514                    let num = y2
515                        .amount
516                        .checked_sub(&y1.amount)
517                        .ok_or_else(|| {
518                            SimulationError::FatalError(
519                                "Cannot calculate spot price: y2 < y1".to_string(),
520                            )
521                        })?;
522                    let den = x2.checked_sub(&x1).ok_or_else(|| {
523                        SimulationError::FatalError(
524                            "Cannot calculate spot price: x2 < x1".to_string(),
525                        )
526                    })?;
527
528                    if den == BigUint::from(0u64) {
529                        return Err(SimulationError::FatalError(
530                            "Cannot calculate spot price: denominator is zero".to_string(),
531                        ));
532                    }
533
534                    // Convert to f64 and adjust for decimals
535                    let num_f64 = num.to_f64().ok_or_else(|| {
536                        SimulationError::FatalError(
537                            "Failed to convert numerator to f64".to_string(),
538                        )
539                    })?;
540                    let den_f64 = den.to_f64().ok_or_else(|| {
541                        SimulationError::FatalError(
542                            "Failed to convert denominator to f64".to_string(),
543                        )
544                    })?;
545
546                    let token_correction = 10f64.powi(base.decimals as i32 - quote.decimals as i32);
547
548                    return Ok(num_f64 / den_f64 * token_correction);
549                }
550                Err(e) => return Err(e),
551            }
552        }
553
554        self.core_spot_price(base, quote)
555    }
556
557    fn get_amount_out(
558        &self,
559        amount_in: BigUint,
560        token_in: &Token,
561        token_out: &Token,
562    ) -> Result<GetAmountOutResult, SimulationError> {
563        let zero_for_one = token_in < token_out;
564        let amount_specified = I256::checked_from_sign_and_abs(
565            Sign::Negative,
566            U256::from_be_slice(&amount_in.to_bytes_be()),
567        )
568        .ok_or_else(|| {
569            SimulationError::InvalidInput("I256 overflow: amount_in".to_string(), None)
570        })?;
571
572        let mut amount_to_swap = amount_specified;
573        let mut lp_fee_override: Option<u32> = None;
574        let mut before_swap_gas = 0u64;
575        let mut after_swap_gas = 0u64;
576        let mut before_swap_delta = BeforeSwapDelta(I256::ZERO);
577        let mut storage_overwrites = None;
578
579        let token_in_address = Address::from_slice(&token_in.address);
580        let token_out_address = Address::from_slice(&token_out.address);
581
582        let state_context = StateContext {
583            currency_0: if zero_for_one { token_in_address } else { token_out_address },
584            currency_1: if zero_for_one { token_out_address } else { token_in_address },
585            fees: self.fees.clone(),
586            tick_spacing: self.tick_spacing,
587        };
588
589        let swap_params = SwapParams {
590            zero_for_one,
591            amount_specified: amount_to_swap,
592            sqrt_price_limit: self.sqrt_price,
593        };
594
595        // Check if hook is set and has before_swap permissions
596        if let Some(ref hook) = self.hook {
597            if has_permission(hook.address(), HookOptions::BeforeSwap) {
598                let before_swap_params = BeforeSwapParameters {
599                    context: state_context.clone(),
600                    sender: *EXTERNAL_ACCOUNT,
601                    swap_params: swap_params.clone(),
602                    hook_data: Bytes::new(),
603                };
604
605                let before_swap_result = hook
606                    .before_swap(before_swap_params, None, None)
607                    .map_err(|e| {
608                        SimulationError::FatalError(format!(
609                            "BeforeSwap hook simulation failed: {e:?}"
610                        ))
611                    })?;
612
613                before_swap_gas = before_swap_result.gas_estimate;
614                before_swap_delta = before_swap_result.result.amount_delta;
615                storage_overwrites = Some(before_swap_result.result.overwrites);
616
617                // Convert amountDelta to amountToSwap as per Uniswap V4 spec
618                // See: https://github.com/Uniswap/v4-core/blob/main/src/libraries/Hooks.sol#L270
619                if before_swap_delta.as_i256() != I256::ZERO {
620                    amount_to_swap += I256::from(before_swap_delta.get_specified_delta());
621                    if amount_to_swap > I256::ZERO {
622                        return Err(SimulationError::FatalError(
623                            "Hook delta exceeds swap amount".into(),
624                        ));
625                    }
626                }
627
628                // Set LP fee override if provided by hook
629                // The fee returned by beforeSwap may have the override flag (bit 22) set,
630                // which needs to be removed before using the fee value.
631                // See: https://github.com/Uniswap/v4-core/blob/main/src/libraries/LPFeeLibrary.sol
632                let hook_fee = before_swap_result
633                    .result
634                    .fee
635                    .to::<u32>();
636                if hook_fee != 0 {
637                    // Remove the override flag (bit 22) as per LPFeeLibrary.sol
638                    let cleaned_fee = lp_fee::remove_override_flag(hook_fee);
639
640                    // Validate the fee doesn't exceed MAX_LP_FEE (1,000,000 pips = 100%)
641                    if !lp_fee::is_valid(cleaned_fee) {
642                        return Err(SimulationError::FatalError(format!(
643                            "LP fee override {} exceeds maximum {} pips",
644                            cleaned_fee,
645                            lp_fee::MAX_LP_FEE
646                        )));
647                    }
648
649                    lp_fee_override = Some(cleaned_fee);
650                }
651            }
652        }
653
654        // Perform the swap with potential hook modifications
655        let result = self.swap(zero_for_one, amount_to_swap, None, lp_fee_override)?;
656
657        // Create BalanceDelta from swap result using the proper constructor
658        let mut swap_delta = BalanceDelta::from_swap_result(result.amount_calculated, zero_for_one);
659
660        // Get deltas (change in the specified/given and unspecified/computed token balances after
661        // calling before swap)
662        let hook_delta_specified = before_swap_delta.get_specified_delta();
663        let mut hook_delta_unspecified = before_swap_delta.get_unspecified_delta();
664
665        if let Some(ref hook) = self.hook {
666            if has_permission(hook.address(), HookOptions::AfterSwap) {
667                let after_swap_params = AfterSwapParameters {
668                    context: state_context,
669                    sender: *EXTERNAL_ACCOUNT,
670                    swap_params,
671                    delta: swap_delta,
672                    hook_data: Bytes::new(),
673                };
674
675                let after_swap_result = hook
676                    .after_swap(after_swap_params, storage_overwrites, None)
677                    .map_err(|e| {
678                        SimulationError::FatalError(format!(
679                            "AfterSwap hook simulation failed: {e:?}"
680                        ))
681                    })?;
682                after_swap_gas = after_swap_result.gas_estimate;
683                // Hooks.sol calls afterSwap whenever AFTER_SWAP_FLAG is set, but only parses the
684                // returned delta when AFTER_SWAP_RETURNS_DELTA_FLAG is set too. Without that
685                // permission the PoolManager discards the return value, so the hook still costs
686                // gas but cannot move the swapper's balance.
687                if has_permission(hook.address(), HookOptions::AfterSwapReturnsDelta) {
688                    hook_delta_unspecified += after_swap_result.result;
689                }
690            }
691        }
692
693        // Replicates the behaviour of the Hooks library wrapper of the afterSwap method:
694        // https://github.com/Uniswap/v4-core/blob/59d3ecf53afa9264a16bba0e38f4c5d2231f80bc/src/libraries/Hooks.sol
695        if (hook_delta_specified != I128::ZERO) || (hook_delta_unspecified != I128::ZERO) {
696            let hook_delta = if (amount_specified < I256::ZERO) == zero_for_one {
697                BalanceDelta::new(hook_delta_specified, hook_delta_unspecified)
698            } else {
699                BalanceDelta::new(hook_delta_unspecified, hook_delta_specified)
700            };
701            // This is a BalanceDelta subtraction
702            swap_delta = swap_delta - hook_delta
703        }
704
705        let amount_out = if (amount_specified < I256::ZERO) == zero_for_one {
706            swap_delta.amount1()
707        } else {
708            swap_delta.amount0()
709        };
710
711        trace!(?amount_in, ?token_in, ?token_out, ?zero_for_one, ?result, "V4 SWAP");
712        let mut new_state = self.clone();
713        new_state.liquidity = result.liquidity;
714        new_state.tick = result.tick;
715        new_state.sqrt_price = result.sqrt_price;
716
717        // Add hook gas costs to baseline swap cost.
718        // before_swap_gas / after_swap_gas capture the hook contract's internal
719        // logic (from VM simulation). PM_PER_HOOK_CALL_OVERHEAD accounts for the
720        // PoolManager's Hooks.sol dispatch wrapper that is not captured by either
721        // the native swap constants or the VM simulation.
722        let mut hook_overhead = before_swap_gas + after_swap_gas;
723        if before_swap_gas > 0 {
724            hook_overhead += PM_PER_HOOK_CALL_OVERHEAD;
725        }
726        if after_swap_gas > 0 {
727            hook_overhead += PM_PER_HOOK_CALL_OVERHEAD;
728        }
729        let total_gas_used = result.gas_used + U256::from(hook_overhead);
730        Ok(GetAmountOutResult::new(
731            u256_to_biguint(U256::from(amount_out.abs())),
732            u256_to_biguint(total_gas_used),
733            Box::new(new_state),
734        ))
735    }
736
737    fn get_limits(
738        &self,
739        token_in: Bytes,
740        token_out: Bytes,
741    ) -> Result<(BigUint, BigUint), SimulationError> {
742        if let Some(hook) = &self.hook {
743            // Check if pool has no liquidity & ticks -> hook manages liquidity
744            if self.liquidity == 0 && self.has_no_initialized_ticks() {
745                // If the hook has a get_amount_ranges entrypoint, call it and return (0, limits[1])
746                match hook.get_amount_ranges(token_in.clone(), token_out.clone()) {
747                    Ok(amount_ranges) => {
748                        return Ok((
749                            u256_to_biguint(amount_ranges.amount_in_range.1),
750                            u256_to_biguint(amount_ranges.amount_out_range.1),
751                        ))
752                    }
753                    // Check if hook get_amount_ranges is not implemented or the limits entrypoint
754                    // is not set for this hook
755                    Err(SimulationError::RecoverableError(msg))
756                        if msg.contains("not implemented") || msg.contains("not set") =>
757                    {
758                        // Hook manages liquidity but doesn't have get_amount_ranges
759                        // Use binary search to find limits by calling swap with increasing amounts
760                        return self.find_limits_experimentally(token_in, token_out);
761                        // Otherwise fall back to default implementation
762                    }
763                    Err(e) => return Err(e),
764                }
765            }
766        }
767
768        // If the pool has no liquidity, return zeros for both limits
769        if self.liquidity == 0 {
770            return Ok((BigUint::zero(), BigUint::zero()));
771        }
772
773        let zero_for_one = token_in < token_out;
774        let mut current_tick = self.tick;
775        let mut current_sqrt_price = self.sqrt_price;
776        let mut current_liquidity = self.liquidity;
777        let mut total_amount_in = U256::ZERO;
778        let mut total_amount_out = U256::ZERO;
779        let mut ticks_crossed: u64 = 0;
780
781        // Iterate through ticks in the direction of the swap
782        // Stops when: no more liquidity, no more ticks, or gas limit would be exceeded
783        while let Ok((tick, initialized)) = self
784            .ticks
785            .next_initialized_tick_within_one_word(current_tick, zero_for_one)
786        {
787            // Cap iteration to prevent exceeding Ethereum's gas limit
788            if ticks_crossed >= MAX_TICKS_CROSSED {
789                break;
790            }
791            ticks_crossed += 1;
792
793            // Clamp the tick value to ensure it's within valid range
794            let next_tick = tick.clamp(MIN_TICK, MAX_TICK);
795
796            // Calculate the sqrt price at the next tick boundary
797            let sqrt_price_next = get_sqrt_ratio_at_tick(next_tick)?;
798
799            // Calculate the amount of tokens swapped when moving from current_sqrt_price to
800            // sqrt_price_next. Direction determines which token is being swapped in vs out
801            let (amount_in, amount_out) = if zero_for_one {
802                let amount0 = get_amount0_delta(
803                    sqrt_price_next,
804                    current_sqrt_price,
805                    current_liquidity,
806                    true,
807                )?;
808                let amount1 = get_amount1_delta(
809                    sqrt_price_next,
810                    current_sqrt_price,
811                    current_liquidity,
812                    false,
813                )?;
814                (amount0, amount1)
815            } else {
816                let amount0 = get_amount0_delta(
817                    sqrt_price_next,
818                    current_sqrt_price,
819                    current_liquidity,
820                    false,
821                )?;
822                let amount1 = get_amount1_delta(
823                    sqrt_price_next,
824                    current_sqrt_price,
825                    current_liquidity,
826                    true,
827                )?;
828                (amount1, amount0)
829            };
830
831            // Accumulate total amounts for this tick range
832            total_amount_in = safe_add_u256(total_amount_in, amount_in)?;
833            total_amount_out = safe_add_u256(total_amount_out, amount_out)?;
834
835            // If this tick is "initialized" (meaning its someone's position boundary), update the
836            // liquidity when crossing it
837            // For zero_for_one, liquidity is removed when crossing a tick
838            // For one_for_zero, liquidity is added when crossing a tick
839            if initialized {
840                let liquidity_raw = self
841                    .ticks
842                    .get_tick(next_tick)
843                    .unwrap()
844                    .net_liquidity;
845                let liquidity_delta = if zero_for_one { -liquidity_raw } else { liquidity_raw };
846
847                // Check if applying this liquidity delta would cause underflow
848                // If so, stop here rather than continuing with invalid state
849                match liquidity_math::add_liquidity_delta(current_liquidity, liquidity_delta) {
850                    Ok(new_liquidity) => {
851                        current_liquidity = new_liquidity;
852                    }
853                    Err(_) => {
854                        // Liquidity would underflow, stop iteration here
855                        // This represents the maximum liquidity we can actually use
856                        break;
857                    }
858                }
859            }
860
861            // Move to the next tick position
862            current_tick = if zero_for_one { next_tick - 1 } else { next_tick };
863            current_sqrt_price = sqrt_price_next;
864
865            // If we've consumed all liquidity, no point continuing the loop
866            if current_liquidity == 0 {
867                break;
868            }
869        }
870
871        // A hook that charges the output reduces what a swapper can actually receive, so the
872        // limit has to be reported net of its cut.
873        if let Some(hook) = &self.hook {
874            if let Some(fee) = hook.unspecified_fee_amount(total_amount_out, zero_for_one)? {
875                total_amount_out = safe_sub_u256(total_amount_out, fee)?;
876            }
877        }
878
879        Ok((u256_to_biguint(total_amount_in), u256_to_biguint(total_amount_out)))
880    }
881
882    fn delta_transition(
883        &mut self,
884        delta: ProtocolStateDelta,
885        tokens: &HashMap<Bytes, Token>,
886        balances: &Balances,
887    ) -> Result<(), TransitionError> {
888        if let Some(mut hook) = self.hook.clone() {
889            match hook.delta_transition(delta.clone(), tokens, balances) {
890                Ok(()) => self.set_hook_handler(hook),
891                Err(TransitionError::SimulationError(SimulationError::RecoverableError(msg)))
892                    if msg.contains("not implemented") =>
893                {
894                    // Fall back to default implementation
895                }
896                Err(e) => return Err(e),
897            }
898        }
899
900        // Apply attribute changes
901        if let Some(liquidity) = delta
902            .updated_attributes
903            .get("liquidity")
904        {
905            self.liquidity = u128::from(liquidity.clone());
906        }
907        if let Some(sqrt_price) = delta
908            .updated_attributes
909            .get("sqrt_price_x96")
910        {
911            self.sqrt_price = U256::from_be_slice(sqrt_price);
912        }
913        if let Some(tick) = delta.updated_attributes.get("tick") {
914            self.tick = i24_be_bytes_to_i32(tick);
915        }
916        if let Some(lp_fee) = delta.updated_attributes.get("fee") {
917            self.fees.lp_fee = u32::from(lp_fee.clone());
918        }
919        if let Some(zero2one_protocol_fee) = delta
920            .updated_attributes
921            .get("protocol_fees/zero2one")
922        {
923            self.fees.zero_for_one = u32::from(zero2one_protocol_fee.clone());
924        }
925        if let Some(one2zero_protocol_fee) = delta
926            .updated_attributes
927            .get("protocol_fees/one2zero")
928        {
929            self.fees.one_for_zero = u32::from(one2zero_protocol_fee.clone());
930        }
931
932        // apply tick changes
933        for (key, value) in delta.updated_attributes.iter() {
934            // tick liquidity keys are in the format "ticks/{tick_index}/net_liquidity"
935            if key.starts_with("ticks/") {
936                let parts: Vec<&str> = key.split('/').collect();
937                self.ticks
938                    .set_tick_liquidity(
939                        parts[1]
940                            .parse::<i32>()
941                            .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
942                        i128::from(value.clone()),
943                    )
944                    .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
945            }
946        }
947        // delete ticks - ignores deletes for attributes other than tick liquidity
948        for key in delta.deleted_attributes.iter() {
949            // tick liquidity keys are in the format "ticks/{tick_index}/net_liquidity"
950            if key.starts_with("ticks/") {
951                let parts: Vec<&str> = key.split('/').collect();
952                self.ticks
953                    .set_tick_liquidity(
954                        parts[1]
955                            .parse::<i32>()
956                            .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
957                        0,
958                    )
959                    .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
960            }
961        }
962
963        Ok(())
964    }
965
966    /// See [`ProtocolSim::query_pool_swap`] for the trait documentation.
967    ///
968    /// This method uses Uniswap V4 internal swap logic by swapping an infinite amount of token_in
969    /// until the target price is reached. Takes into account V4-specific features like protocol
970    /// fees and dynamic LP fees.
971    ///
972    /// Note: This implementation does not invoke hooks, as it is a query-only operation meant to
973    /// determine available liquidity at a given price without executing an actual swap.
974    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
975        if self.liquidity == 0 {
976            return Err(SimulationError::FatalError("No liquidity".to_string()));
977        }
978
979        // Calculate total fee (protocol + LP fee) for V4
980        let zero_for_one = params.token_in().address < params.token_out().address;
981        let fee_pips = self
982            .fees
983            .calculate_swap_fees_pips(zero_for_one, None);
984
985        match params.swap_constraint() {
986            SwapConstraint::TradeLimitPrice { .. } => Err(SimulationError::InvalidInput(
987                "Uniswap V4 does not support TradeLimitPrice constraint in query_pool_swap"
988                    .to_string(),
989                None,
990            )),
991            SwapConstraint::PoolTargetPrice {
992                target,
993                tolerance: _,
994                min_amount_in: _,
995                max_amount_in: _,
996            } => {
997                if self.liquidity == 0 {
998                    return Err(SimulationError::FatalError("No liquidity".to_string()));
999                }
1000
1001                let (amount_in, amount_out, swap_result) = clmm_swap_to_price(
1002                    self.sqrt_price,
1003                    &params.token_in().address,
1004                    &params.token_out().address,
1005                    target,
1006                    fee_pips,
1007                    Sign::Negative, // V4 uses negative for exact input
1008                    |zero_for_one, amount_specified, sqrt_price_limit| {
1009                        self.swap(zero_for_one, amount_specified, Some(sqrt_price_limit), None)
1010                    },
1011                )?;
1012
1013                let mut new_state = self.clone();
1014                new_state.liquidity = swap_result.liquidity;
1015                new_state.tick = swap_result.tick;
1016                new_state.sqrt_price = swap_result.sqrt_price;
1017
1018                Ok(PoolSwap::new(amount_in, amount_out, Box::new(new_state), None))
1019            }
1020        }
1021    }
1022
1023    fn clone_box(&self) -> Box<dyn ProtocolSim> {
1024        Box::new(self.clone())
1025    }
1026
1027    fn as_any(&self) -> &dyn Any {
1028        self
1029    }
1030
1031    fn as_any_mut(&mut self) -> &mut dyn Any {
1032        self
1033    }
1034
1035    fn eq(&self, other: &dyn ProtocolSim) -> bool {
1036        if let Some(other_state) = other
1037            .as_any()
1038            .downcast_ref::<UniswapV4State>()
1039        {
1040            self.liquidity == other_state.liquidity &&
1041                self.sqrt_price == other_state.sqrt_price &&
1042                self.fees == other_state.fees &&
1043                self.tick == other_state.tick &&
1044                self.ticks == other_state.ticks
1045        } else {
1046            false
1047        }
1048    }
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053    use std::{collections::HashSet, fs, path::Path, str::FromStr};
1054
1055    use alloy::primitives::{aliases::U24, U160};
1056    use num_traits::FromPrimitive;
1057    use rstest::rstest;
1058    use serde_json::Value;
1059    use tycho_client::feed::{synchronizer::ComponentWithState, BlockHeader};
1060    use tycho_common::{models::Chain, simulation::protocol_sim::Price};
1061
1062    use super::*;
1063    use crate::{
1064        evm::{
1065            engine_db::{
1066                create_engine,
1067                simulation_db::SimulationDB,
1068                utils::{get_client, get_runtime},
1069            },
1070            protocol::{
1071                u256_num::biguint_to_u256,
1072                uniswap_v4::hooks::{
1073                    angstrom::hook_handler::{AngstromFees, AngstromHookHandler},
1074                    generic_vm_hook_handler::GenericVMHookHandler,
1075                    models::{AfterSwapDelta, AmountRanges, BeforeSwapOutput, WithGasEstimate},
1076                    pons_v2::hook_handler::{PonsV2HookHandler, PONS_V2_HOOK_ROBINHOOD},
1077                },
1078                utils::uniswap::{lp_fee, sqrt_price_math::get_sqrt_price_q96},
1079            },
1080        },
1081        protocol::models::{DecoderContext, TryFromWithBlock},
1082    };
1083
1084    // Helper methods to create commonly used tokens
1085    fn usdc() -> Token {
1086        Token::new(
1087            &Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(),
1088            "USDC",
1089            6,
1090            0,
1091            &[Some(10_000)],
1092            Default::default(),
1093            100,
1094        )
1095    }
1096
1097    fn weth() -> Token {
1098        Token::new(
1099            &Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(),
1100            "WETH",
1101            18,
1102            0,
1103            &[Some(10_000)],
1104            Default::default(),
1105            100,
1106        )
1107    }
1108
1109    fn eth() -> Token {
1110        Token::new(
1111            &Bytes::from_str("0x0000000000000000000000000000000000000000").unwrap(),
1112            "ETH",
1113            18,
1114            0,
1115            &[Some(10_000)],
1116            Default::default(),
1117            100,
1118        )
1119    }
1120
1121    fn token_x() -> Token {
1122        Token::new(
1123            &Bytes::from_str("0x0000000000000000000000000000000000000001").unwrap(),
1124            "X",
1125            18,
1126            0,
1127            &[Some(10_000)],
1128            Default::default(),
1129            100,
1130        )
1131    }
1132
1133    fn token_y() -> Token {
1134        Token::new(
1135            &Bytes::from_str("0x0000000000000000000000000000000000000002").unwrap(),
1136            "Y",
1137            18,
1138            0,
1139            &[Some(10_000)],
1140            Default::default(),
1141            100,
1142        )
1143    }
1144
1145    #[test]
1146    fn test_delta_transition() {
1147        let mut pool = UniswapV4State::new(
1148            1000,
1149            U256::from_str("1000").unwrap(),
1150            UniswapV4Fees { zero_for_one: 100, one_for_zero: 90, lp_fee: 700 },
1151            100,
1152            60,
1153            vec![TickInfo::new(120, 10000).unwrap(), TickInfo::new(180, -10000).unwrap()],
1154        )
1155        .unwrap();
1156
1157        let attributes: HashMap<String, Bytes> = [
1158            ("liquidity".to_string(), Bytes::from(2000_u64.to_be_bytes().to_vec())),
1159            ("sqrt_price_x96".to_string(), Bytes::from(1001_u64.to_be_bytes().to_vec())),
1160            ("tick".to_string(), Bytes::from(120_i32.to_be_bytes().to_vec())),
1161            ("protocol_fees/zero2one".to_string(), Bytes::from(50_u32.to_be_bytes().to_vec())),
1162            ("protocol_fees/one2zero".to_string(), Bytes::from(75_u32.to_be_bytes().to_vec())),
1163            ("fee".to_string(), Bytes::from(100_u32.to_be_bytes().to_vec())),
1164            ("ticks/-120/net_liquidity".to_string(), Bytes::from(10200_u64.to_be_bytes().to_vec())),
1165            ("ticks/120/net_liquidity".to_string(), Bytes::from(9800_u64.to_be_bytes().to_vec())),
1166            ("block_number".to_string(), Bytes::from(2000_u64.to_be_bytes().to_vec())),
1167            ("block_timestamp".to_string(), Bytes::from(1758201935_u64.to_be_bytes().to_vec())),
1168        ]
1169        .into_iter()
1170        .collect();
1171
1172        let delta = ProtocolStateDelta {
1173            component_id: "State1".to_owned(),
1174            updated_attributes: attributes,
1175            deleted_attributes: HashSet::new(),
1176        };
1177
1178        pool.delta_transition(delta, &HashMap::new(), &Balances::default())
1179            .unwrap();
1180
1181        assert_eq!(pool.liquidity, 2000);
1182        assert_eq!(pool.sqrt_price, U256::from(1001));
1183        assert_eq!(pool.tick, 120);
1184        assert_eq!(pool.fees.zero_for_one, 50);
1185        assert_eq!(pool.fees.one_for_zero, 75);
1186        assert_eq!(pool.fees.lp_fee, 100);
1187        assert_eq!(
1188            pool.ticks
1189                .get_tick(-120)
1190                .unwrap()
1191                .net_liquidity,
1192            10200
1193        );
1194        assert_eq!(
1195            pool.ticks
1196                .get_tick(120)
1197                .unwrap()
1198                .net_liquidity,
1199            9800
1200        );
1201    }
1202
1203    #[tokio::test]
1204    /// Compares a quote from the UniswapV4 Quoter contract on Sepolia with a simulation.
1205    async fn test_swap_sim() {
1206        use tycho_client::feed::dto;
1207        let project_root = env!("CARGO_MANIFEST_DIR");
1208        let asset_path = Path::new(project_root)
1209            .join("tests/assets/decoder/uniswap_v4_snapshot_sepolia_block_7239119.json");
1210        let json_data = fs::read_to_string(asset_path).expect("Failed to read test asset");
1211        let data: Value = serde_json::from_str(&json_data).expect("Failed to parse JSON");
1212        let state: ComponentWithState = serde_json::from_value::<dto::ComponentWithState>(data)
1213            .expect("Expected json to match ComponentWithState structure")
1214            .into();
1215
1216        let block = BlockHeader {
1217            number: 7239119,
1218            hash: Bytes::from_str(
1219                "0x28d41d40f2ac275a4f5f621a636b9016b527d11d37d610a45ac3a821346ebf8c",
1220            )
1221            .expect("Invalid block hash"),
1222            parent_hash: Bytes::from(vec![0; 32]),
1223            ..Default::default()
1224        };
1225
1226        let t0 = Token::new(
1227            &Bytes::from_str("0x647e32181a64f4ffd4f0b0b4b052ec05b277729c").unwrap(),
1228            "T0",
1229            18,
1230            0,
1231            &[Some(10_000)],
1232            Chain::Ethereum,
1233            100,
1234        );
1235        let t1 = Token::new(
1236            &Bytes::from_str("0xe390a1c311b26f14ed0d55d3b0261c2320d15ca5").unwrap(),
1237            "T1",
1238            18,
1239            0,
1240            &[Some(10_000)],
1241            Chain::Ethereum,
1242            100,
1243        );
1244
1245        let all_tokens = [t0.clone(), t1.clone()]
1246            .iter()
1247            .map(|t| (t.address.clone(), t.clone()))
1248            .collect();
1249
1250        let usv4_state = UniswapV4State::try_from_with_header(
1251            state,
1252            block,
1253            &Default::default(),
1254            &all_tokens,
1255            &DecoderContext::new(),
1256        )
1257        .await
1258        .unwrap();
1259
1260        // The fixture's `hooks` attribute is the zero address, so this is a plain V4 pool and
1261        // needs no chain to decode.
1262        assert!(usv4_state.hook.is_none());
1263
1264        let res = usv4_state
1265            .get_amount_out(BigUint::from_u64(1000000000000000000).unwrap(), &t0, &t1)
1266            .unwrap();
1267
1268        let expected_amount = BigUint::from(9999909699895_u64);
1269        assert_eq!(res.amount, expected_amount);
1270    }
1271
1272    #[tokio::test]
1273    async fn test_get_limits() {
1274        use tycho_client::feed::dto;
1275        let block = BlockHeader {
1276            number: 22689129,
1277            hash: Bytes::from_str(
1278                "0x7763ea30d11aef68da729b65250c09a88ad00458c041064aad8c9a9dbf17adde",
1279            )
1280            .expect("Invalid block hash"),
1281            parent_hash: Bytes::from(vec![0; 32]),
1282            ..Default::default()
1283        };
1284
1285        let project_root = env!("CARGO_MANIFEST_DIR");
1286        let asset_path =
1287            Path::new(project_root).join("tests/assets/decoder/uniswap_v4_snapshot.json");
1288        let json_data = fs::read_to_string(asset_path).expect("Failed to read test asset");
1289        let data: Value = serde_json::from_str(&json_data).expect("Failed to parse JSON");
1290        let state: ComponentWithState = serde_json::from_value::<dto::ComponentWithState>(data)
1291            .expect("Expected json to match ComponentWithState structure")
1292            .into();
1293
1294        let t0 = Token::new(
1295            &Bytes::from_str("0x2260fac5e5542a773aa44fbcfedf7c193bc2c599").unwrap(),
1296            "WBTC",
1297            8,
1298            0,
1299            &[Some(10_000)],
1300            Chain::Ethereum,
1301            100,
1302        );
1303        let t1 = Token::new(
1304            &Bytes::from_str("0xdac17f958d2ee523a2206206994597c13d831ec7").unwrap(),
1305            "USDT",
1306            6,
1307            0,
1308            &[Some(10_000)],
1309            Chain::Ethereum,
1310            100,
1311        );
1312
1313        let all_tokens = [t0.clone(), t1.clone()]
1314            .iter()
1315            .map(|t| (t.address.clone(), t.clone()))
1316            .collect();
1317
1318        let usv4_state = UniswapV4State::try_from_with_header(
1319            state,
1320            block,
1321            &Default::default(),
1322            &all_tokens,
1323            &DecoderContext::new(),
1324        )
1325        .await
1326        .unwrap();
1327
1328        // The fixture's `hooks` attribute is the zero address, so this is a plain V4 pool and
1329        // needs no chain to decode.
1330        assert!(usv4_state.hook.is_none());
1331
1332        let res = usv4_state
1333            .get_limits(t0.address.clone(), t1.address.clone())
1334            .unwrap();
1335
1336        assert_eq!(&res.0, &BigUint::from_u128(71698353688830259750744466706).unwrap());
1337
1338        let out = usv4_state
1339            .get_amount_out(res.0, &t0, &t1)
1340            .expect("swap for limit in didn't work");
1341
1342        assert_eq!(&res.1, &out.amount);
1343    }
1344    #[test]
1345    fn test_get_amount_out_no_hook() {
1346        // Test using transaction 0x78ea4bbb7d4405000f33fdf6f3fa08b5e557d50e5e7f826a79766d50bd643b6f
1347
1348        // Pool ID: 0x00b9edc1583bf6ef09ff3a09f6c23ecb57fd7d0bb75625717ec81eed181e22d7
1349        // Information taken from Tenderly simulation / event emitted on Etherscan
1350        let usv4_state = UniswapV4State::new(
1351            541501951282951892,
1352            U256::from_str("5362798333066270795901222").unwrap(), // Sqrt price
1353            UniswapV4Fees { zero_for_one: 0, one_for_zero: 0, lp_fee: 100 },
1354            -192022,
1355            1,
1356            // Ticks taken from indexer logs
1357            vec![
1358                TickInfo {
1359                    index: -887272,
1360                    net_liquidity: 460382969070005,
1361                    sqrt_price: U256::from(4295128739_u64),
1362                },
1363                TickInfo {
1364                    index: -207244,
1365                    net_liquidity: 561268407024557,
1366                    sqrt_price: U256::from_str("2505291706254206075074035").unwrap(),
1367                },
1368                TickInfo {
1369                    index: -196411,
1370                    net_liquidity: 825711941800452,
1371                    sqrt_price: U256::from_str("4306080513146952705853399").unwrap(),
1372                },
1373                TickInfo {
1374                    index: -196257,
1375                    net_liquidity: 64844666874010,
1376                    sqrt_price: U256::from_str("4339363644587371378270009").unwrap(),
1377                },
1378                TickInfo {
1379                    index: -195611,
1380                    net_liquidity: 2344045150766798,
1381                    sqrt_price: U256::from_str("4481806029599743916020126").unwrap(),
1382                },
1383                TickInfo {
1384                    index: -194715,
1385                    net_liquidity: 391037380558274654,
1386                    sqrt_price: U256::from_str("4687145946111116896040494").unwrap(),
1387                },
1388                TickInfo {
1389                    index: -194599,
1390                    net_liquidity: 89032603464508,
1391                    sqrt_price: U256::from_str("4714409015946702405379370").unwrap(),
1392                },
1393                TickInfo {
1394                    index: -194389,
1395                    net_liquidity: 66635600426483168,
1396                    sqrt_price: U256::from_str("4764168603367683402636621").unwrap(),
1397                },
1398                TickInfo {
1399                    index: -194160,
1400                    net_liquidity: 6123093436523361,
1401                    sqrt_price: U256::from_str("4819029067726467394386780").unwrap(),
1402                },
1403                TickInfo {
1404                    index: -194025,
1405                    net_liquidity: 79940813798964,
1406                    sqrt_price: U256::from_str("4851665907541490407930032").unwrap(),
1407                },
1408                TickInfo {
1409                    index: -193922,
1410                    net_liquidity: 415630967437234,
1411                    sqrt_price: U256::from_str("4876715181040466809166531").unwrap(),
1412                },
1413                TickInfo {
1414                    index: -193876,
1415                    net_liquidity: 9664144015186047,
1416                    sqrt_price: U256::from_str("4887943972687250473582419").unwrap(),
1417                },
1418                TickInfo {
1419                    index: -193818,
1420                    net_liquidity: 435344726052344,
1421                    sqrt_price: U256::from_str("4902138873132735049121973").unwrap(),
1422                },
1423                TickInfo {
1424                    index: -193804,
1425                    net_liquidity: 221726179374067,
1426                    sqrt_price: U256::from_str("4905571399964683340605904").unwrap(),
1427                },
1428                TickInfo {
1429                    index: -193719,
1430                    net_liquidity: 101340835774487,
1431                    sqrt_price: U256::from_str("4926463397882393957462188").unwrap(),
1432                },
1433                TickInfo {
1434                    index: -193690,
1435                    net_liquidity: 193367475630077,
1436                    sqrt_price: U256::from_str("4933611593595025190448924").unwrap(),
1437                },
1438                TickInfo {
1439                    index: -193643,
1440                    net_liquidity: 357016631583746,
1441                    sqrt_price: U256::from_str("4945218633428068823432932").unwrap(),
1442                },
1443                TickInfo {
1444                    index: -193520,
1445                    net_liquidity: 917243184365178,
1446                    sqrt_price: U256::from_str("4975723910367862081017120").unwrap(),
1447                },
1448                TickInfo {
1449                    index: -193440,
1450                    net_liquidity: 114125890211958292,
1451                    sqrt_price: U256::from_str("4995665665861492533686137").unwrap(),
1452                },
1453                TickInfo {
1454                    index: -193380,
1455                    net_liquidity: -65980729148766579,
1456                    sqrt_price: U256::from_str("5010674414300823856025303").unwrap(),
1457                },
1458                TickInfo {
1459                    index: -192891,
1460                    net_liquidity: 1687883551433195,
1461                    sqrt_price: U256::from_str("5134689105039642314202223").unwrap(),
1462                },
1463                TickInfo {
1464                    index: -192573,
1465                    net_liquidity: 11108903221360975,
1466                    sqrt_price: U256::from_str("5216979018647067786855495").unwrap(),
1467                },
1468                TickInfo {
1469                    index: -192448,
1470                    net_liquidity: 32888457482352,
1471                    sqrt_price: U256::from_str("5249685603828944002327927").unwrap(),
1472                },
1473                TickInfo {
1474                    index: -191525,
1475                    net_liquidity: -221726179374067,
1476                    sqrt_price: U256::from_str("5497623359964843320146512").unwrap(),
1477                },
1478                TickInfo {
1479                    index: -191447,
1480                    net_liquidity: -32888457482352,
1481                    sqrt_price: U256::from_str("5519104878745833608097296").unwrap(),
1482                },
1483                TickInfo {
1484                    index: -191444,
1485                    net_liquidity: -114125890211958292,
1486                    sqrt_price: U256::from_str("5519932765173943847315221").unwrap(),
1487                },
1488                TickInfo {
1489                    index: -191417,
1490                    net_liquidity: -101340835774487,
1491                    sqrt_price: U256::from_str("5527389333636021285046380").unwrap(),
1492                },
1493                TickInfo {
1494                    index: -191384,
1495                    net_liquidity: -9664144015186047,
1496                    sqrt_price: U256::from_str("5536516597603056457376182").unwrap(),
1497                },
1498                TickInfo {
1499                    index: -191148,
1500                    net_liquidity: -561268407024557,
1501                    sqrt_price: U256::from_str("5602231161238705865493165").unwrap(),
1502                },
1503                TickInfo {
1504                    index: -191147,
1505                    net_liquidity: -1687883551433195,
1506                    sqrt_price: U256::from_str("5602511265794328966803451").unwrap(),
1507                },
1508                TickInfo {
1509                    index: -191091,
1510                    net_liquidity: -89032603464508,
1511                    sqrt_price: U256::from_str("5618219493196441347292357").unwrap(),
1512                },
1513                TickInfo {
1514                    index: -190950,
1515                    net_liquidity: -189177935487638,
1516                    sqrt_price: U256::from_str("5657965894785859782969011").unwrap(),
1517                },
1518                TickInfo {
1519                    index: -190756,
1520                    net_liquidity: -6123093436523361,
1521                    sqrt_price: U256::from_str("5713112435031881967192022").unwrap(),
1522                },
1523                TickInfo {
1524                    index: -190548,
1525                    net_liquidity: -193367475630077,
1526                    sqrt_price: U256::from_str("5772835841671084402427710").unwrap(),
1527                },
1528                TickInfo {
1529                    index: -190430,
1530                    net_liquidity: -11108903221360975,
1531                    sqrt_price: U256::from_str("5806994534290341208820930").unwrap(),
1532                },
1533                TickInfo {
1534                    index: -190195,
1535                    net_liquidity: -391583014714302569,
1536                    sqrt_price: U256::from_str("5875625707132601785181387").unwrap(),
1537                },
1538                TickInfo {
1539                    index: -190043,
1540                    net_liquidity: -357016631583746,
1541                    sqrt_price: U256::from_str("5920448331650864936739481").unwrap(),
1542                },
1543                TickInfo {
1544                    index: -189779,
1545                    net_liquidity: -917243184365178,
1546                    sqrt_price: U256::from_str("5999112356918485175181346").unwrap(),
1547                },
1548                TickInfo {
1549                    index: -189663,
1550                    net_liquidity: -2344045150766798,
1551                    sqrt_price: U256::from_str("6034006559279282606084981").unwrap(),
1552                },
1553                TickInfo {
1554                    index: -189620,
1555                    net_liquidity: -435344726052344,
1556                    sqrt_price: U256::from_str("6046992979471024289177519").unwrap(),
1557                },
1558                TickInfo {
1559                    index: -189409,
1560                    net_liquidity: -825711941800452,
1561                    sqrt_price: U256::from_str("6111123241285165242130911").unwrap(),
1562                },
1563                TickInfo {
1564                    index: -189325,
1565                    net_liquidity: -3947182209207,
1566                    sqrt_price: U256::from_str("6136842645893819031257990").unwrap(),
1567                },
1568                TickInfo {
1569                    index: -189324,
1570                    net_liquidity: -415630967437234,
1571                    sqrt_price: U256::from_str("6137149480355443943537284").unwrap(),
1572                },
1573                TickInfo {
1574                    index: -115136,
1575                    net_liquidity: 462452451821,
1576                    sqrt_price: U256::from_str("250529060232794967902094762").unwrap(),
1577                },
1578                TickInfo {
1579                    index: -92109,
1580                    net_liquidity: -462452451821,
1581                    sqrt_price: U256::from_str("792242363124136400178523925").unwrap(),
1582                },
1583                TickInfo {
1584                    index: 887272,
1585                    net_liquidity: -521280453734808,
1586                    sqrt_price: U256::from_str("1461446703485210103287273052203988822378723970342")
1587                        .unwrap(),
1588                },
1589            ],
1590        )
1591        .unwrap();
1592
1593        let t0 = usdc();
1594        let t1 = eth();
1595
1596        let out = usv4_state
1597            .get_amount_out(BigUint::from_u64(2000000).unwrap(), &t0, &t1)
1598            .unwrap();
1599
1600        assert_eq!(out.amount, BigUint::from_str("436478419853848").unwrap())
1601    }
1602
1603    #[test]
1604    fn test_get_amount_out_euler_hook() {
1605        // Test using transaction 0xb372306a81c6e840f4ec55f006da6b0b097f435802a2e6fd216998dd12fb4aca
1606        //
1607        // Output of beforeSwap:
1608        // "output":{
1609        //      "amountToSwap":"0"
1610        //      "hookReturn":"2520471492123673565794154180707800634502860978735"
1611        //      "lpFeeOverride":"0"
1612        // }
1613        //
1614        // Output of entire swap, including hooks:
1615        // "swapDelta":"-2520471491783391198873215717244426027071092767279"
1616        //
1617        // Get amount out:
1618        // "amountOut":"2681115183499232721"
1619
1620        let block = BlockHeader {
1621            number: 22689128,
1622            hash: Bytes::from_str(
1623                "0xfbfa716523d25d6d5248c18d001ca02b1caf10cabd1ab7321465e2262c41157b",
1624            )
1625            .expect("Invalid block hash"),
1626            timestamp: 1749739055,
1627            ..Default::default()
1628        };
1629
1630        // Pool ID: 0xdd8dd509e58ec98631b800dd6ba86ee569c517ffbd615853ed5ab815bbc48ccb
1631        // Information taken from Tenderly simulation
1632        let mut usv4_state = UniswapV4State::new(
1633            0,
1634            U256::from_str("4295128740").unwrap(),
1635            UniswapV4Fees { zero_for_one: 100, one_for_zero: 90, lp_fee: 500 },
1636            0,
1637            1,
1638            vec![],
1639        )
1640        .unwrap();
1641
1642        let hook_address: Address = Address::from_str("0x69058613588536167ba0aa94f0cc1fe420ef28a8")
1643            .expect("Invalid hook address");
1644
1645        let db = SimulationDB::new(
1646            get_client(None).expect("Failed to create client"),
1647            get_runtime().expect("Failed to get runtime"),
1648            Some(block.clone()),
1649        );
1650        let engine = create_engine(db, true).expect("Failed to create simulation engine");
1651        let pool_manager = Address::from_str("0x000000000004444c5dc75cb358380d2e3de08a90")
1652            .expect("Invalid pool manager address");
1653
1654        let hook_handler = GenericVMHookHandler::new(
1655            hook_address,
1656            engine,
1657            pool_manager,
1658            HashMap::new(),
1659            HashMap::new(),
1660            None,
1661            true, // Euler hook
1662        )
1663        .unwrap();
1664
1665        let t0 = usdc();
1666        let t1 = weth();
1667
1668        usv4_state.set_hook_handler(Box::new(hook_handler));
1669        let out = usv4_state
1670            .get_amount_out(BigUint::from_u64(7407000000).unwrap(), &t0, &t1)
1671            .unwrap();
1672
1673        assert_eq!(out.amount, BigUint::from_str("2681115183499232721").unwrap())
1674    }
1675
1676    #[test]
1677    fn test_get_amount_out_angstrom_hook() {
1678        // Test using transaction 0x671b8e1d0966cee520dc2bb9628de8e22a17b036e70077504796d0a476932d21
1679        let mut usv4_state = UniswapV4State::new(
1680            // Liquidity and tick taken from tycho indexer for same block as transaction
1681            66319800403673162,
1682            U256::from_str("1314588940601923011323000261788004").unwrap(),
1683            // 8388608 (i.e. 0x800000) signifies a dynamic fee.
1684            UniswapV4Fees { zero_for_one: 0, one_for_zero: 0, lp_fee: 8388608 },
1685            194343,
1686            10,
1687            vec![
1688                TickInfo::new(-887270, 198117767801).unwrap(),
1689                TickInfo::new(191990, 24561988698695).unwrap(),
1690                TickInfo::new(192280, 2839631428751224).unwrap(),
1691                TickInfo::new(193130, 318786492813931).unwrap(),
1692                TickInfo::new(194010, 26209207141081).unwrap(),
1693                TickInfo::new(194210, -26209207141081).unwrap(),
1694                TickInfo::new(194220, 63136622375641511).unwrap(),
1695                TickInfo::new(194420, -63136622375641511).unwrap(),
1696                TickInfo::new(195130, -318786492813931).unwrap(),
1697                TickInfo::new(196330, -2839631428751224).unwrap(),
1698                TickInfo::new(197100, -24561988698695).unwrap(),
1699                TickInfo::new(887270, -198117767801).unwrap(),
1700            ],
1701        )
1702        .unwrap();
1703
1704        let fees = AngstromFees {
1705            // To get these values, enable storage access logs on tenderly,
1706            // and look at the hex value retrieved right after calling afterSwap
1707            //
1708            // The value (hex: 0x70000152) contains two packed uint24 values:
1709            // Lower 24 bits (unlockedFee):         0x152   = 338
1710            // Upper 24 bits (protocolUnlockedFee): 0x70    = 112
1711            unlock: U24::from(338),
1712            protocol_unlock: U24::from(112),
1713        };
1714        let hook_handler = AngstromHookHandler::new(
1715            Address::from_str("0x0000000aa232009084bd71a5797d089aa4edfad4").unwrap(),
1716            Address::from_str("0x000000000004444c5dc75cb358380d2e3de08a90").unwrap(),
1717            fees,
1718            false,
1719        );
1720
1721        let t0 = usdc();
1722        let t1 = weth();
1723
1724        usv4_state.set_hook_handler(Box::new(hook_handler));
1725        let out = usv4_state
1726            .get_amount_out(
1727                BigUint::from_u64(
1728                    6645198144, // usdc
1729                )
1730                .unwrap(),
1731                &t0, // usdc IN
1732                &t1, // weth OUT
1733            )
1734            .unwrap();
1735
1736        assert_eq!(out.amount, BigUint::from_str("1825627051870330472").unwrap())
1737    }
1738
1739    /// Hook that only answers `after_swap`, with a configurable address and returned delta.
1740    #[derive(Debug, Clone, PartialEq, Eq)]
1741    struct AfterSwapTestHook {
1742        address: Address,
1743        delta: I128,
1744        /// Share of the output the hook reports analytically, in basis points. `None` models a
1745        /// hook that cannot price its fee without a simulation.
1746        analytic_fee_bps: Option<u32>,
1747        /// Price the hook answers `spot_price` with. `None` models a hook that leaves the price
1748        /// to the pool by failing recoverably.
1749        spot_price_override: Option<u64>,
1750    }
1751
1752    impl HookHandler for AfterSwapTestHook {
1753        fn address(&self) -> Address {
1754            self.address
1755        }
1756
1757        fn before_swap(
1758            &self,
1759            _: BeforeSwapParameters,
1760            _: Option<HashMap<Address, HashMap<U256, U256>>>,
1761            _: Option<HashMap<Address, HashMap<U256, U256>>>,
1762        ) -> Result<WithGasEstimate<BeforeSwapOutput>, SimulationError> {
1763            Err(SimulationError::RecoverableError("not implemented".into()))
1764        }
1765
1766        fn after_swap(
1767            &self,
1768            _: AfterSwapParameters,
1769            _: Option<HashMap<Address, HashMap<U256, U256>>>,
1770            _: Option<HashMap<Address, HashMap<U256, U256>>>,
1771        ) -> Result<WithGasEstimate<AfterSwapDelta>, SimulationError> {
1772            Ok(WithGasEstimate { gas_estimate: AFTER_SWAP_TEST_HOOK_GAS, result: self.delta })
1773        }
1774
1775        fn fee(&self, _: &UniswapV4State, _: SwapParams) -> Result<f64, SimulationError> {
1776            Err(SimulationError::RecoverableError("not implemented".into()))
1777        }
1778
1779        fn spot_price(&self, _: &Token, _: &Token) -> Result<f64, SimulationError> {
1780            match self.spot_price_override {
1781                Some(price) => Ok(price as f64),
1782                None => Err(SimulationError::RecoverableError("not implemented".into())),
1783            }
1784        }
1785
1786        fn unspecified_fee_amount(
1787            &self,
1788            unspecified: U256,
1789            _: bool,
1790        ) -> Result<Option<U256>, SimulationError> {
1791            let Some(bps) = self.analytic_fee_bps else { return Ok(None) };
1792            Ok(Some(unspecified * U256::from(bps) / U256::from(10_000u64)))
1793        }
1794
1795        fn get_amount_ranges(&self, _: Bytes, _: Bytes) -> Result<AmountRanges, SimulationError> {
1796            Err(SimulationError::RecoverableError("not implemented".into()))
1797        }
1798
1799        fn delta_transition(
1800            &mut self,
1801            _: ProtocolStateDelta,
1802            _: &HashMap<Bytes, Token>,
1803            _: &Balances,
1804        ) -> Result<(), TransitionError> {
1805            Ok(())
1806        }
1807
1808        fn clone_box(&self) -> Box<dyn HookHandler> {
1809            Box::new(self.clone())
1810        }
1811
1812        fn as_any(&self) -> &dyn Any {
1813            self
1814        }
1815
1816        fn is_equal(&self, other: &dyn HookHandler) -> bool {
1817            other.as_any().downcast_ref::<Self>() == Some(self)
1818        }
1819    }
1820
1821    // Deliberately different values: the gate tests below assert on a gas delta and on an amount
1822    // delta, and equal constants would let a mix-up between the two pass.
1823    const AFTER_SWAP_TEST_HOOK_GAS: u64 = 1_000;
1824    const AFTER_SWAP_TEST_HOOK_DELTA: u64 = 777;
1825
1826    fn construct_hook_address(hook_options: &[HookOptions]) -> Address {
1827        let mut hook_flags = U160::ZERO;
1828        let one = U160::from_limbs([1, 0, 0]);
1829        for hook_option in hook_options {
1830            hook_flags |= one << (*hook_option as u8);
1831        }
1832        Address::from(hook_flags)
1833    }
1834
1835    fn after_swap_test_hook(hook_options: &[HookOptions]) -> Box<dyn HookHandler> {
1836        Box::new(AfterSwapTestHook {
1837            address: construct_hook_address(hook_options),
1838            delta: I128::unchecked_from(AFTER_SWAP_TEST_HOOK_DELTA),
1839            analytic_fee_bps: None,
1840            spot_price_override: None,
1841        })
1842    }
1843
1844    /// A hook that prices its fee analytically as `analytic_fee_bps` of the output.
1845    fn analytic_fee_test_hook(analytic_fee_bps: u32) -> Box<dyn HookHandler> {
1846        Box::new(AfterSwapTestHook {
1847            address: construct_hook_address(&[
1848                HookOptions::AfterSwap,
1849                HookOptions::AfterSwapReturnsDelta,
1850            ]),
1851            delta: I128::ZERO,
1852            analytic_fee_bps: Some(analytic_fee_bps),
1853            spot_price_override: None,
1854        })
1855    }
1856
1857    /// The price a test hook answers `spot_price` with when it is asked for one. Far from any
1858    /// price the test pool could quote, so a test cannot confuse the two.
1859    const TEST_HOOK_SPOT_PRICE: u64 = 1_000_000;
1860
1861    /// A hook that answers `spot_price` with [`TEST_HOOK_SPOT_PRICE`], and prices its fee
1862    /// analytically when `analytic_fee_bps` is `Some`.
1863    fn pricing_test_hook(analytic_fee_bps: Option<u32>) -> Box<dyn HookHandler> {
1864        Box::new(AfterSwapTestHook {
1865            address: construct_hook_address(&[
1866                HookOptions::AfterSwap,
1867                HookOptions::AfterSwapReturnsDelta,
1868            ]),
1869            delta: I128::ZERO,
1870            analytic_fee_bps,
1871            spot_price_override: Some(TEST_HOOK_SPOT_PRICE),
1872        })
1873    }
1874
1875    fn basic_v4_test_pool_tokens(zero_for_one: bool) -> (Token, Token) {
1876        if zero_for_one {
1877            (token_x(), token_y())
1878        } else {
1879            (token_y(), token_x())
1880        }
1881    }
1882
1883    /// An `afterSwap` delta must be discarded unless the hook also carries
1884    /// `AfterSwapReturnsDelta`, mirroring `Hooks.afterSwap` in v4-core.
1885    #[rstest]
1886    #[case::zero_for_one(true)]
1887    #[case::one_for_zero(false)]
1888    fn test_after_swap_delta_ignored_without_returns_delta_permission(#[case] zero_for_one: bool) {
1889        let (token_in, token_out) = basic_v4_test_pool_tokens(zero_for_one);
1890        let amount_in = BigUint::from(1_000_000_000_000_000u64);
1891
1892        let hookless = create_basic_v4_test_pool()
1893            .get_amount_out(amount_in.clone(), &token_in, &token_out)
1894            .expect("hookless swap should succeed");
1895
1896        let mut pool = create_basic_v4_test_pool();
1897        pool.set_hook_handler(after_swap_test_hook(&[HookOptions::AfterSwap]));
1898        let gated = pool
1899            .get_amount_out(amount_in, &token_in, &token_out)
1900            .expect("gated swap should succeed");
1901
1902        assert_eq!(gated.amount, hookless.amount);
1903        assert_eq!(
1904            gated.gas,
1905            hookless.gas + BigUint::from(AFTER_SWAP_TEST_HOOK_GAS + PM_PER_HOOK_CALL_OVERHEAD)
1906        );
1907    }
1908
1909    /// With `AfterSwapReturnsDelta` set, the returned delta is taken out of the unspecified
1910    /// (output) currency.
1911    #[rstest]
1912    #[case::zero_for_one(true)]
1913    #[case::one_for_zero(false)]
1914    fn test_after_swap_delta_applied_with_returns_delta_permission(#[case] zero_for_one: bool) {
1915        let (token_in, token_out) = basic_v4_test_pool_tokens(zero_for_one);
1916        let amount_in = BigUint::from(1_000_000_000_000_000u64);
1917
1918        let hookless = create_basic_v4_test_pool()
1919            .get_amount_out(amount_in.clone(), &token_in, &token_out)
1920            .expect("hookless swap should succeed");
1921
1922        let mut pool = create_basic_v4_test_pool();
1923        pool.set_hook_handler(after_swap_test_hook(&[
1924            HookOptions::AfterSwap,
1925            HookOptions::AfterSwapReturnsDelta,
1926        ]));
1927        let with_delta = pool
1928            .get_amount_out(amount_in, &token_in, &token_out)
1929            .expect("swap with hook delta should succeed");
1930
1931        assert_eq!(with_delta.amount, &hookless.amount - BigUint::from(AFTER_SWAP_TEST_HOOK_DELTA));
1932        assert_eq!(
1933            with_delta.gas,
1934            hookless.gas + BigUint::from(AFTER_SWAP_TEST_HOOK_GAS + PM_PER_HOOK_CALL_OVERHEAD)
1935        );
1936    }
1937
1938    const FEELESS_POOL_LIQUIDITY: u128 = 100_000_000_000_000_000_000; // 100e18
1939
1940    /// A pool with no LP fee and no protocol fee, holding a single position that spans the whole
1941    /// walkable tick range, so every difference between a hooked and a hookless answer is the
1942    /// hook's doing alone.
1943    fn create_feeless_v4_test_pool(liquidity: u128) -> UniswapV4State {
1944        let sqrt_price = get_sqrt_price_q96(U256::from(20_000_000u64), U256::from(10_000_000u64))
1945            .expect("a price of two has a square root");
1946        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("the sqrt price maps to a tick");
1947        let position = FEELESS_POOL_LIQUIDITY as i128;
1948
1949        UniswapV4State::new(
1950            liquidity,
1951            sqrt_price,
1952            UniswapV4Fees { zero_for_one: 0, one_for_zero: 0, lp_fee: 0 },
1953            tick,
1954            60,
1955            vec![
1956                TickInfo::new(-46_080, position).unwrap(),
1957                TickInfo::new(46_080, -position).unwrap(),
1958            ],
1959        )
1960        .expect("the pool builds")
1961    }
1962
1963    fn pons_test_handler() -> PonsV2HookHandler {
1964        PonsV2HookHandler::new(PONS_V2_HOOK_ROBINHOOD, 100, 100)
1965    }
1966
1967    /// A pool whose `hooks` attribute is the zero address carries no handler, so it prices
1968    /// through `core_spot_price`: the documented `ProtocolSim::spot_price` contract, which is the
1969    /// buy price `P / (1 − f)` rather than the sell-side slope `P · (1 − f)` a finite-difference
1970    /// fallback would return. Pinned to a concrete number in both token orderings.
1971    #[test]
1972    fn hookless_spot_price_is_the_documented_buy_price_with_lp_fee_markup() {
1973        const LP_FEE_PIPS: u32 = 3_000;
1974        const LP_FEE: f64 = LP_FEE_PIPS as f64 / 1_000_000.0;
1975        const LIQUIDITY: u128 = 100_000_000_000_000_000_000; // 100e18
1976        const TOLERANCE: f64 = 1e-12;
1977
1978        let sqrt_price = get_sqrt_price_q96(U256::from(20_000_000u64), U256::from(10_000_000u64))
1979            .expect("a price of two has a square root");
1980        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("the sqrt price maps to a tick");
1981        let pool = UniswapV4State::new(
1982            LIQUIDITY,
1983            sqrt_price,
1984            UniswapV4Fees { zero_for_one: 0, one_for_zero: 0, lp_fee: LP_FEE_PIPS },
1985            tick,
1986            60,
1987            vec![
1988                TickInfo::new(-46_080, LIQUIDITY as i128).unwrap(),
1989                TickInfo::new(46_080, -(LIQUIDITY as i128)).unwrap(),
1990            ],
1991        )
1992        .expect("the pool builds");
1993
1994        assert!(pool.hook.is_none(), "the pinned values only hold with no hook handler");
1995
1996        let (t0, t1) = (token_x(), token_y());
1997        let pre_fee = sqrt_price_q96_to_f64(sqrt_price, t0.decimals, t1.decimals)
1998            .expect("the sqrt price converts to a price");
1999
2000        let expected_buy_t0 = pre_fee / (1.0 - LP_FEE);
2001        let expected_buy_t1 = (1.0 / pre_fee) / (1.0 - LP_FEE);
2002
2003        let buy_t0 = pool
2004            .spot_price(&t0, &t1)
2005            .expect("a hookless pool always prices");
2006        let buy_t1 = pool
2007            .spot_price(&t1, &t0)
2008            .expect("a hookless pool always prices");
2009
2010        assert!(
2011            (buy_t0 / expected_buy_t0 - 1.0).abs() < TOLERANCE,
2012            "buying t0 quoted {buy_t0}, expected {expected_buy_t0}"
2013        );
2014        assert!(
2015            (buy_t1 / expected_buy_t1 - 1.0).abs() < TOLERANCE,
2016            "buying t1 quoted {buy_t1}, expected {expected_buy_t1}"
2017        );
2018    }
2019
2020    /// Pons takes its cut out of the swap's output, so buying one `base` through it costs
2021    /// `core / (1 − rate)` of `quote`. At 100 + 100 bps that is `1 / 0.98` of the hookless
2022    /// price: above it, never below.
2023    #[rstest]
2024    #[case::base_is_currency0(true)]
2025    #[case::base_is_currency1(false)]
2026    fn test_spot_price_marks_up_an_analytic_hook_fee(#[case] base_is_currency0: bool) {
2027        let (base, quote) = basic_v4_test_pool_tokens(base_is_currency0);
2028        let mut hooked = create_feeless_v4_test_pool(FEELESS_POOL_LIQUIDITY);
2029        hooked.set_hook_handler(Box::new(pons_test_handler()));
2030
2031        let core = create_feeless_v4_test_pool(FEELESS_POOL_LIQUIDITY)
2032            .spot_price(&base, &quote)
2033            .expect("a hookless pool always prices");
2034        let price = hooked
2035            .spot_price(&base, &quote)
2036            .expect("the hook prices its own fee");
2037
2038        assert!(price > core, "hooked {price} is not above hookless {core}");
2039        let ratio = price / core;
2040        assert!((ratio * 0.98 - 1.0).abs() < 1e-9, "hooked/hookless is {ratio}, not 1/0.98");
2041    }
2042
2043    /// The marked-up spot price is the limit of the hooked quote as the trade shrinks: buying
2044    /// 1e-4 of a token out of 100e18 of liquidity executes within 1e-4 of it.
2045    #[rstest]
2046    #[case::base_is_currency0(true)]
2047    #[case::base_is_currency1(false)]
2048    fn test_hooked_spot_price_matches_a_small_hooked_buy(#[case] base_is_currency0: bool) {
2049        let (base, quote) = basic_v4_test_pool_tokens(base_is_currency0);
2050        let mut hooked = create_feeless_v4_test_pool(FEELESS_POOL_LIQUIDITY);
2051        hooked.set_hook_handler(Box::new(pons_test_handler()));
2052
2053        let quote_in = BigUint::from(100_000_000_000_000u64);
2054        let base_out = hooked
2055            .get_amount_out(quote_in.clone(), &quote, &base)
2056            .expect("a tiny buy always fits the pool")
2057            .amount;
2058
2059        let executed = quote_in.to_f64().unwrap() / base_out.to_f64().unwrap();
2060        let spot = hooked
2061            .spot_price(&base, &quote)
2062            .expect("the hook prices its own fee");
2063
2064        assert!((executed / spot - 1.0).abs() < 1e-4, "executed {executed}, quoted {spot}");
2065    }
2066
2067    /// A hook that prices its fee analytically is priced from the pool, not from whatever its
2068    /// own `spot_price` answers: the analytic rate is the whole of what it does to the price,
2069    /// and asking the handler would cost a simulation for no gain.
2070    #[rstest]
2071    #[case::base_is_currency0(true)]
2072    #[case::base_is_currency1(false)]
2073    fn test_spot_price_prefers_an_analytic_fee_over_the_hooks_own_price(
2074        #[case] base_is_currency0: bool,
2075    ) {
2076        let (base, quote) = basic_v4_test_pool_tokens(base_is_currency0);
2077        let mut hooked = create_feeless_v4_test_pool(FEELESS_POOL_LIQUIDITY);
2078        hooked.set_hook_handler(pricing_test_hook(Some(200)));
2079
2080        let core = create_feeless_v4_test_pool(FEELESS_POOL_LIQUIDITY)
2081            .spot_price(&base, &quote)
2082            .expect("a hookless pool always prices");
2083        let price = hooked
2084            .spot_price(&base, &quote)
2085            .expect("the hook prices its own fee");
2086
2087        assert_ne!(
2088            price, TEST_HOOK_SPOT_PRICE as f64,
2089            "the pool asked the hook for a price instead of marking up its own"
2090        );
2091        let ratio = price / core;
2092        assert!((ratio * 0.98 - 1.0).abs() < 1e-9, "hooked/hookless is {ratio}, not 1/0.98");
2093    }
2094
2095    /// The companion of the test above: the very same handler, with nothing but its analytic fee
2096    /// taken away, is asked for a price and its answer is passed through. That is what the pool
2097    /// would return for the hook above if it consulted the handler first.
2098    #[rstest]
2099    #[case::base_is_currency0(true)]
2100    #[case::base_is_currency1(false)]
2101    fn test_spot_price_uses_the_hooks_own_price_without_an_analytic_fee(
2102        #[case] base_is_currency0: bool,
2103    ) {
2104        let (base, quote) = basic_v4_test_pool_tokens(base_is_currency0);
2105        let mut hooked = create_feeless_v4_test_pool(FEELESS_POOL_LIQUIDITY);
2106        hooked.set_hook_handler(pricing_test_hook(None));
2107
2108        let price = hooked
2109            .spot_price(&base, &quote)
2110            .expect("the hook answers with a price");
2111
2112        assert_eq!(price, TEST_HOOK_SPOT_PRICE as f64);
2113    }
2114
2115    /// A handler that does not price its fee analytically keeps the finite-difference fallback,
2116    /// which reads the slope of two quotes and so cancels out a constant per-swap take.
2117    #[rstest]
2118    #[case::base_is_currency0(true)]
2119    #[case::base_is_currency1(false)]
2120    fn test_spot_price_falls_back_to_finite_difference_without_an_analytic_fee(
2121        #[case] base_is_currency0: bool,
2122    ) {
2123        let (base, quote) = basic_v4_test_pool_tokens(base_is_currency0);
2124        let mut hooked = create_feeless_v4_test_pool(FEELESS_POOL_LIQUIDITY);
2125        hooked.set_hook_handler(after_swap_test_hook(&[
2126            HookOptions::AfterSwap,
2127            HookOptions::AfterSwapReturnsDelta,
2128        ]));
2129
2130        let x1 = BigUint::from(10u64).pow(base.decimals) / BigUint::from(100u64);
2131        let x2 = &x1 + (&x1 / BigUint::from(100u64));
2132        let y1 = hooked
2133            .get_amount_out(x1.clone(), &base, &quote)
2134            .expect("the smaller probe swap fits")
2135            .amount;
2136        let y2 = hooked
2137            .get_amount_out(x2.clone(), &base, &quote)
2138            .expect("the larger probe swap fits")
2139            .amount;
2140        let slope = (&y2 - &y1).to_f64().unwrap() / (&x2 - &x1).to_f64().unwrap();
2141
2142        let price = hooked
2143            .spot_price(&base, &quote)
2144            .expect("the fallback always prices");
2145
2146        assert_eq!(price, slope);
2147        let core = create_feeless_v4_test_pool(FEELESS_POOL_LIQUIDITY)
2148            .spot_price(&base, &quote)
2149            .expect("a hookless pool always prices");
2150        assert!((price / core - 1.0).abs() < 1e-3, "fallback {price} strayed from core {core}");
2151    }
2152
2153    /// `get_limits` reports what a swapper actually receives, so the hook's cut comes off the
2154    /// output. The input the pool can absorb is unchanged: the hook charges the other leg.
2155    #[rstest]
2156    #[case::zero_for_one(true)]
2157    #[case::one_for_zero(false)]
2158    fn test_get_limits_reports_the_output_net_of_an_analytic_hook_fee(#[case] zero_for_one: bool) {
2159        let (token_in, token_out) = basic_v4_test_pool_tokens(zero_for_one);
2160        let mut hooked = create_feeless_v4_test_pool(FEELESS_POOL_LIQUIDITY);
2161        hooked.set_hook_handler(Box::new(pons_test_handler()));
2162
2163        let (hookless_in, hookless_out) = create_feeless_v4_test_pool(FEELESS_POOL_LIQUIDITY)
2164            .get_limits(token_in.address.clone(), token_out.address.clone())
2165            .expect("a pool with liquidity has limits");
2166        let (limit_in, limit_out) = hooked
2167            .get_limits(token_in.address.clone(), token_out.address.clone())
2168            .expect("a pool with liquidity has limits");
2169
2170        assert!(hookless_out > BigUint::zero(), "the reference pool must move some output");
2171        assert_eq!(limit_in, hookless_in);
2172        let taken = pons_test_handler()
2173            .fee_and_tax(biguint_to_u256(&hookless_out))
2174            .expect("a pool sized output never overflows");
2175        assert_eq!(limit_out, &hookless_out - u256_to_biguint(taken));
2176        assert!(limit_out < hookless_out);
2177    }
2178
2179    /// A handler with no analytic fee leaves the limits exactly where the pool's own liquidity
2180    /// puts them, so hooks that quote by simulation are unaffected.
2181    #[rstest]
2182    #[case::zero_for_one(true)]
2183    #[case::one_for_zero(false)]
2184    fn test_get_limits_unchanged_without_an_analytic_hook_fee(#[case] zero_for_one: bool) {
2185        let (token_in, token_out) = basic_v4_test_pool_tokens(zero_for_one);
2186        let mut hooked = create_feeless_v4_test_pool(FEELESS_POOL_LIQUIDITY);
2187        hooked.set_hook_handler(after_swap_test_hook(&[
2188            HookOptions::AfterSwap,
2189            HookOptions::AfterSwapReturnsDelta,
2190        ]));
2191
2192        let hookless = create_feeless_v4_test_pool(FEELESS_POOL_LIQUIDITY)
2193            .get_limits(token_in.address.clone(), token_out.address.clone())
2194            .expect("a pool with liquidity has limits");
2195        let limits = hooked
2196            .get_limits(token_in.address.clone(), token_out.address.clone())
2197            .expect("a pool with liquidity has limits");
2198
2199        assert_eq!(limits, hookless);
2200    }
2201
2202    /// A pool with no liquidity of its own still reports no limits: the hook does not manage the
2203    /// liquidity here, so there is no output for it to take a cut of.
2204    #[test]
2205    fn test_get_limits_on_a_drained_pool_is_zero_with_an_analytic_hook() {
2206        let (token_in, token_out) = basic_v4_test_pool_tokens(true);
2207        let mut drained = create_feeless_v4_test_pool(0);
2208        drained.set_hook_handler(Box::new(pons_test_handler()));
2209
2210        let limits = drained
2211            .get_limits(token_in.address, token_out.address)
2212            .expect("a drained pool reports zero rather than failing");
2213
2214        assert_eq!(limits, (BigUint::zero(), BigUint::zero()));
2215    }
2216
2217    /// A hook that keeps the whole output leaves no price to quote. The markup would divide by
2218    /// zero or go negative, so the pool has to report the error instead.
2219    #[rstest]
2220    #[case::the_whole_output(10_000)]
2221    #[case::more_than_the_whole_output(20_000)]
2222    fn test_spot_price_rejects_a_hook_that_takes_the_whole_output(#[case] analytic_fee_bps: u32) {
2223        let mut pool = create_feeless_v4_test_pool(FEELESS_POOL_LIQUIDITY);
2224        pool.set_hook_handler(analytic_fee_test_hook(analytic_fee_bps));
2225
2226        let error = pool
2227            .spot_price(&token_x(), &token_y())
2228            .expect_err("a rate of one or more is not a price");
2229
2230        assert!(matches!(error, SimulationError::FatalError(_)), "{error:?}");
2231    }
2232
2233    #[test]
2234    fn test_spot_price_with_recoverable_error() {
2235        // Test that spot_price correctly falls back to swap-based calculation
2236        // when a RecoverableError (other than "not implemented") is returned
2237
2238        let usv4_state = UniswapV4State::new(
2239            1000000000000000000u128,                                  // 1e18 liquidity
2240            U256::from_str("79228162514264337593543950336").unwrap(), // 1:1 price
2241            UniswapV4Fees { zero_for_one: 100, one_for_zero: 100, lp_fee: 100 },
2242            0,
2243            60,
2244            vec![
2245                TickInfo::new(-600, 500000000000000000i128).unwrap(),
2246                TickInfo::new(600, -500000000000000000i128).unwrap(),
2247            ],
2248        )
2249        .unwrap();
2250
2251        // Test spot price calculation without a hook (should use default implementation)
2252        let spot_price_result = usv4_state.spot_price(&usdc(), &weth());
2253        assert!(spot_price_result.is_ok());
2254
2255        // The price should be approximately 1.0 (since we set sqrt_price for 1:1)
2256        // Adjusting for decimals difference (USDC has 6, WETH has 18)
2257        let price = spot_price_result.unwrap();
2258        assert!(price > 0.0);
2259    }
2260
2261    #[test]
2262    fn test_get_limits_with_hook_managed_liquidity_no_ranges_entrypoint() {
2263        // This test demonstrates the experimental limit finding logic for hooks that:
2264        // 1. Manage liquidity (pool has no liquidity & no ticks)
2265        // 2. Don't have get_amount_ranges entrypoint
2266
2267        let block = BlockHeader {
2268            number: 22689128,
2269            hash: Bytes::from_str(
2270                "0xfbfa716523d25d6d5248c18d001ca02b1caf10cabd1ab7321465e2262c41157b",
2271            )
2272            .expect("Invalid block hash"),
2273            timestamp: 1749739055,
2274            ..Default::default()
2275        };
2276
2277        let hook_address: Address = Address::from_str("0x69058613588536167ba0aa94f0cc1fe420ef28a8")
2278            .expect("Invalid hook address");
2279
2280        let db = SimulationDB::new(
2281            get_client(None).expect("Failed to create client"),
2282            get_runtime().expect("Failed to get runtime"),
2283            Some(block.clone()),
2284        );
2285        let engine = create_engine(db, true).expect("Failed to create simulation engine");
2286        let pool_manager = Address::from_str("0x000000000004444c5dc75cb358380d2e3de08a90")
2287            .expect("Invalid pool manager address");
2288
2289        // Create a GenericVMHookHandler without limits_entrypoint
2290        // This will trigger the "not set" error path and use experimental limit finding
2291        let hook_handler = GenericVMHookHandler::new(
2292            hook_address,
2293            engine,
2294            pool_manager,
2295            HashMap::new(),
2296            HashMap::new(),
2297            None,
2298            true, // Euler hook
2299        )
2300        .unwrap();
2301
2302        // Create a UniswapV4State with NO liquidity and NO ticks (hook manages all liquidity)
2303        let mut usv4_state = UniswapV4State::new(
2304            0, // no liquidity - hook provides it
2305            U256::from_str("4295128740").unwrap(),
2306            UniswapV4Fees { zero_for_one: 100, one_for_zero: 90, lp_fee: 500 },
2307            0,      // current tick
2308            1,      // tick spacing
2309            vec![], // no ticks - hook manages liquidity
2310        )
2311        .unwrap();
2312
2313        usv4_state.set_hook_handler(Box::new(hook_handler));
2314
2315        let token_in = usdc().address;
2316        let token_out = weth().address;
2317
2318        let (amount_in_limit, amount_out_limit) = usv4_state
2319            .get_limits(token_in, token_out)
2320            .expect("Should find limits through experimental swapping");
2321
2322        // Assuming pool supply doesn't change drastically at time of this test
2323        // At least 1 million USDC, not more than 100 million USDC
2324        assert!(amount_in_limit > BigUint::from(10u64).pow(12));
2325        assert!(amount_in_limit < BigUint::from(10u64).pow(14));
2326
2327        // At least 100 ETH, not more than 10 000 ETH
2328        assert!(amount_out_limit > BigUint::from(10u64).pow(20));
2329        assert!(amount_out_limit < BigUint::from(10u64).pow(22));
2330    }
2331
2332    #[rstest]
2333    #[case::high_liquidity(u128::MAX / 2)] // Very large liquidity
2334    #[case::medium_liquidity(10000000000000000000u128)] // Moderate liquidity: 10e18
2335    #[case::minimal_liquidity(1000u128)] // Very small liquidity
2336    fn test_find_max_amount(#[case] liquidity: u128) {
2337        // Use fixed configuration for all test cases
2338        let fees = UniswapV4Fees { zero_for_one: 100, one_for_zero: 100, lp_fee: 100 };
2339        let tick_spacing = 60;
2340        let ticks = vec![
2341            TickInfo::new(-600, (liquidity / 4) as i128).unwrap(),
2342            TickInfo::new(600, -((liquidity / 4) as i128)).unwrap(),
2343        ];
2344
2345        let usv4_state = UniswapV4State::new(
2346            liquidity,
2347            U256::from_str("79228162514264337593543950336").unwrap(),
2348            fees,
2349            0,
2350            tick_spacing,
2351            ticks,
2352        )
2353        .unwrap();
2354
2355        let token_in = usdc();
2356        let token_out = weth();
2357
2358        let (max_amount_in, _max_amount_out) = usv4_state
2359            .find_max_amount(&token_in, &token_out)
2360            .unwrap();
2361
2362        let success = usv4_state
2363            .get_amount_out(max_amount_in.clone(), &token_in, &token_out)
2364            .is_ok();
2365        assert!(success, "Should be able to swap the exact max amount.");
2366
2367        let one_more = &max_amount_in + BigUint::from(1u64);
2368        let should_fail = usv4_state
2369            .get_amount_out(one_more, &token_in, &token_out)
2370            .is_err();
2371        assert!(should_fail, "Swapping max_amount + 1 should fail.");
2372    }
2373
2374    #[test]
2375    fn test_calculate_swap_fees_with_override() {
2376        // Test that calculate_swap_fees_pips works correctly with overridden fees
2377        let fees = UniswapV4Fees::new(100, 90, 500);
2378
2379        // Without override, should use UniswapV4 formula: protocol + lp - (protocol * lp /
2380        // 1_000_000)
2381        let total_zero_for_one = fees.calculate_swap_fees_pips(true, None);
2382        // 100 + 500 - (100 * 500 / 1_000_000) = 600 - 0 = 600 (rounded down)
2383        assert_eq!(total_zero_for_one, 600);
2384
2385        // With override, should use override fee + protocol fee with same formula
2386        let total_with_override = fees.calculate_swap_fees_pips(true, Some(1000));
2387        // 100 + 1000 - (100 * 1000 / 1_000_000) = 1100 - 0 = 1100 (rounded down)
2388        assert_eq!(total_with_override, 1100);
2389    }
2390
2391    #[test]
2392    fn test_max_combined_fees_stays_valid() {
2393        // Test that even with max protocol + max LP fees, we stay under compute_swap_step limit
2394        let fees = UniswapV4Fees::new(1000, 1000, 1000);
2395        let total = fees.calculate_swap_fees_pips(true, Some(lp_fee::MAX_LP_FEE));
2396
2397        // Using UniswapV4 formula: 1000 + 1000000 - (1000 * 1000000 / 1_000_000)
2398        // = 1001000 - 1000 = 1000000
2399        assert_eq!(total, 1_000_000);
2400    }
2401
2402    #[test]
2403    fn test_get_limits_graceful_underflow() {
2404        // Verifies graceful handling of liquidity underflow in get_limits for V4
2405        let usv4_state = UniswapV4State::new(
2406            1000000,
2407            U256::from_str("79228162514264337593543950336").unwrap(), // 1:1 price
2408            UniswapV4Fees { zero_for_one: 0, one_for_zero: 0, lp_fee: 3000 },
2409            0,
2410            60,
2411            vec![
2412                // A tick with net_liquidity > current_liquidity
2413                // When zero_for_one=true, this gets negated and would cause underflow
2414                TickInfo {
2415                    index: -60,
2416                    net_liquidity: 2000000, // 2x current liquidity
2417                    sqrt_price: U256::from_str("79051508376726796163471739988").unwrap(),
2418                },
2419            ],
2420        )
2421        .unwrap();
2422
2423        let usdc = usdc();
2424        let weth = weth();
2425
2426        let (limit_in, limit_out) = usv4_state
2427            .get_limits(usdc.address.clone(), weth.address.clone())
2428            .unwrap();
2429
2430        // Should return some conservative limits
2431        assert!(limit_in > BigUint::zero());
2432        assert!(limit_out > BigUint::zero());
2433    }
2434
2435    // Tests based on Uniswap V4's ProtocolFeeLibrary.t.sol
2436    // See: https://github.com/Uniswap/v4-core/blob/main/test/libraries/ProtocolFeeLibrary.t.sol
2437
2438    /// Maximum protocol fee in pips (1000 = 0.1%)
2439    const MAX_PROTOCOL_FEE: u32 = 1000;
2440
2441    #[rstest]
2442    #[case::max_protocol_and_max_lp(MAX_PROTOCOL_FEE, lp_fee::MAX_LP_FEE, lp_fee::MAX_LP_FEE)]
2443    #[case::max_protocol_with_3000_lp(MAX_PROTOCOL_FEE, 3000, 3997)]
2444    #[case::max_protocol_with_zero_lp(MAX_PROTOCOL_FEE, 0, MAX_PROTOCOL_FEE)]
2445    #[case::zero_protocol_zero_lp(0, 0, 0)]
2446    #[case::zero_protocol_with_1000_lp(0, 1000, 1000)]
2447    fn test_calculate_swap_fees_uniswap_test_cases(
2448        #[case] protocol_fee: u32,
2449        #[case] lp_fee: u32,
2450        #[case] expected: u32,
2451    ) {
2452        let fees = UniswapV4Fees::new(protocol_fee, protocol_fee, lp_fee);
2453        let result = fees.calculate_swap_fees_pips(true, None);
2454        assert_eq!(result, expected);
2455    }
2456
2457    #[test]
2458    fn test_calculate_swap_fees_with_dynamic_fee() {
2459        // Test that dynamic fees default to 0 when no override is provided
2460        let fees = UniswapV4Fees::new(100, 90, lp_fee::DYNAMIC_FEE_FLAG);
2461
2462        // Without override, dynamic fee should be treated as 0
2463        let total_zero_for_one = fees.calculate_swap_fees_pips(true, None);
2464        // 100 + 0 - (100 * 0 / 1_000_000) = 100
2465        assert_eq!(total_zero_for_one, 100);
2466
2467        // With override, should use the override value
2468        let total_with_override = fees.calculate_swap_fees_pips(true, Some(500));
2469        // 100 + 500 - (100 * 500 / 1_000_000) = 600 - 0 = 600
2470        assert_eq!(total_with_override, 600);
2471    }
2472
2473    #[test]
2474    fn test_calculate_swap_fees_direction_matters() {
2475        // Test that zero_for_one direction affects which protocol fee is used
2476        let fees = UniswapV4Fees::new(100, 200, 500);
2477
2478        let zero_for_one_fee = fees.calculate_swap_fees_pips(true, None);
2479        // 100 + 500 - (100 * 500 / 1_000_000) = 600 - 0 = 600
2480        assert_eq!(zero_for_one_fee, 600);
2481
2482        let one_for_zero_fee = fees.calculate_swap_fees_pips(false, None);
2483        // 200 + 500 - (200 * 500 / 1_000_000) = 700 - 0 = 700
2484        assert_eq!(one_for_zero_fee, 700);
2485    }
2486
2487    #[rstest]
2488    #[case::high_lp_fee(1000, 500_000, 500_500)] // 1000 + 500k - 500 = 500.5k
2489    #[case::mid_fees(500, 500_000, 500_250)] // 500 + 500k - 250 = 500.25k
2490    #[case::low_fees(100, 100_000, 100_090)] // 100 + 100k - 10 = 100.09k
2491    fn test_calculate_swap_fees_formula_precision(
2492        #[case] protocol_fee: u32,
2493        #[case] lp_fee: u32,
2494        #[case] expected: u32,
2495    ) {
2496        // Test cases where the subtraction term (protocol * lp / 1M) significantly affects the
2497        // result
2498        let fees = UniswapV4Fees::new(protocol_fee, protocol_fee, lp_fee);
2499        let result = fees.calculate_swap_fees_pips(true, None);
2500        assert_eq!(result, expected, "Failed for protocol={}, lp={}", protocol_fee, lp_fee);
2501    }
2502
2503    #[test]
2504    fn test_calculate_swap_fees_override_takes_precedence() {
2505        // Test that lp_fee_override completely replaces stored lp_fee
2506        let fees = UniswapV4Fees::new(100, 100, 3000);
2507
2508        // With override, stored lp_fee should be ignored
2509        let result = fees.calculate_swap_fees_pips(true, Some(5000));
2510        // 100 + 5000 - (100 * 5000 / 1_000_000) = 5100 - 0 = 5100
2511        assert_eq!(result, 5100);
2512
2513        // Without override, should use stored lp_fee
2514        let result_no_override = fees.calculate_swap_fees_pips(true, None);
2515        // 100 + 3000 - (100 * 3000 / 1_000_000) = 3100 - 0 = 3100
2516        assert_eq!(result_no_override, 3100);
2517    }
2518
2519    #[test]
2520    fn test_calculate_swap_fees_zero_protocol_fee() {
2521        // When protocol fee is 0, formula simplifies to just lpFee
2522        let fees = UniswapV4Fees::new(0, 0, 3000);
2523        let result = fees.calculate_swap_fees_pips(true, None);
2524        // 0 + 3000 - (0 * 3000 / 1_000_000) = 3000
2525        assert_eq!(result, 3000);
2526    }
2527
2528    #[test]
2529    fn test_calculate_swap_fees_zero_lp_fee() {
2530        // When lp fee is 0, formula simplifies to just protocolFee
2531        let fees = UniswapV4Fees::new(500, 500, 0);
2532        let result = fees.calculate_swap_fees_pips(true, None);
2533        // 500 + 0 - (500 * 0 / 1_000_000) = 500
2534        assert_eq!(result, 500);
2535    }
2536
2537    // Helper to create a basic test pool for swap_to_price tests
2538    fn create_basic_v4_test_pool() -> UniswapV4State {
2539        let liquidity = 100_000_000_000_000_000_000u128; // 100e18
2540        let sqrt_price = get_sqrt_price_q96(U256::from(20_000_000u64), U256::from(10_000_000u64))
2541            .expect("Failed to calculate sqrt price");
2542        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("Failed to calculate tick");
2543
2544        let ticks = vec![TickInfo::new(0, 0).unwrap(), TickInfo::new(46080, 0).unwrap()];
2545
2546        UniswapV4State::new(
2547            liquidity,
2548            sqrt_price,
2549            UniswapV4Fees { zero_for_one: 0, one_for_zero: 0, lp_fee: 3000 }, // 0.3% fee
2550            tick,
2551            60, // tick spacing
2552            ticks,
2553        )
2554        .expect("Failed to create pool")
2555    }
2556
2557    fn create_tick_boundary_v4_test_pool() -> UniswapV4State {
2558        let sqrt_price = get_sqrt_ratio_at_tick(0).expect("Failed to calculate sqrt price");
2559        let ticks = vec![TickInfo::new(-120, 0).unwrap(), TickInfo::new(120, 0).unwrap()];
2560
2561        UniswapV4State::new(
2562            100_000_000_000_000_000_000u128,
2563            sqrt_price,
2564            UniswapV4Fees { zero_for_one: 0, one_for_zero: 0, lp_fee: 3000 },
2565            0,
2566            60,
2567            ticks,
2568        )
2569        .expect("Failed to create pool")
2570    }
2571
2572    #[test]
2573    fn test_partial_step_updates_tick_when_price_moves_without_crossing_initialized_tick() {
2574        let pool = create_tick_boundary_v4_test_pool();
2575        let amount = -I256::from_raw(U256::from(100_000_000_000_000_000u64));
2576
2577        let result = pool
2578            .swap(true, amount, None, None)
2579            .expect("swap should stay within the current liquidity range");
2580        let expected_tick =
2581            get_tick_at_sqrt_ratio(result.sqrt_price).expect("new sqrt price should map to a tick");
2582
2583        assert_ne!(result.sqrt_price, pool.sqrt_price);
2584        assert_ne!(result.sqrt_price, get_sqrt_ratio_at_tick(-120).unwrap());
2585        assert_ne!(expected_tick, pool.tick);
2586        assert_eq!(result.tick, expected_tick);
2587    }
2588
2589    #[test]
2590    fn test_swap_keeps_boundary_tick_when_price_does_not_move() {
2591        let mut pool = create_tick_boundary_v4_test_pool();
2592        pool.tick = -1;
2593        let amount = -I256::from_raw(U256::from(1u64));
2594
2595        let result = pool
2596            .swap(true, amount, None, None)
2597            .expect("swap should consume the input as fee without moving price");
2598
2599        assert_eq!(result.sqrt_price, pool.sqrt_price);
2600        assert_eq!(get_tick_at_sqrt_ratio(result.sqrt_price).unwrap(), 0);
2601        assert_eq!(result.tick, pool.tick);
2602    }
2603
2604    #[test]
2605    fn test_swap_to_price_price_too_high() {
2606        let pool = create_basic_v4_test_pool();
2607
2608        let token_x = token_x();
2609        let token_y = token_y();
2610
2611        // Price far above pool price - should return zero
2612        let target_price = Price::new(BigUint::from(10_000_000u64), BigUint::from(1_000_000u64));
2613
2614        let result = pool.query_pool_swap(&QueryPoolSwapParams::new(
2615            token_x,
2616            token_y,
2617            SwapConstraint::PoolTargetPrice {
2618                target: target_price,
2619                tolerance: 0f64,
2620                min_amount_in: None,
2621                max_amount_in: None,
2622            },
2623        ));
2624        assert!(result.is_err(), "Should return error when target price is unreachable");
2625    }
2626
2627    #[test]
2628    fn test_swap_to_price_no_liquidity() {
2629        // Test that swap_to_price returns zero for pool with no liquidity
2630        let pool = UniswapV4State::new(
2631            0, // No liquidity
2632            U256::from_str("79228162514264337593543950336").unwrap(),
2633            UniswapV4Fees { zero_for_one: 0, one_for_zero: 0, lp_fee: 3000 },
2634            0,
2635            60,
2636            vec![],
2637        )
2638        .unwrap();
2639
2640        let token_x = token_x();
2641        let token_y = token_y();
2642
2643        let target_price = Price::new(BigUint::from(2_000_000u64), BigUint::from(1_000_000u64));
2644
2645        let pool_swap = pool.query_pool_swap(&QueryPoolSwapParams::new(
2646            token_x,
2647            token_y,
2648            SwapConstraint::PoolTargetPrice {
2649                target: target_price,
2650                tolerance: 0f64,
2651                min_amount_in: None,
2652                max_amount_in: None,
2653            },
2654        ));
2655
2656        assert!(pool_swap.is_err());
2657    }
2658
2659    #[test]
2660    fn test_swap_to_price_with_protocol_fees() {
2661        let liquidity = 100_000_000_000_000_000_000u128;
2662        let sqrt_price = get_sqrt_price_q96(U256::from(20_000_000u64), U256::from(10_000_000u64))
2663            .expect("Failed to calculate sqrt price");
2664        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("Failed to calculate tick");
2665
2666        let ticks = vec![TickInfo::new(0, 0).unwrap(), TickInfo::new(46080, 0).unwrap()];
2667
2668        // Create pool with different protocol fees for each direction
2669        let pool = UniswapV4State::new(
2670            liquidity,
2671            sqrt_price,
2672            UniswapV4Fees {
2673                zero_for_one: 1000, // 0.1% protocol fee for zero_for_one
2674                one_for_zero: 200,  // 0.02% protocol fee for one_for_zero
2675                lp_fee: 3000,       // 0.3% LP fee
2676            },
2677            tick,
2678            60,
2679            ticks,
2680        )
2681        .expect("Failed to create pool");
2682
2683        let token_x = token_x();
2684        let token_y = token_y();
2685
2686        // Pool at 2.0 Y/X = 0.5 X/Y, swap_to_price moves price DOWN to target
2687
2688        // Test zero_for_one direction (X -> Y, uses zero_for_one fee)
2689        let target_price = Price::new(BigUint::from(2_000_000u64), BigUint::from(1_010_000u64));
2690        let pool_swap_forward = pool
2691            .query_pool_swap(&QueryPoolSwapParams::new(
2692                token_x.clone(),
2693                token_y.clone(),
2694                SwapConstraint::PoolTargetPrice {
2695                    target: target_price,
2696                    tolerance: 0f64,
2697                    min_amount_in: None,
2698                    max_amount_in: None,
2699                },
2700            ))
2701            .expect("swap_to_price failed");
2702
2703        // Test one_for_zero direction (Y -> X, uses one_for_zero fee)
2704        let target_price_reverse =
2705            Price::new(BigUint::from(1_010_000u64), BigUint::from(2_040_000u64));
2706        let pool_swap_backward = pool
2707            .query_pool_swap(&QueryPoolSwapParams::new(
2708                token_y,
2709                token_x,
2710                SwapConstraint::PoolTargetPrice {
2711                    target: target_price_reverse,
2712                    tolerance: 0f64,
2713                    min_amount_in: None,
2714                    max_amount_in: None,
2715                },
2716            ))
2717            .expect("swap_to_price failed");
2718
2719        assert!(
2720            pool_swap_backward.amount_out().clone() > BigUint::ZERO,
2721            "One for zero swap should return non-zero output"
2722        );
2723
2724        // Higher fees require more volume to reach the same price target
2725        // trade_zfo has 0.1% protocol fee, trade_ofz has 0.02% protocol fee
2726        assert!(
2727            pool_swap_forward.amount_out() < pool_swap_backward.amount_in(),
2728            "Backward fees should be lower therefore backward swap should be bigger"
2729        );
2730        assert!(
2731            pool_swap_forward.amount_in() < pool_swap_backward.amount_out(),
2732            "Backward fees should be lower therefore backward swap should be bigger"
2733        );
2734    }
2735
2736    #[test]
2737    fn test_swap_to_price_different_targets() {
2738        // Test with various target prices using working format
2739        let pool = create_basic_v4_test_pool();
2740
2741        let token_x = token_x();
2742        let token_y = token_y();
2743
2744        // Pool at 2.0 Y/X (20M/10M)
2745        // Test 1: Target close to spot (1.98 Y/X)
2746        let target_price = Price::new(BigUint::from(2_000_000u64), BigUint::from(1_010_000u64));
2747        let pool_swap_close = pool
2748            .query_pool_swap(&QueryPoolSwapParams::new(
2749                token_x.clone(),
2750                token_y.clone(),
2751                SwapConstraint::PoolTargetPrice {
2752                    target: target_price,
2753                    tolerance: 0f64,
2754                    min_amount_in: None,
2755                    max_amount_in: None,
2756                },
2757            ))
2758            .expect("swap_to_price failed");
2759        assert!(
2760            *pool_swap_close.amount_out() > BigUint::ZERO,
2761            "Expected non-zero for 1.98 Y/X target"
2762        );
2763
2764        // Test 2: Target further from spot (1.90 Y/X)
2765        let target_price = Price::new(BigUint::from(1_900_000u64), BigUint::from(1_000_000u64));
2766        let pool_swap_below = pool
2767            .query_pool_swap(&QueryPoolSwapParams::new(
2768                token_x.clone(),
2769                token_y.clone(),
2770                SwapConstraint::PoolTargetPrice {
2771                    target: target_price,
2772                    tolerance: 0f64,
2773                    min_amount_in: None,
2774                    max_amount_in: None,
2775                },
2776            ))
2777            .expect("swap_to_price failed");
2778        assert!(
2779            pool_swap_below.amount_out().clone() > BigUint::ZERO,
2780            "Expected non-zero for 1.90 Y/X target"
2781        );
2782
2783        // Test 3: Target far from spot (1.5 Y/X)
2784        let target_price = Price::new(BigUint::from(1_500_000u64), BigUint::from(1_000_000u64));
2785        let pool_swap_far = pool
2786            .query_pool_swap(&QueryPoolSwapParams::new(
2787                token_x,
2788                token_y,
2789                SwapConstraint::PoolTargetPrice {
2790                    target: target_price,
2791                    tolerance: 0f64,
2792                    min_amount_in: None,
2793                    max_amount_in: None,
2794                },
2795            ))
2796            .expect("swap_to_price failed");
2797        assert!(
2798            pool_swap_far.amount_out().clone() > BigUint::ZERO,
2799            "Expected non-zero for 1.5 Y/X target"
2800        );
2801
2802        // Verify that further targets require more volume
2803        assert!(
2804            pool_swap_close.amount_out().clone() < pool_swap_below.amount_out().clone(),
2805            "Closer target (1.98 Y/X) should require less volume than medium target (1.90 Y/X). \
2806             Got close: {}, medium: {}",
2807            pool_swap_close.amount_out().clone(),
2808            pool_swap_below.amount_out().clone()
2809        );
2810        assert!(
2811            pool_swap_below.amount_out().clone() < pool_swap_far.amount_out().clone(),
2812            "Medium target (1.90 Y/X) should require less volume than far target (1.5 Y/X). \
2813             Got medium: {}, far: {}",
2814            pool_swap_below.amount_out().clone(),
2815            pool_swap_far.amount_out().clone()
2816        );
2817    }
2818
2819    #[test]
2820    fn test_swap_to_price_around_spot_price() {
2821        let liquidity = 10_000_000_000_000_000u128;
2822        let sqrt_price =
2823            get_sqrt_price_q96(U256::from(2_000_000_000u64), U256::from(1_000_000_000u64))
2824                .expect("Failed to calculate sqrt price");
2825        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("Failed to calculate tick");
2826
2827        let ticks = vec![TickInfo::new(0, 0).unwrap(), TickInfo::new(46080, 0).unwrap()];
2828
2829        // Use FeeAmount::Low equivalent (500 pips = 0.05%)
2830        let pool = UniswapV4State::new(
2831            liquidity,
2832            sqrt_price,
2833            UniswapV4Fees {
2834                zero_for_one: 0,
2835                one_for_zero: 0,
2836                lp_fee: 500, // 0.05% to match V3 FeeAmount::Low
2837            },
2838            tick,
2839            60,
2840            ticks,
2841        )
2842        .expect("Failed to create pool");
2843
2844        let token_x = token_x();
2845        let token_y = token_y();
2846
2847        // Test 1: Price just above spot price, too little to cover fees
2848        let target_price = Price::new(BigUint::from(1_999_750u64), BigUint::from(1_000_250u64));
2849
2850        let result = pool.query_pool_swap(&QueryPoolSwapParams::new(
2851            token_x.clone(),
2852            token_y.clone(),
2853            SwapConstraint::PoolTargetPrice {
2854                target: target_price,
2855                tolerance: 0f64,
2856                min_amount_in: None,
2857                max_amount_in: None,
2858            },
2859        ));
2860        assert!(result.is_err(), "Should return error when target price is unreachable");
2861
2862        // Test 2: Price far enough from spot prices to enable trading despite fees (0.1% lower)
2863        let target_price = Price::new(BigUint::from(1_999_000u64), BigUint::from(1_001_000u64));
2864
2865        let pool_swap = pool
2866            .query_pool_swap(&QueryPoolSwapParams::new(
2867                token_x,
2868                token_y,
2869                SwapConstraint::PoolTargetPrice {
2870                    target: target_price,
2871                    tolerance: 0f64,
2872                    min_amount_in: None,
2873                    max_amount_in: None,
2874                },
2875            ))
2876            .expect("swap_to_price failed");
2877
2878        // Should match V3 output exactly with same fees
2879        let expected_amount_out =
2880            BigUint::from_str("7062236922008").expect("Failed to parse expected value");
2881        assert_eq!(
2882            pool_swap.amount_out().clone(),
2883            expected_amount_out,
2884            "V4 should match V3 output with same fees (0.05%)"
2885        );
2886    }
2887
2888    #[test]
2889    fn test_swap_to_price_matches_get_amount_out() {
2890        let pool = create_basic_v4_test_pool();
2891
2892        let token_x = token_x();
2893        let token_y = token_y();
2894
2895        // Get the trade from swap_to_price
2896        let target_price = Price::new(BigUint::from(2_000_000u64), BigUint::from(1_010_000u64));
2897        let pool_swap = pool
2898            .query_pool_swap(&QueryPoolSwapParams::new(
2899                token_x.clone(),
2900                token_y.clone(),
2901                SwapConstraint::PoolTargetPrice {
2902                    target: target_price,
2903                    tolerance: 0f64,
2904                    min_amount_in: None,
2905                    max_amount_in: None,
2906                },
2907            ))
2908            .expect("swap_to_price failed");
2909        assert!(*pool_swap.amount_in() > BigUint::ZERO, "Amount in should be positive");
2910
2911        // Use the amount_in from swap_to_price with get_amount_out
2912        let result = pool
2913            .get_amount_out(pool_swap.amount_in().clone(), &token_x, &token_y)
2914            .expect("get_amount_out failed");
2915
2916        // The amount_out from get_amount_out should be close to swap_to_price's amount_out
2917        // Allow for small rounding differences
2918        assert!(result.amount > BigUint::ZERO);
2919        assert!(result.amount >= *pool_swap.amount_out());
2920    }
2921
2922    #[test]
2923    fn test_swap_to_price_basic() {
2924        let liquidity = 100_000_000_000_000_000_000u128;
2925        let sqrt_price = get_sqrt_price_q96(U256::from(20_000_000u64), U256::from(10_000_000u64))
2926            .expect("Failed to calculate sqrt price");
2927        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("Failed to calculate tick");
2928
2929        let ticks = vec![TickInfo::new(0, 0).unwrap(), TickInfo::new(46080, 0).unwrap()];
2930
2931        let pool = UniswapV4State::new(
2932            liquidity,
2933            sqrt_price,
2934            UniswapV4Fees {
2935                zero_for_one: 0,
2936                one_for_zero: 0,
2937                lp_fee: 3000, // 0.3% LP fee
2938            },
2939            tick,
2940            60,
2941            ticks,
2942        )
2943        .expect("Failed to create pool");
2944
2945        let token_x = token_x();
2946        let token_y = token_y();
2947
2948        // Target price: 2_000_000/1_010_000 ≈ 1.98 Y/X
2949        let target_price = Price::new(BigUint::from(2_000_000u64), BigUint::from(1_010_000u64));
2950
2951        let pool_swap = pool
2952            .query_pool_swap(&QueryPoolSwapParams::new(
2953                token_x,
2954                token_y,
2955                SwapConstraint::PoolTargetPrice {
2956                    target: target_price,
2957                    tolerance: 0f64,
2958                    min_amount_in: None,
2959                    max_amount_in: None,
2960                },
2961            ))
2962            .expect("swap_to_price failed");
2963
2964        // Should match V3's output exactly with same fees (0.3%)
2965        let expected_amount_in = BigUint::from_str("246739021727519745").unwrap();
2966        let expected_amount_out = BigUint::from_str("490291909043340795").unwrap();
2967
2968        assert_eq!(
2969            *pool_swap.amount_in(),
2970            expected_amount_in,
2971            "amount_in should match expected value"
2972        );
2973        assert_eq!(
2974            *pool_swap.amount_out(),
2975            expected_amount_out,
2976            "amount_out should match expected value"
2977        );
2978    }
2979
2980    #[test]
2981    fn test_swap_price_limit_out_of_range_returns_error() {
2982        let pool = create_basic_v4_test_pool();
2983        let amount = -I256::from_raw(U256::from(1000u64)); // V4 uses negative for exact input
2984
2985        // zero_for_one: price_limit equal to sqrt_price is invalid (must be strictly less)
2986        let result = pool.swap(true, amount, Some(pool.sqrt_price), None);
2987        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
2988
2989        // zero_for_one: price_limit at MIN_SQRT_RATIO is invalid (must be strictly greater)
2990        let result = pool.swap(true, amount, Some(MIN_SQRT_RATIO), None);
2991        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
2992
2993        // one_for_zero: price_limit equal to sqrt_price is invalid (must be strictly greater)
2994        let result = pool.swap(false, amount, Some(pool.sqrt_price), None);
2995        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
2996
2997        // one_for_zero: price_limit at MAX_SQRT_RATIO is invalid (must be strictly less)
2998        let result = pool.swap(false, amount, Some(MAX_SQRT_RATIO), None);
2999        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
3000    }
3001
3002    #[test]
3003    fn test_swap_at_extreme_price_returns_error() {
3004        // Simulates the depth calculation scenario: pool sqrt_price is at MIN_SQRT_RATIO + 1,
3005        // so the default price limit for zero_for_one equals sqrt_price and fails validation.
3006        let sqrt_price = MIN_SQRT_RATIO + U256::from(1u64);
3007        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("Failed to calculate tick");
3008        // tick_spacing 60; ticks must be aligned
3009        let aligned_tick = (MIN_TICK / 60) * 60 + 60; // first multiple of 60 above MIN_TICK
3010        let ticks = vec![
3011            TickInfo::new(aligned_tick, 0).unwrap(),
3012            TickInfo::new(aligned_tick + 60, 0).unwrap(),
3013        ];
3014        let pool = UniswapV4State::new(
3015            100_000_000_000_000_000_000u128,
3016            sqrt_price,
3017            UniswapV4Fees { zero_for_one: 0, one_for_zero: 0, lp_fee: 3000 },
3018            tick,
3019            60,
3020            ticks,
3021        )
3022        .unwrap();
3023
3024        let amount = -I256::from_raw(U256::from(1000u64));
3025        // Default price limit for zero_for_one is MIN_SQRT_RATIO + 1 == sqrt_price, so invalid
3026        let result = pool.swap(true, amount, None, None);
3027        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
3028    }
3029}