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