Skip to main content

tycho_simulation/evm/protocol/uniswap_v3/
state.rs

1use std::{any::Any, collections::HashMap};
2
3use alloy::primitives::{Sign, I256, U256};
4use num_bigint::BigUint;
5use num_traits::Zero;
6use serde::{Deserialize, Serialize};
7use tracing::trace;
8use tycho_common::{
9    dto::ProtocolStateDelta,
10    models::token::Token,
11    simulation::{
12        errors::{SimulationError, TransitionError},
13        protocol_sim::{
14            Balances, GetAmountOutResult, PoolSwap, ProtocolSim, QueryPoolSwapParams,
15            SwapConstraint,
16        },
17    },
18    Bytes,
19};
20
21use super::fee_tier::{fee_serde, FeeTier};
22use crate::evm::protocol::{
23    clmm::clmm_swap_to_price,
24    safe_math::{safe_add_u256, safe_sub_u256},
25    u256_num::u256_to_biguint,
26    utils::{
27        add_fee_markup,
28        uniswap::{
29            i24_be_bytes_to_i32, liquidity_math,
30            sqrt_price_math::{get_amount0_delta, get_amount1_delta, sqrt_price_q96_to_f64},
31            swap_math,
32            tick_list::{TickInfo, TickList, TickListErrorKind},
33            tick_math::{
34                get_sqrt_ratio_at_tick, get_tick_at_sqrt_ratio, MAX_SQRT_RATIO, MAX_TICK,
35                MIN_SQRT_RATIO, MIN_TICK,
36            },
37            StepComputation, SwapResults, SwapState,
38        },
39    },
40};
41
42// Pre/post loop overhead: cold SLOADs (slot0, liquidity, feeGrowthGlobal) +
43// cold SSTOREs (slot0 write, liquidity write) at end of swap.
44const SWAP_BASE_GAS: u64 = 70_000;
45// Bitmap word scan (cold SLOAD of tickBitmap word)
46const GAS_PER_BITMAP_WORD: u64 = 2_100;
47// swap math step: getSqrtRatioAtTick + computeSwapStep + amount accounting + getTickAtSqrtRatio
48const GAS_PER_SWAP_MATH_STEP: u64 = 5_400;
49// Initialized tick crossing: cross() updates feeGrowthOutside0/1 (2 SSTOREs).
50// Warm ≈ 10-17k, cold ≈ 40-52k. 24k biases toward cold for overestimation.
51const GAS_PER_INITIALIZED_TICK_CROSS: u64 = 24_000;
52// Output transfer + balanceBefore + callback + balanceAfter.
53const V3_CALLBACK_SETTLEMENT_GAS: u64 = 70_000;
54// Conservative max gas budget for a single swap (Ethereum transaction gas limit)
55const MAX_SWAP_GAS: u64 = 16_700_000;
56const MAX_TICKS_CROSSED: u64 = (MAX_SWAP_GAS - SWAP_BASE_GAS) / GAS_PER_INITIALIZED_TICK_CROSS;
57
58#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
59pub struct UniswapV3State {
60    liquidity: u128,
61    sqrt_price: U256,
62    #[serde(with = "fee_serde")]
63    fee: u32,
64    tick: i32,
65    ticks: TickList,
66}
67
68impl UniswapV3State {
69    /// Creates a new instance of `UniswapV3State`.
70    ///
71    /// # Arguments
72    /// - `liquidity`: The initial liquidity of the pool.
73    /// - `sqrt_price`: The square root of the current price.
74    /// - `fee`: The pool's fee and tick spacing. A [`FeeAmount`](super::enums::FeeAmount) implies
75    ///   the canonical Uniswap V3 spacing for its fee; pass a [`FeeTier`] for pools whose spacing
76    ///   is set independently.
77    /// - `tick`: The current tick of the pool.
78    /// - `ticks`: A vector of `TickInfo` representing the tick information for the pool.
79    ///
80    /// Errors when a tick index is not a multiple of the tick spacing or the ticks are unsorted.
81    pub fn new(
82        liquidity: u128,
83        sqrt_price: U256,
84        fee: impl Into<FeeTier>,
85        tick: i32,
86        ticks: Vec<TickInfo>,
87    ) -> Result<Self, SimulationError> {
88        let fee = fee.into();
89        let tick_list = TickList::from(fee.tick_spacing(), ticks)?;
90        Ok(UniswapV3State { liquidity, sqrt_price, fee: fee.fee(), tick, ticks: tick_list })
91    }
92
93    fn swap(
94        &self,
95        zero_for_one: bool,
96        amount_specified: I256,
97        sqrt_price_limit: Option<U256>,
98    ) -> Result<SwapResults, SimulationError> {
99        if self.liquidity == 0 {
100            return Err(SimulationError::RecoverableError("No liquidity".to_string()));
101        }
102        let price_limit = if let Some(limit) = sqrt_price_limit {
103            limit
104        } else if zero_for_one {
105            safe_add_u256(MIN_SQRT_RATIO, U256::from(1u64))?
106        } else {
107            safe_sub_u256(MAX_SQRT_RATIO, U256::from(1u64))?
108        };
109
110        let price_limit_valid = if zero_for_one {
111            price_limit > MIN_SQRT_RATIO && price_limit < self.sqrt_price
112        } else {
113            price_limit < MAX_SQRT_RATIO && price_limit > self.sqrt_price
114        };
115        if !price_limit_valid {
116            return Err(SimulationError::InvalidInput("Price limit out of range".into(), None));
117        }
118
119        let exact_input = amount_specified > I256::from_raw(U256::from(0u64));
120
121        let mut state = SwapState {
122            amount_remaining: amount_specified,
123            amount_calculated: I256::from_raw(U256::from(0u64)),
124            sqrt_price: self.sqrt_price,
125            tick: self.tick,
126            liquidity: self.liquidity,
127        };
128        let mut gas_used = U256::from(SWAP_BASE_GAS);
129
130        while state.amount_remaining != I256::from_raw(U256::from(0u64)) &&
131            state.sqrt_price != price_limit
132        {
133            let (mut next_tick, initialized) = match self
134                .ticks
135                .next_initialized_tick_within_one_word(state.tick, zero_for_one)
136            {
137                Ok((tick, init)) => {
138                    gas_used = safe_add_u256(gas_used, U256::from(GAS_PER_BITMAP_WORD))?;
139                    (tick, init)
140                }
141                Err(tick_err) => match tick_err.kind {
142                    TickListErrorKind::TicksExeeded => {
143                        let mut new_state = self.clone();
144                        new_state.liquidity = state.liquidity;
145                        new_state.tick = state.tick;
146                        new_state.sqrt_price = state.sqrt_price;
147                        return Err(SimulationError::InvalidInput(
148                            "Ticks exceeded".into(),
149                            Some(GetAmountOutResult::new(
150                                u256_to_biguint(state.amount_calculated.abs().into_raw()),
151                                u256_to_biguint(gas_used),
152                                Box::new(new_state),
153                            )),
154                        ));
155                    }
156                    _ => return Err(SimulationError::FatalError("Unknown error".to_string())),
157                },
158            };
159
160            next_tick = next_tick.clamp(MIN_TICK, MAX_TICK);
161
162            let sqrt_price_start = state.sqrt_price;
163            let sqrt_price_next = get_sqrt_ratio_at_tick(next_tick)?;
164            let (sqrt_price, amount_in, amount_out, fee_amount) = swap_math::compute_swap_step(
165                state.sqrt_price,
166                UniswapV3State::get_sqrt_ratio_target(sqrt_price_next, price_limit, zero_for_one),
167                state.liquidity,
168                state.amount_remaining,
169                self.fee,
170            )?;
171            state.sqrt_price = sqrt_price;
172
173            let step = StepComputation {
174                sqrt_price_start,
175                tick_next: next_tick,
176                initialized,
177                sqrt_price_next,
178                amount_in,
179                amount_out,
180                fee_amount,
181            };
182
183            gas_used = safe_add_u256(gas_used, U256::from(GAS_PER_SWAP_MATH_STEP))?;
184
185            if exact_input {
186                state.amount_remaining -= I256::checked_from_sign_and_abs(
187                    Sign::Positive,
188                    safe_add_u256(step.amount_in, step.fee_amount)?,
189                )
190                .unwrap();
191                state.amount_calculated -=
192                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
193            } else {
194                state.amount_remaining +=
195                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
196                state.amount_calculated += I256::checked_from_sign_and_abs(
197                    Sign::Positive,
198                    safe_add_u256(step.amount_in, step.fee_amount)?,
199                )
200                .unwrap();
201            }
202            if state.sqrt_price == step.sqrt_price_next {
203                if step.initialized {
204                    let liquidity_raw = self
205                        .ticks
206                        .get_tick(step.tick_next)
207                        .unwrap()
208                        .net_liquidity;
209                    let liquidity_net = if zero_for_one { -liquidity_raw } else { liquidity_raw };
210                    state.liquidity =
211                        liquidity_math::add_liquidity_delta(state.liquidity, liquidity_net)?;
212                    gas_used = safe_add_u256(gas_used, U256::from(GAS_PER_INITIALIZED_TICK_CROSS))?;
213                }
214                state.tick = if zero_for_one { step.tick_next - 1 } else { step.tick_next };
215            } else if state.sqrt_price != step.sqrt_price_start {
216                state.tick = get_tick_at_sqrt_ratio(state.sqrt_price)?;
217            }
218        }
219        Ok(SwapResults {
220            amount_calculated: state.amount_calculated,
221            amount_specified,
222            amount_remaining: state.amount_remaining,
223            sqrt_price: state.sqrt_price,
224            liquidity: state.liquidity,
225            tick: state.tick,
226            gas_used: safe_add_u256(gas_used, U256::from(V3_CALLBACK_SETTLEMENT_GAS))?,
227        })
228    }
229
230    fn get_sqrt_ratio_target(
231        sqrt_price_next: U256,
232        sqrt_price_limit: U256,
233        zero_for_one: bool,
234    ) -> U256 {
235        let cond1 = if zero_for_one {
236            sqrt_price_next < sqrt_price_limit
237        } else {
238            sqrt_price_next > sqrt_price_limit
239        };
240
241        if cond1 {
242            sqrt_price_limit
243        } else {
244            sqrt_price_next
245        }
246    }
247}
248
249#[typetag::serde]
250impl ProtocolSim for UniswapV3State {
251    fn fee(&self) -> f64 {
252        self.fee as f64 / 1_000_000.0
253    }
254
255    fn spot_price(&self, a: &Token, b: &Token) -> Result<f64, SimulationError> {
256        let price = if a < b {
257            sqrt_price_q96_to_f64(self.sqrt_price, a.decimals, b.decimals)?
258        } else {
259            1.0f64 / sqrt_price_q96_to_f64(self.sqrt_price, b.decimals, a.decimals)?
260        };
261        Ok(add_fee_markup(price, self.fee()))
262    }
263
264    fn get_amount_out(
265        &self,
266        amount_in: BigUint,
267        token_a: &Token,
268        token_b: &Token,
269    ) -> Result<GetAmountOutResult, SimulationError> {
270        let zero_for_one = token_a < token_b;
271        let amount_specified = I256::checked_from_sign_and_abs(
272            Sign::Positive,
273            U256::from_be_slice(&amount_in.to_bytes_be()),
274        )
275        .ok_or_else(|| {
276            SimulationError::InvalidInput("I256 overflow: amount_in".to_string(), None)
277        })?;
278
279        let result = self.swap(zero_for_one, amount_specified, None)?;
280
281        trace!(?amount_in, ?token_a, ?token_b, ?zero_for_one, ?result, "V3 SWAP");
282        let mut new_state = self.clone();
283        new_state.liquidity = result.liquidity;
284        new_state.tick = result.tick;
285        new_state.sqrt_price = result.sqrt_price;
286
287        Ok(GetAmountOutResult::new(
288            u256_to_biguint(
289                result
290                    .amount_calculated
291                    .abs()
292                    .into_raw(),
293            ),
294            u256_to_biguint(result.gas_used),
295            Box::new(new_state),
296        ))
297    }
298
299    fn get_limits(
300        &self,
301        token_in: Bytes,
302        token_out: Bytes,
303    ) -> Result<(BigUint, BigUint), SimulationError> {
304        // If the pool has no liquidity, return zeros for both limits
305        if self.liquidity == 0 {
306            return Ok((BigUint::zero(), BigUint::zero()));
307        }
308
309        let zero_for_one = token_in < token_out;
310        let mut current_tick = self.tick;
311        let mut current_sqrt_price = self.sqrt_price;
312        let mut current_liquidity = self.liquidity;
313        let mut total_amount_in = U256::from(0u64);
314        let mut total_amount_out = U256::from(0u64);
315        let mut ticks_crossed: u64 = 0;
316
317        // Iterate through ticks in the direction of the swap
318        // Stops when: no more liquidity, no more ticks, or gas limit would be exceeded
319        while let Ok((tick, initialized)) = self
320            .ticks
321            .next_initialized_tick_within_one_word(current_tick, zero_for_one)
322        {
323            // Cap iteration to prevent exceeding Ethereum's gas limit
324            if ticks_crossed >= MAX_TICKS_CROSSED {
325                break;
326            }
327            ticks_crossed += 1;
328
329            // Clamp the tick value to ensure it's within valid range
330            let next_tick = tick.clamp(MIN_TICK, MAX_TICK);
331
332            // Calculate the sqrt price at the next tick boundary
333            let sqrt_price_next = get_sqrt_ratio_at_tick(next_tick)?;
334
335            // Calculate the amount of tokens swapped when moving from current_sqrt_price to
336            // sqrt_price_next. Direction determines which token is being swapped in vs out
337            let (amount_in, amount_out) = if zero_for_one {
338                let amount0 = get_amount0_delta(
339                    sqrt_price_next,
340                    current_sqrt_price,
341                    current_liquidity,
342                    true,
343                )?;
344                let amount1 = get_amount1_delta(
345                    sqrt_price_next,
346                    current_sqrt_price,
347                    current_liquidity,
348                    false,
349                )?;
350                (amount0, amount1)
351            } else {
352                let amount0 = get_amount0_delta(
353                    sqrt_price_next,
354                    current_sqrt_price,
355                    current_liquidity,
356                    false,
357                )?;
358                let amount1 = get_amount1_delta(
359                    sqrt_price_next,
360                    current_sqrt_price,
361                    current_liquidity,
362                    true,
363                )?;
364                (amount1, amount0)
365            };
366
367            // Accumulate total amounts for this tick range
368            total_amount_in = safe_add_u256(total_amount_in, amount_in)?;
369            total_amount_out = safe_add_u256(total_amount_out, amount_out)?;
370
371            // If this tick is "initialized" (meaning its someone's position boundary), update the
372            // liquidity when crossing it
373            // For zero_for_one, liquidity is removed when crossing a tick
374            // For one_for_zero, liquidity is added when crossing a tick
375            if initialized {
376                let liquidity_raw = self
377                    .ticks
378                    .get_tick(next_tick)
379                    .unwrap()
380                    .net_liquidity;
381                let liquidity_delta = if zero_for_one { -liquidity_raw } else { liquidity_raw };
382
383                // Check if applying this liquidity delta would cause underflow
384                // If so, stop here rather than continuing with invalid state
385                match liquidity_math::add_liquidity_delta(current_liquidity, liquidity_delta) {
386                    Ok(new_liquidity) => {
387                        current_liquidity = new_liquidity;
388                    }
389                    Err(_) => {
390                        // Liquidity would underflow, stop iteration here
391                        // This represents the maximum liquidity we can actually use
392                        break;
393                    }
394                }
395            }
396
397            // Move to the next tick position
398            current_tick = if zero_for_one { next_tick - 1 } else { next_tick };
399            current_sqrt_price = sqrt_price_next;
400        }
401
402        Ok((u256_to_biguint(total_amount_in), u256_to_biguint(total_amount_out)))
403    }
404
405    fn delta_transition(
406        &mut self,
407        delta: ProtocolStateDelta,
408        _tokens: &HashMap<Bytes, Token>,
409        _balances: &Balances,
410    ) -> Result<(), TransitionError> {
411        // apply attribute changes
412        if let Some(liquidity) = delta
413            .updated_attributes
414            .get("liquidity")
415        {
416            // This is a hotfix because if the liquidity has never been updated after creation, it's
417            // currently encoded as H256::zero(), therefore, we can't decode this as u128.
418            // We can remove this once it has been fixed on the tycho side.
419            let liq_16_bytes = if liquidity.len() == 32 {
420                // Make sure it only happens for 0 values, otherwise error.
421                if liquidity == &Bytes::zero(32) {
422                    Bytes::from([0; 16])
423                } else {
424                    return Err(TransitionError::DecodeError(format!(
425                        "Liquidity bytes too long for {liquidity}, expected 16",
426                    )));
427                }
428            } else {
429                liquidity.clone()
430            };
431
432            self.liquidity = u128::from(liq_16_bytes);
433        }
434        if let Some(sqrt_price) = delta
435            .updated_attributes
436            .get("sqrt_price_x96")
437        {
438            self.sqrt_price = U256::from_be_slice(sqrt_price);
439        }
440        if let Some(tick) = delta.updated_attributes.get("tick") {
441            // This is a hotfix because if the tick has never been updated after creation, it's
442            // currently encoded as H256::zero(), therefore, we can't decode this as i32.
443            // We can remove this once it has been fixed on the tycho side.
444            let ticks_4_bytes = if tick.len() == 32 {
445                // Make sure it only happens for 0 values, otherwise error.
446                if tick == &Bytes::zero(32) {
447                    Bytes::from([0; 4])
448                } else {
449                    return Err(TransitionError::DecodeError(format!(
450                        "Tick bytes too long for {tick}, expected 4"
451                    )));
452                }
453            } else {
454                tick.clone()
455            };
456            self.tick = i24_be_bytes_to_i32(&ticks_4_bytes);
457        }
458
459        // apply tick changes
460        for (key, value) in delta.updated_attributes.iter() {
461            // tick liquidity keys are in the format "ticks/{tick_index}/net_liquidity"
462            if key.starts_with("ticks/") {
463                let parts: Vec<&str> = key.split('/').collect();
464                self.ticks
465                    .set_tick_liquidity(
466                        parts[1]
467                            .parse::<i32>()
468                            .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
469                        i128::from(value.clone()),
470                    )
471                    .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
472            }
473        }
474        // delete ticks - ignores deletes for attributes other than tick liquidity
475        for key in delta.deleted_attributes.iter() {
476            // tick liquidity keys are in the format "ticks/{tick_index}/net_liquidity"
477            if key.starts_with("ticks/") {
478                let parts: Vec<&str> = key.split('/').collect();
479                self.ticks
480                    .set_tick_liquidity(
481                        parts[1]
482                            .parse::<i32>()
483                            .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
484                        0,
485                    )
486                    .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
487            }
488        }
489        Ok(())
490    }
491
492    /// See [`ProtocolSim::query_pool_swap`] for the trait documentation.
493    ///
494    /// This method uses Uniswap V3 internal swap logic by swapping an infinite amount of token_in
495    /// until the target price is reached.
496    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
497        if self.liquidity == 0 {
498            return Err(SimulationError::FatalError("No liquidity".to_string()));
499        }
500
501        match params.swap_constraint() {
502            SwapConstraint::TradeLimitPrice { .. } => Err(SimulationError::InvalidInput(
503                "Uniswap V3 does not support TradeLimitPrice constraint in query_pool_swap"
504                    .to_string(),
505                None,
506            )),
507            SwapConstraint::PoolTargetPrice {
508                target,
509                tolerance: _,
510                min_amount_in: _,
511                max_amount_in: _,
512            } => {
513                let (amount_in, amount_out, swap_result) = clmm_swap_to_price(
514                    self.sqrt_price,
515                    &params.token_in().address,
516                    &params.token_out().address,
517                    target,
518                    self.fee,
519                    Sign::Positive,
520                    |zero_for_one, amount_specified, sqrt_price_limit| {
521                        self.swap(zero_for_one, amount_specified, Some(sqrt_price_limit))
522                    },
523                )?;
524
525                let mut new_state = self.clone();
526                new_state.liquidity = swap_result.liquidity;
527                new_state.tick = swap_result.tick;
528                new_state.sqrt_price = swap_result.sqrt_price;
529
530                Ok(PoolSwap::new(amount_in, amount_out, Box::new(new_state), None))
531            }
532        }
533    }
534
535    fn clone_box(&self) -> Box<dyn ProtocolSim> {
536        Box::new(self.clone())
537    }
538
539    fn as_any(&self) -> &dyn Any {
540        self
541    }
542
543    fn as_any_mut(&mut self) -> &mut dyn Any {
544        self
545    }
546
547    fn eq(&self, other: &dyn ProtocolSim) -> bool {
548        if let Some(other_state) = other
549            .as_any()
550            .downcast_ref::<UniswapV3State>()
551        {
552            self.liquidity == other_state.liquidity &&
553                self.sqrt_price == other_state.sqrt_price &&
554                self.fee == other_state.fee &&
555                self.tick == other_state.tick &&
556                self.ticks == other_state.ticks
557        } else {
558            false
559        }
560    }
561}
562
563#[cfg(test)]
564mod tests {
565    use std::{
566        collections::{HashMap, HashSet},
567        fs,
568        path::Path,
569        str::FromStr,
570    };
571
572    use num_bigint::ToBigUint;
573    use num_traits::FromPrimitive;
574    use serde_json::Value;
575    use tycho_client::feed::synchronizer::ComponentWithState;
576    use tycho_common::{hex_bytes::Bytes, models::Chain, simulation::protocol_sim::Price};
577
578    use super::*;
579    use crate::{
580        evm::protocol::{
581            uniswap_v3::enums::FeeAmount, utils::uniswap::sqrt_price_math::get_sqrt_price_q96,
582        },
583        protocol::models::{DecoderContext, TryFromWithBlock},
584    };
585
586    fn serde_test_state(fee: impl Into<FeeTier>) -> UniswapV3State {
587        UniswapV3State::new(
588            1_000_000,
589            U256::from(79228162514264337593543950336u128),
590            fee,
591            0,
592            vec![TickInfo::new(-600, 1_000_000).unwrap(), TickInfo::new(600, -1_000_000).unwrap()],
593        )
594        .unwrap()
595    }
596
597    #[test]
598    fn test_serialize_fee_amount_fee_as_variant_name() {
599        let state = serde_test_state(FeeAmount::Medium);
600
601        let json = serde_json::to_value(&state).unwrap();
602
603        assert_eq!(json["fee"], Value::from("Medium"));
604        assert_eq!(serde_json::from_value::<UniswapV3State>(json).unwrap(), state);
605    }
606
607    #[test]
608    fn test_serialize_fee_outside_fee_amount_as_number() {
609        let state = serde_test_state(FeeTier::new(50, 10).unwrap());
610
611        let json = serde_json::to_value(&state).unwrap();
612
613        assert_eq!(json["fee"], Value::from(50));
614        assert_eq!(serde_json::from_value::<UniswapV3State>(json).unwrap(), state);
615    }
616
617    #[test]
618    fn test_deserialize_fee_as_number_for_fee_amount_fee() {
619        let state = serde_test_state(FeeAmount::Medium);
620        let mut json = serde_json::to_value(&state).unwrap();
621        json["fee"] = Value::from(3000);
622
623        assert_eq!(serde_json::from_value::<UniswapV3State>(json).unwrap(), state);
624    }
625
626    #[test]
627    fn test_deserialize_rejects_unknown_fee_name() {
628        let mut json = serde_json::to_value(serde_test_state(FeeAmount::Medium)).unwrap();
629        json["fee"] = Value::from("Unknown");
630
631        assert!(serde_json::from_value::<UniswapV3State>(json).is_err());
632    }
633
634    #[test]
635    fn test_get_amount_out_full_range_liquidity() {
636        let token_x = Token::new(
637            &Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(),
638            "X",
639            18,
640            0,
641            &[Some(10_000)],
642            Chain::Ethereum,
643            100,
644        );
645        let token_y = Token::new(
646            &Bytes::from_str("0xf1ca9cb74685755965c7458528a36934df52a3ef").unwrap(),
647            "Y",
648            18,
649            0,
650            &[Some(10_000)],
651            Chain::Ethereum,
652            100,
653        );
654
655        let pool = UniswapV3State::new(
656            8330443394424070888454257,
657            U256::from_str("188562464004052255423565206602").unwrap(),
658            FeeAmount::Medium,
659            17342,
660            vec![TickInfo::new(0, 0).unwrap(), TickInfo::new(46080, 0).unwrap()],
661        )
662        .unwrap();
663        let sell_amount = BigUint::from_str("11_000_000000000000000000").unwrap();
664        let expected = BigUint::from_str("61927070842678722935941").unwrap();
665
666        let res = pool
667            .get_amount_out(sell_amount, &token_x, &token_y)
668            .unwrap();
669
670        assert_eq!(res.amount, expected);
671    }
672
673    struct SwapTestCase {
674        symbol: &'static str,
675        sell: BigUint,
676        exp: BigUint,
677    }
678
679    #[test]
680    fn test_get_amount_out() {
681        let wbtc = Token::new(
682            &Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap(),
683            "WBTC",
684            8,
685            0,
686            &[Some(10_000)],
687            Chain::Ethereum,
688            100,
689        );
690        let weth = Token::new(
691            &Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(),
692            "WETH",
693            18,
694            0,
695            &[Some(10_000)],
696            Chain::Ethereum,
697            100,
698        );
699        let pool = UniswapV3State::new(
700            377952820878029838,
701            U256::from_str("28437325270877025820973479874632004").unwrap(),
702            FeeAmount::Low,
703            255830,
704            vec![
705                TickInfo::new(255760, 1759015528199933i128).unwrap(),
706                TickInfo::new(255770, 6393138051835308i128).unwrap(),
707                TickInfo::new(255780, 228206673808681i128).unwrap(),
708                TickInfo::new(255820, 1319490609195820i128).unwrap(),
709                TickInfo::new(255830, 678916926147901i128).unwrap(),
710                TickInfo::new(255840, 12208947683433103i128).unwrap(),
711                TickInfo::new(255850, 1177970713095301i128).unwrap(),
712                TickInfo::new(255860, 8752304680520407i128).unwrap(),
713                TickInfo::new(255880, 1486478248067104i128).unwrap(),
714                TickInfo::new(255890, 1878744276123248i128).unwrap(),
715                TickInfo::new(255900, 77340284046725227i128).unwrap(),
716            ],
717        )
718        .unwrap();
719        let cases = vec![
720            SwapTestCase {
721                symbol: "WBTC",
722                sell: 500000000.to_biguint().unwrap(),
723                exp: BigUint::from_str("64352395915550406461").unwrap(),
724            },
725            SwapTestCase {
726                symbol: "WBTC",
727                sell: 550000000.to_biguint().unwrap(),
728                exp: BigUint::from_str("70784271504035662865").unwrap(),
729            },
730            SwapTestCase {
731                symbol: "WBTC",
732                sell: 600000000.to_biguint().unwrap(),
733                exp: BigUint::from_str("77215534856185613494").unwrap(),
734            },
735            SwapTestCase {
736                symbol: "WBTC",
737                sell: BigUint::from_str("1000000000").unwrap(),
738                exp: BigUint::from_str("128643569649663616249").unwrap(),
739            },
740            SwapTestCase {
741                symbol: "WBTC",
742                sell: BigUint::from_str("3000000000").unwrap(),
743                exp: BigUint::from_str("385196519076234662939").unwrap(),
744            },
745            SwapTestCase {
746                symbol: "WETH",
747                sell: BigUint::from_str("64000000000000000000").unwrap(),
748                exp: BigUint::from_str("496294784").unwrap(),
749            },
750            SwapTestCase {
751                symbol: "WETH",
752                sell: BigUint::from_str("70000000000000000000").unwrap(),
753                exp: BigUint::from_str("542798479").unwrap(),
754            },
755            SwapTestCase {
756                symbol: "WETH",
757                sell: BigUint::from_str("77000000000000000000").unwrap(),
758                exp: BigUint::from_str("597047757").unwrap(),
759            },
760            SwapTestCase {
761                symbol: "WETH",
762                sell: BigUint::from_str("128000000000000000000").unwrap(),
763                exp: BigUint::from_str("992129037").unwrap(),
764            },
765            SwapTestCase {
766                symbol: "WETH",
767                sell: BigUint::from_str("385000000000000000000").unwrap(),
768                exp: BigUint::from_str("2978713582").unwrap(),
769            },
770        ];
771
772        for case in cases {
773            let (token_a, token_b) =
774                if case.symbol == "WBTC" { (&wbtc, &weth) } else { (&weth, &wbtc) };
775            let res = pool
776                .get_amount_out(case.sell, token_a, token_b)
777                .unwrap();
778
779            assert_eq!(res.amount, case.exp);
780        }
781    }
782
783    #[test]
784    fn test_err_with_partial_trade() {
785        let dai = Token::new(
786            &Bytes::from_str("0x6b175474e89094c44da98b954eedeac495271d0f").unwrap(),
787            "DAI",
788            18,
789            0,
790            &[Some(10_000)],
791            Chain::Ethereum,
792            100,
793        );
794        let usdc = Token::new(
795            &Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(),
796            "USDC",
797            6,
798            0,
799            &[Some(10_000)],
800            Chain::Ethereum,
801            100,
802        );
803        let pool = UniswapV3State::new(
804            73015811375239994,
805            U256::from_str("148273042406850898575413").unwrap(),
806            FeeAmount::High,
807            -263789,
808            vec![
809                TickInfo::new(-269600, 3612326326695492i128).unwrap(),
810                TickInfo::new(-268800, 1487613939516867i128).unwrap(),
811                TickInfo::new(-267800, 1557587121322546i128).unwrap(),
812                TickInfo::new(-267400, 424592076717375i128).unwrap(),
813                TickInfo::new(-267200, 11691597431643916i128).unwrap(),
814                TickInfo::new(-266800, -218742815100986i128).unwrap(),
815                TickInfo::new(-266600, 1118947532495477i128).unwrap(),
816                TickInfo::new(-266200, 1233064286622365i128).unwrap(),
817                TickInfo::new(-265000, 4252603063356107i128).unwrap(),
818                TickInfo::new(-263200, -351282010325232i128).unwrap(),
819                TickInfo::new(-262800, -2352011819117842i128).unwrap(),
820                TickInfo::new(-262600, -424592076717375i128).unwrap(),
821                TickInfo::new(-262200, -11923662433672566i128).unwrap(),
822                TickInfo::new(-261600, -2432911749667741i128).unwrap(),
823                TickInfo::new(-260200, -4032727022572273i128).unwrap(),
824                TickInfo::new(-260000, -22889492064625028i128).unwrap(),
825                TickInfo::new(-259400, -1557587121322546i128).unwrap(),
826                TickInfo::new(-259200, -1487613939516867i128).unwrap(),
827                TickInfo::new(-258400, -400137022888262i128).unwrap(),
828            ],
829        )
830        .unwrap();
831        let amount_in = BigUint::from_str("50000000000").unwrap();
832        let exp = BigUint::from_str("6820591625999718100883").unwrap();
833
834        let err = pool
835            .get_amount_out(amount_in, &usdc, &dai)
836            .unwrap_err();
837
838        match err {
839            SimulationError::InvalidInput(ref _err, ref amount_out_result) => {
840                match amount_out_result {
841                    Some(amount_out_result) => {
842                        assert_eq!(amount_out_result.amount, exp);
843                        let new_state = amount_out_result
844                            .new_state
845                            .as_any()
846                            .downcast_ref::<UniswapV3State>()
847                            .unwrap();
848                        assert_ne!(new_state.tick, pool.tick);
849                        assert_ne!(new_state.liquidity, pool.liquidity);
850                    }
851                    _ => panic!("Partial amount out result is None. Expected partial result."),
852                }
853            }
854            _ => panic!("Test failed: was expecting a SimulationError::InsufficientData"),
855        }
856    }
857
858    #[test]
859    fn test_delta_transition() {
860        let mut pool = UniswapV3State::new(
861            1000,
862            U256::from_str("1000").unwrap(),
863            FeeAmount::Low,
864            100,
865            vec![TickInfo::new(255760, 10000).unwrap(), TickInfo::new(255900, -10000).unwrap()],
866        )
867        .unwrap();
868        let attributes: HashMap<String, Bytes> = [
869            ("liquidity".to_string(), Bytes::from(2000_u64.to_be_bytes().to_vec())),
870            ("sqrt_price_x96".to_string(), Bytes::from(1001_u64.to_be_bytes().to_vec())),
871            ("tick".to_string(), Bytes::from(120_i32.to_be_bytes().to_vec())),
872            (
873                "ticks/-255760/net_liquidity".to_string(),
874                Bytes::from(10200_u64.to_be_bytes().to_vec()),
875            ),
876            (
877                "ticks/255900/net_liquidity".to_string(),
878                Bytes::from(9800_u64.to_be_bytes().to_vec()),
879            ),
880        ]
881        .into_iter()
882        .collect();
883        let delta = ProtocolStateDelta {
884            component_id: "State1".to_owned(),
885            updated_attributes: attributes,
886            deleted_attributes: HashSet::new(),
887        };
888
889        pool.delta_transition(delta, &HashMap::new(), &Balances::default())
890            .unwrap();
891
892        assert_eq!(pool.liquidity, 2000);
893        assert_eq!(pool.sqrt_price, U256::from(1001));
894        assert_eq!(pool.tick, 120);
895        assert_eq!(
896            pool.ticks
897                .get_tick(-255760)
898                .unwrap()
899                .net_liquidity,
900            10200
901        );
902        assert_eq!(
903            pool.ticks
904                .get_tick(255900)
905                .unwrap()
906                .net_liquidity,
907            9800
908        );
909    }
910
911    #[tokio::test]
912    async fn test_get_limits() {
913        use tycho_client::feed::dto;
914        let project_root = env!("CARGO_MANIFEST_DIR");
915        let asset_path =
916            Path::new(project_root).join("tests/assets/decoder/uniswap_v3_snapshot.json");
917        let json_data = fs::read_to_string(asset_path).expect("Failed to read test asset");
918        let data: Value = serde_json::from_str(&json_data).expect("Failed to parse JSON");
919        let state: ComponentWithState = serde_json::from_value::<dto::ComponentWithState>(data)
920            .expect("Expected json to match ComponentWithState structure")
921            .into();
922
923        let usv3_state = UniswapV3State::try_from_with_header(
924            state,
925            Default::default(),
926            &Default::default(),
927            &Default::default(),
928            &DecoderContext::new(),
929        )
930        .await
931        .unwrap();
932
933        let t0 = Token::new(
934            &Bytes::from_str("0x2260fac5e5542a773aa44fbcfedf7c193bc2c599").unwrap(),
935            "WBTC",
936            8,
937            0,
938            &[Some(10_000)],
939            Chain::Ethereum,
940            100,
941        );
942        let t1 = Token::new(
943            &Bytes::from_str("0xcbb7c0000ab88b473b1f5afd9ef808440eed33bf").unwrap(),
944            "cbBTC",
945            8,
946            0,
947            &[Some(10_000)],
948            Chain::Ethereum,
949            100,
950        );
951
952        let res = usv3_state
953            .get_limits(t0.address.clone(), t1.address.clone())
954            .unwrap();
955
956        assert_eq!(&res.0, &BigUint::from_u128(29160572556).unwrap());
957
958        let out = usv3_state
959            .get_amount_out(res.0, &t0, &t1)
960            .expect("swap for limit in didn't work");
961
962        // Allow 1-unit rounding difference: get_limits uses ceiling/floor delta math
963        // while get_amount_out uses the full swap path.
964        let diff = if res.1 > out.amount {
965            res.1.clone() - out.amount.clone()
966        } else {
967            out.amount.clone() - res.1.clone()
968        };
969        assert!(diff <= BigUint::from(1u64), "limit_out and amount_out differ by {diff}");
970    }
971
972    // Helper to create a basic test pool
973    fn create_basic_test_pool() -> UniswapV3State {
974        let liquidity = 100_000_000_000_000_000_000u128; // 100e18
975        let sqrt_price = get_sqrt_price_q96(U256::from(20_000_000u64), U256::from(10_000_000u64))
976            .expect("Failed to calculate sqrt price");
977        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("Failed to calculate tick");
978
979        let ticks = vec![TickInfo::new(0, 0).unwrap(), TickInfo::new(46080, 0).unwrap()];
980
981        UniswapV3State::new(liquidity, sqrt_price, FeeAmount::Low, tick, ticks)
982            .expect("Failed to create pool")
983    }
984
985    fn create_tick_boundary_test_pool() -> UniswapV3State {
986        let sqrt_price = get_sqrt_ratio_at_tick(0).expect("Failed to calculate sqrt price");
987        let ticks = vec![TickInfo::new(-120, 0).unwrap(), TickInfo::new(120, 0).unwrap()];
988
989        UniswapV3State::new(100_000_000_000_000_000_000u128, sqrt_price, FeeAmount::Low, 0, ticks)
990            .expect("Failed to create pool")
991    }
992
993    #[test]
994    fn test_partial_step_updates_tick_when_price_moves_without_crossing_initialized_tick() {
995        let pool = create_tick_boundary_test_pool();
996        let amount =
997            I256::checked_from_sign_and_abs(Sign::Positive, U256::from(100_000_000_000_000_000u64))
998                .unwrap();
999
1000        let result = pool
1001            .swap(true, amount, None)
1002            .expect("swap should stay within the current liquidity range");
1003        let expected_tick =
1004            get_tick_at_sqrt_ratio(result.sqrt_price).expect("new sqrt price should map to a tick");
1005
1006        assert_ne!(result.sqrt_price, pool.sqrt_price);
1007        assert_ne!(result.sqrt_price, get_sqrt_ratio_at_tick(-120).unwrap());
1008        assert_ne!(expected_tick, pool.tick);
1009        assert_eq!(result.tick, expected_tick);
1010    }
1011
1012    #[test]
1013    fn test_swap_keeps_boundary_tick_when_price_does_not_move() {
1014        let mut pool = create_tick_boundary_test_pool();
1015        pool.tick = -1;
1016        let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1u64)).unwrap();
1017
1018        let result = pool
1019            .swap(true, amount, None)
1020            .expect("swap should consume the input as fee without moving price");
1021
1022        assert_eq!(result.sqrt_price, pool.sqrt_price);
1023        assert_eq!(get_tick_at_sqrt_ratio(result.sqrt_price).unwrap(), 0);
1024        assert_eq!(result.tick, pool.tick);
1025    }
1026
1027    #[test]
1028    fn test_swap_basic() {
1029        let pool = create_basic_test_pool();
1030
1031        // Test selling token X for token Y
1032        let amount_in =
1033            I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1000000u64)).unwrap();
1034        let result = pool
1035            .swap(true, amount_in, None)
1036            .unwrap();
1037
1038        // At current pool price, we should get a little less than 2 times the amount of X
1039        let expected_amount = U256::from(2000000u64);
1040        let actual_amount = result
1041            .amount_calculated
1042            .abs()
1043            .into_raw();
1044        assert_eq!(expected_amount - actual_amount, U256::from(1001u64));
1045        println!("Swap X->Y: amount_in={}, amount_out={}", amount_in, actual_amount);
1046    }
1047
1048    #[test]
1049    fn test_swap_to_price_basic() {
1050        // Create pool with Medium fee (0.3%) to match V4's basic test
1051        let liquidity = 100_000_000_000_000_000_000u128;
1052        let sqrt_price = get_sqrt_price_q96(U256::from(20_000_000u64), U256::from(10_000_000u64))
1053            .expect("Failed to calculate sqrt price");
1054        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("Failed to calculate tick");
1055        let ticks = vec![TickInfo::new(0, 0).unwrap(), TickInfo::new(46080, 0).unwrap()];
1056
1057        let pool = UniswapV3State::new(liquidity, sqrt_price, FeeAmount::Medium, tick, ticks)
1058            .expect("Failed to create pool");
1059
1060        // Token X has lower address (0x01), Y has higher address (0x02)
1061        let token_x = Token::new(
1062            &Bytes::from_str("0x0000000000000000000000000000000000000001").unwrap(),
1063            "X",
1064            18,
1065            0,
1066            &[Some(10_000)],
1067            Chain::Ethereum,
1068            100,
1069        );
1070        let token_y = Token::new(
1071            &Bytes::from_str("0x0000000000000000000000000000000000000002").unwrap(),
1072            "Y",
1073            18,
1074            0,
1075            &[Some(10_000)],
1076            Chain::Ethereum,
1077            100,
1078        );
1079
1080        // Swap price: buying X for Y (token_out/token_in)
1081        let target_price =
1082            Price::new(2_000_000u64.to_biguint().unwrap(), 1_010_000u64.to_biguint().unwrap());
1083
1084        // Query how much Y the pool can supply when buying X at this price
1085        let trade = pool
1086            .query_pool_swap(&QueryPoolSwapParams::new(
1087                token_x,
1088                token_y,
1089                SwapConstraint::PoolTargetPrice {
1090                    target: target_price,
1091                    tolerance: 0f64,
1092                    min_amount_in: None,
1093                    max_amount_in: None,
1094                },
1095            ))
1096            .expect("swap_to_price failed");
1097
1098        // Should match V4's output exactly with same fees (0.3%)
1099        let expected_amount_in =
1100            BigUint::from_str("246739021727519745").expect("Failed to parse expected amount_in");
1101        let expected_amount_out =
1102            BigUint::from_str("490291909043340795").expect("Failed to parse expected amount_out");
1103
1104        assert_eq!(
1105            trade.amount_in().clone(),
1106            expected_amount_in,
1107            "amount_in should match expected value"
1108        );
1109        assert_eq!(
1110            trade.amount_out().clone(),
1111            expected_amount_out,
1112            "amount_out should match expected value"
1113        );
1114    }
1115
1116    #[test]
1117    fn test_swap_to_price_price_too_high() {
1118        let pool = create_basic_test_pool();
1119
1120        let token_x = Token::new(
1121            &Bytes::from_str("0x0000000000000000000000000000000000000001").unwrap(),
1122            "X",
1123            18,
1124            0,
1125            &[Some(10_000)],
1126            Chain::Ethereum,
1127            100,
1128        );
1129        let token_y = Token::new(
1130            &Bytes::from_str("0x0000000000000000000000000000000000000002").unwrap(),
1131            "Y",
1132            18,
1133            0,
1134            &[Some(10_000)],
1135            Chain::Ethereum,
1136            100,
1137        );
1138
1139        // Price far above pool price - should return zero
1140        let target_price =
1141            Price::new(10_000_000u64.to_biguint().unwrap(), 1_000_000u64.to_biguint().unwrap());
1142
1143        let result = pool.query_pool_swap(&QueryPoolSwapParams::new(
1144            token_x,
1145            token_y,
1146            SwapConstraint::PoolTargetPrice {
1147                target: target_price,
1148                tolerance: 0f64,
1149                min_amount_in: None,
1150                max_amount_in: None,
1151            },
1152        ));
1153        assert!(result.is_err(), "Should return error when target price is unreachable");
1154    }
1155
1156    #[test]
1157    fn test_swap_parameterized() {
1158        // Parameterized swap tests with real WBTC/WETH pool data
1159        let liquidity = 377_952_820_878_029_838u128;
1160        let sqrt_price = U256::from_str("28437325270877025820973479874632004")
1161            .expect("Failed to parse sqrt_price");
1162        let tick = 255830;
1163
1164        let ticks = vec![
1165            TickInfo::new(255760, 1_759_015_528_199_933).unwrap(),
1166            TickInfo::new(255770, 6_393_138_051_835_308).unwrap(),
1167            TickInfo::new(255780, 228_206_673_808_681).unwrap(),
1168            TickInfo::new(255820, 1_319_490_609_195_820).unwrap(),
1169            TickInfo::new(255830, 678_916_926_147_901).unwrap(),
1170            TickInfo::new(255840, 12_208_947_683_433_103).unwrap(),
1171            TickInfo::new(255850, 1_177_970_713_095_301).unwrap(),
1172            TickInfo::new(255860, 8_752_304_680_520_407).unwrap(),
1173            TickInfo::new(255880, 1_486_478_248_067_104).unwrap(),
1174            TickInfo::new(255890, 1_878_744_276_123_248).unwrap(),
1175            TickInfo::new(255900, 77_340_284_046_725_227).unwrap(),
1176        ];
1177
1178        let pool = UniswapV3State::new(liquidity, sqrt_price, FeeAmount::Low, tick, ticks)
1179            .expect("Failed to create pool");
1180
1181        // Test cases: (zero_for_one, amount_in, expected_amount_out, test_id)
1182        // WBTC address (0x2260...) < WETH address (0xC02a...), so WBTC is token0
1183        let test_cases = vec![
1184            // WBTC to WETH cases (zero_for_one = true)
1185            (true, "500000000", "64352395915550406461", "WBTC->WETH 500000000"),
1186            (true, "550000000", "70784271504035662865", "WBTC->WETH 550000000"),
1187            (true, "600000000", "77215534856185613494", "WBTC->WETH 600000000"),
1188            (true, "1000000000", "128643569649663616249", "WBTC->WETH 1000000000"),
1189            (true, "3000000000", "385196519076234662939", "WBTC->WETH 3000000000"),
1190            // WETH to WBTC cases (zero_for_one = false)
1191            (false, "64000000000000000000", "496294784", "WETH->WBTC 64 ETH"),
1192            (false, "70000000000000000000", "542798479", "WETH->WBTC 70 ETH"),
1193            (false, "77000000000000000000", "597047757", "WETH->WBTC 77 ETH"),
1194            (false, "128000000000000000000", "992129037", "WETH->WBTC 128 ETH"),
1195            (false, "385000000000000000000", "2978713582", "WETH->WBTC 385 ETH"),
1196        ];
1197
1198        for (zero_for_one, amount_in_str, expected_amount_out_str, test_id) in test_cases {
1199            let amount_in = U256::from_str(amount_in_str).expect("Failed to parse amount_in");
1200            let amount_specified = I256::checked_from_sign_and_abs(Sign::Positive, amount_in)
1201                .unwrap_or_else(|| panic!("{} - Failed to convert amount to I256", test_id));
1202
1203            let result = pool
1204                .swap(zero_for_one, amount_specified, None)
1205                .unwrap_or_else(|e| panic!("{} - swap failed: {:?}", test_id, e));
1206
1207            let amount_out = result
1208                .amount_calculated
1209                .abs()
1210                .into_raw();
1211            let expected = U256::from_str(expected_amount_out_str)
1212                .expect("Failed to parse expected_amount_out");
1213
1214            assert_eq!(amount_out, expected, "{}", test_id);
1215        }
1216    }
1217
1218    #[test]
1219    fn test_swap_to_price_parameterized() {
1220        // Tests query_supply with various price points
1221        let wbtc = Token::new(
1222            &Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap(),
1223            "WBTC",
1224            8,
1225            0,
1226            &[Some(10_000)],
1227            Chain::Ethereum,
1228            100,
1229        );
1230        let weth = Token::new(
1231            &Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(),
1232            "WETH",
1233            18,
1234            0,
1235            &[Some(10_000)],
1236            Chain::Ethereum,
1237            100,
1238        );
1239
1240        let liquidity = 377_952_820_878_029_838u128;
1241        let sqrt_price = get_sqrt_price_q96(U256::from(130_000_000u64), U256::from(10_000_000u64))
1242            .expect("Failed to calculate sqrt price");
1243        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("Failed to calculate tick");
1244
1245        let ticks = vec![
1246            TickInfo::new(25560, 1759015528199933).unwrap(),
1247            TickInfo::new(25570, 6393138051835308).unwrap(),
1248            TickInfo::new(25580, 228206673808681).unwrap(),
1249            TickInfo::new(25620, 1319490609195820).unwrap(),
1250            TickInfo::new(25630, 678916926147901).unwrap(),
1251            TickInfo::new(25640, 12208947683433103).unwrap(),
1252            TickInfo::new(25660, 8752304680520407).unwrap(),
1253            TickInfo::new(25680, 1486478248067104).unwrap(),
1254            TickInfo::new(25690, 1878744276123248).unwrap(),
1255            TickInfo::new(25700, 77340284046725227).unwrap(),
1256        ];
1257
1258        let pool = UniswapV3State::new(liquidity, sqrt_price, FeeAmount::Low, tick, ticks)
1259            .expect("Failed to create pool");
1260
1261        // Test cases: (sell_token, sell_price, buy_price, expected_supply, test_id)
1262        let test_cases = vec![
1263            (&wbtc, 129u64, 10u64, "0", "WBTC sell_price=129, buy_price=10"),
1264            (&wbtc, 130u64, 10u64, "0", "WBTC sell_price=130, buy_price=10"),
1265            (&wbtc, 1305u64, 100u64, "163535995630461", "WBTC sell_price=1305, buy_price=100"),
1266            (&weth, 99u64, 1300u64, "0", "WETH sell_price=99, buy_price=1300"),
1267            (&weth, 100u64, 1300u64, "0", "WETH sell_price=100, buy_price=1300"),
1268            (&weth, 101u64, 1299u64, "524227092059180", "WETH sell_price=101, buy_price=1299"),
1269        ];
1270
1271        for (sell_token, sell_price, buy_price, expected_str, test_id) in test_cases {
1272            let buy_token = if sell_token == &wbtc { &weth } else { &wbtc };
1273
1274            let target_price =
1275                Price::new(buy_price.to_biguint().unwrap(), sell_price.to_biguint().unwrap());
1276
1277            if expected_str == "0" {
1278                let result = pool.query_pool_swap(&QueryPoolSwapParams::new(
1279                    buy_token.clone(),
1280                    sell_token.clone(),
1281                    SwapConstraint::PoolTargetPrice {
1282                        target: target_price,
1283                        tolerance: 0f64,
1284                        min_amount_in: None,
1285                        max_amount_in: None,
1286                    },
1287                ));
1288                assert!(result.is_err(), "Should return error when target price is unreachable");
1289            } else {
1290                let expected =
1291                    BigUint::from_str(expected_str).expect("Failed to parse expected value");
1292
1293                let trade = pool
1294                    .query_pool_swap(&QueryPoolSwapParams::new(
1295                        buy_token.clone(),
1296                        sell_token.clone(),
1297                        SwapConstraint::PoolTargetPrice {
1298                            target: target_price,
1299                            tolerance: 0f64,
1300                            min_amount_in: None,
1301                            max_amount_in: None,
1302                        },
1303                    ))
1304                    .unwrap_or_else(|e| panic!("{} - query_supply failed: {:?}", test_id, e));
1305                assert_eq!(trade.amount_out().clone(), expected, "{}", test_id);
1306            }
1307        }
1308    }
1309
1310    #[test]
1311    fn test_swap_to_price_around_spot_price() {
1312        // Tests query_supply edge cases around the spot price with fees
1313        let liquidity = 10_000_000_000_000_000u128;
1314        let sqrt_price =
1315            get_sqrt_price_q96(U256::from(2_000_000_000u64), U256::from(1_000_000_000u64))
1316                .expect("Failed to calculate sqrt price");
1317        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("Failed to calculate tick");
1318
1319        let ticks = vec![TickInfo::new(0, 0).unwrap(), TickInfo::new(46080, 0).unwrap()];
1320
1321        let pool = UniswapV3State::new(liquidity, sqrt_price, FeeAmount::Low, tick, ticks)
1322            .expect("Failed to create pool");
1323
1324        let token_x = Token::new(
1325            &Bytes::from_str("0x0000000000000000000000000000000000000001").unwrap(),
1326            "X",
1327            18,
1328            0,
1329            &[Some(10_000)],
1330            Chain::Ethereum,
1331            100,
1332        );
1333        let token_y = Token::new(
1334            &Bytes::from_str("0x0000000000000000000000000000000000000002").unwrap(),
1335            "Y",
1336            18,
1337            0,
1338            &[Some(10_000)],
1339            Chain::Ethereum,
1340            100,
1341        );
1342
1343        // Test 1: Price just above spot price, too little to cover fees
1344        let target_price =
1345            Price::new(1_999_750u64.to_biguint().unwrap(), 1_000_250u64.to_biguint().unwrap());
1346
1347        let result = pool.query_pool_swap(&QueryPoolSwapParams::new(
1348            token_x.clone(),
1349            token_y.clone(),
1350            SwapConstraint::PoolTargetPrice {
1351                target: target_price,
1352                tolerance: 0f64,
1353                min_amount_in: None,
1354                max_amount_in: None,
1355            },
1356        ));
1357        assert!(result.is_err(), "Should return error when target price is unreachable");
1358
1359        // Test 2: Price high enough to cover fees (0.1% higher)
1360        let target_price =
1361            Price::new(1_999_000u64.to_biguint().unwrap(), 1_001_000u64.to_biguint().unwrap());
1362
1363        let pool_swap = pool
1364            .query_pool_swap(&QueryPoolSwapParams::new(
1365                token_x,
1366                token_y,
1367                SwapConstraint::PoolTargetPrice {
1368                    target: target_price,
1369                    tolerance: 0f64,
1370                    min_amount_in: None,
1371                    max_amount_in: None,
1372                },
1373            ))
1374            .expect("swap_to_price failed");
1375
1376        let expected_amount_out =
1377            BigUint::from_str("7062236922008").expect("Failed to parse expected value");
1378        assert_eq!(
1379            pool_swap.amount_out().clone(),
1380            expected_amount_out,
1381            "Expected amount out when price covers fees"
1382        );
1383    }
1384
1385    #[test]
1386    fn test_swap_to_price_matches_get_amount_out() {
1387        let liquidity = 100_000_000_000_000_000_000u128;
1388        let sqrt_price = get_sqrt_price_q96(U256::from(20_000_000u64), U256::from(10_000_000u64))
1389            .expect("Failed to calculate sqrt price");
1390        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("Failed to calculate tick");
1391
1392        let ticks = vec![TickInfo::new(0, 0).unwrap(), TickInfo::new(46080, 0).unwrap()];
1393
1394        let pool = UniswapV3State::new(liquidity, sqrt_price, FeeAmount::Medium, tick, ticks)
1395            .expect("Failed to create pool");
1396
1397        let token_x_addr = Bytes::from_str("0x0000000000000000000000000000000000000001").unwrap();
1398        let token_y_addr = Bytes::from_str("0x0000000000000000000000000000000000000002").unwrap();
1399
1400        let token_x = Token::new(&token_x_addr, "X", 18, 0, &[], Chain::Ethereum, 1);
1401        let token_y = Token::new(&token_y_addr, "Y", 18, 0, &[], Chain::Ethereum, 1);
1402
1403        // Get the trade from swap_to_price
1404        let target_price = Price::new(BigUint::from(2_000_000u64), BigUint::from(1_010_000u64));
1405        let pool_swap = pool
1406            .query_pool_swap(&QueryPoolSwapParams::new(
1407                token_x.clone(),
1408                token_y.clone(),
1409                SwapConstraint::PoolTargetPrice {
1410                    target: target_price,
1411                    tolerance: 0f64,
1412                    min_amount_in: None,
1413                    max_amount_in: None,
1414                },
1415            ))
1416            .expect("swap_to_price failed");
1417        assert!(pool_swap.amount_in().clone() > BigUint::ZERO, "Amount in should be positive");
1418
1419        // Use the amount_in from swap_to_price with get_amount_out
1420        let result = pool
1421            .get_amount_out(pool_swap.amount_in().clone(), &token_x, &token_y)
1422            .expect("get_amount_out failed");
1423
1424        // The amount_out from get_amount_out should be close to swap_to_price's amount_out
1425        // Allow for small rounding differences
1426        assert!(result.amount > BigUint::ZERO);
1427        assert!(result.amount >= *pool_swap.amount_out());
1428    }
1429
1430    #[test]
1431    fn test_swap_price_limit_out_of_range_returns_error() {
1432        let pool = create_basic_test_pool();
1433        let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1000u64)).unwrap();
1434
1435        // zero_for_one: price_limit equal to sqrt_price is invalid (must be strictly less)
1436        let result = pool.swap(true, amount, Some(pool.sqrt_price));
1437        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
1438
1439        // zero_for_one: price_limit at MIN_SQRT_RATIO is invalid (must be strictly greater)
1440        let result = pool.swap(true, amount, Some(MIN_SQRT_RATIO));
1441        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
1442
1443        // one_for_zero: price_limit equal to sqrt_price is invalid (must be strictly greater)
1444        let result = pool.swap(false, amount, Some(pool.sqrt_price));
1445        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
1446
1447        // one_for_zero: price_limit at MAX_SQRT_RATIO is invalid (must be strictly less)
1448        let result = pool.swap(false, amount, Some(MAX_SQRT_RATIO));
1449        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
1450    }
1451
1452    #[test]
1453    fn test_swap_at_extreme_price_returns_error() {
1454        // Simulates the depth calculation scenario: pool sqrt_price is at MIN_SQRT_RATIO + 1,
1455        // so the default price limit for zero_for_one equals sqrt_price and fails validation.
1456        let sqrt_price = MIN_SQRT_RATIO + U256::from(1u64);
1457        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("Failed to calculate tick");
1458        // FeeAmount::Low has tick spacing 10; ticks must be aligned
1459        let aligned_tick = (MIN_TICK / 10) * 10 + 10; // first multiple of 10 above MIN_TICK
1460        let ticks = vec![
1461            TickInfo::new(aligned_tick, 0).unwrap(),
1462            TickInfo::new(aligned_tick + 10, 0).unwrap(),
1463        ];
1464        let pool = UniswapV3State::new(
1465            100_000_000_000_000_000_000u128,
1466            sqrt_price,
1467            FeeAmount::Low,
1468            tick,
1469            ticks,
1470        )
1471        .unwrap();
1472
1473        let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1000u64)).unwrap();
1474        // Default price limit for zero_for_one is MIN_SQRT_RATIO + 1 == sqrt_price, so invalid
1475        let result = pool.swap(true, amount, None);
1476        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
1477    }
1478}
1479
1480#[cfg(test)]
1481mod tests_forks {
1482    use std::str::FromStr;
1483
1484    use tycho_client::feed::synchronizer::ComponentWithState;
1485    use tycho_common::{hex_bytes::Bytes, models::Chain};
1486
1487    use super::*;
1488    use crate::{
1489        evm::protocol::uniswap_v3::enums::FeeAmount,
1490        protocol::models::{DecoderContext, TryFromWithBlock},
1491    };
1492
1493    #[tokio::test]
1494    async fn test_pancakeswap_get_amount_out() {
1495        use std::{fs, path::Path};
1496
1497        use serde_json::Value;
1498        use tycho_client::feed::dto;
1499        let project_root = env!("CARGO_MANIFEST_DIR");
1500        let asset_path =
1501            Path::new(project_root).join("tests/assets/decoder/pancakeswap_v3_snapshot.json");
1502        let json_data = fs::read_to_string(asset_path).expect("Failed to read test asset");
1503        let data: Value = serde_json::from_str(&json_data).expect("Failed to parse JSON");
1504        let state: ComponentWithState = serde_json::from_value::<dto::ComponentWithState>(data)
1505            .expect("Expected json to match ComponentWithState structure")
1506            .into();
1507
1508        let pool_state = UniswapV3State::try_from_with_header(
1509            state,
1510            Default::default(),
1511            &Default::default(),
1512            &Default::default(),
1513            &DecoderContext::new(),
1514        )
1515        .await
1516        .unwrap();
1517
1518        let usdc = Token::new(
1519            &Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(),
1520            "USDC",
1521            6,
1522            0,
1523            &[Some(10_000)],
1524            Chain::Ethereum,
1525            100,
1526        );
1527        let usdt = Token::new(
1528            &Bytes::from_str("0xdac17f958d2ee523a2206206994597c13d831ec7").unwrap(),
1529            "USDT",
1530            6,
1531            0,
1532            &[Some(10_000)],
1533            Chain::Ethereum,
1534            100,
1535        );
1536
1537        let res = pool_state
1538            .get_amount_out(BigUint::from_str("5976361609").unwrap(), &usdt, &usdc)
1539            .unwrap();
1540
1541        assert_eq!(res.amount, BigUint::from_str("5975901673").unwrap());
1542    }
1543
1544    #[test]
1545    fn test_get_limits_graceful_underflow() {
1546        // Verifies graceful handling of liquidity underflow in get_limits for V3
1547        let pool = UniswapV3State::new(
1548            1000000,
1549            U256::from_str("79228162514264337593543950336").unwrap(),
1550            FeeAmount::Medium,
1551            0,
1552            vec![
1553                // A tick with net_liquidity > current_liquidity
1554                // When zero_for_one=true, this gets negated and would cause underflow
1555                TickInfo::new(-60, 2000000).unwrap(), // 2x current liquidity
1556            ],
1557        )
1558        .unwrap();
1559
1560        let usdc = Token::new(
1561            &Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(),
1562            "USDC",
1563            6,
1564            0,
1565            &[Some(10_000)],
1566            Chain::Ethereum,
1567            100,
1568        );
1569        let weth = Token::new(
1570            &Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap(),
1571            "WETH",
1572            18,
1573            0,
1574            &[Some(10_000)],
1575            Chain::Ethereum,
1576            100,
1577        );
1578
1579        let (limit_in, limit_out) = pool
1580            .get_limits(usdc.address.clone(), weth.address.clone())
1581            .unwrap();
1582
1583        // Should return some conservative limits
1584        assert!(limit_in > BigUint::zero());
1585        assert!(limit_out > BigUint::zero());
1586    }
1587}