Skip to main content

tycho_simulation/evm/protocol/uniswap_v3/
state.rs

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