Skip to main content

tycho_simulation/evm/protocol/uniswap_v4/
state.rs

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