Skip to main content

tycho_simulation/evm/protocol/velodrome_slipstreams/
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, Price, ProtocolSim, QueryPoolSwapParams,
15            SwapConstraint,
16        },
17    },
18    Bytes,
19};
20
21use crate::evm::{
22    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            slipstreams::{dynamic_fee_module::ZERO_FEE_INDICATOR, raw_target_price},
28            uniswap::{
29                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,
37                    MIN_TICK,
38                },
39                StepComputation, SwapResults, SwapState,
40            },
41        },
42    },
43    query_pool_swap::price_to_f64_with_decimals,
44};
45
46// The names of the constants reflect the exact method from the tenderly log.
47const GAS_PER_TICK: u64 = 25_000;
48// nextInitializedTickWithinOneWord +  computeSwapStep + calculateFees
49const GAS_PER_LOOP: u64 = 10_000;
50
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
52pub struct VelodromeSlipstreamsState {
53    liquidity: u128,
54    sqrt_price: U256,
55    default_fee: u32,
56    custom_fee: u32,
57    tick_spacing: i32,
58    tick: PoolTick,
59    /// The tick list, shared by this state, its clones and the states its swaps return, so a quote
60    /// does not copy it. Change it only through `Arc::make_mut`, which copies the list while
61    /// another state shares it.
62    ticks: Arc<TickList>,
63    /// Swap steps already taken from this state, shared by its clones. The steps hold only for
64    /// this state's price, tick, liquidity, fee and ticks. `delta_transition` and
65    /// `clone_post_swap` replace the cache with an empty one, and any other code that changes
66    /// those fields must do the same.
67    #[serde(skip)]
68    step_cache: SwapStepCache,
69}
70
71impl VelodromeSlipstreamsState {
72    /// Creates a new instance of `AerodromeSlipstreamsState`.
73    ///
74    /// # Arguments
75    /// - `liquidity`: The initial liquidity of the pool.
76    /// - `sqrt_price`: The square root of the current price.
77    /// - `default_fee`: The default fee for the pool.
78    /// - `custom_fee`: The custom fee for the pool.
79    /// - `tick_spacing`: The tick spacing for the pool.
80    /// - `tick`: The current tick of the pool.
81    /// - `ticks`: A vector of `TickInfo` representing the tick information for the pool.
82    #[allow(clippy::too_many_arguments)]
83    pub fn new(
84        liquidity: u128,
85        sqrt_price: U256,
86        default_fee: u32,
87        custom_fee: u32,
88        tick_spacing: i32,
89        tick: i32,
90        ticks: Vec<TickInfo>,
91    ) -> Result<Self, SimulationError> {
92        let tick_list = TickList::from(tick_spacing as u16, ticks)?;
93        Ok(VelodromeSlipstreamsState {
94            liquidity,
95            sqrt_price,
96            default_fee,
97            custom_fee,
98            tick_spacing,
99            tick: tick.into(),
100            ticks: Arc::new(tick_list),
101            step_cache: SwapStepCache::default(),
102        })
103    }
104
105    /// Returns the swap fee in pips. A custom fee of zero means that none is set, and
106    /// [`ZERO_FEE_INDICATOR`] is the value the fee module stores for an explicit zero fee.
107    fn get_fee(&self) -> u32 {
108        match self.custom_fee {
109            0 => self.default_fee,
110            ZERO_FEE_INDICATOR => 0,
111            custom_fee => custom_fee,
112        }
113    }
114
115    /// Swaps token_in until `spot_price(token_in, token_out)` reaches the middle of the band
116    /// `[target, target * (1 + tolerance)]`. Falls back to the numerical search on errors, and
117    /// when the native swap stops outside the band.
118    fn swap_to_target_price(
119        &self,
120        params: &QueryPoolSwapParams,
121        target: &Price,
122        tolerance: f64,
123    ) -> Result<PoolSwap, SimulationError> {
124        let token_in = params.token_in();
125        let token_out = params.token_out();
126        let target_f64 = price_to_f64_with_decimals(target, token_in.decimals, token_out.decimals)?;
127        if target_f64 == self.spot_price(token_in, token_out)? {
128            return Ok(PoolSwap::new(BigUint::ZERO, BigUint::ZERO, Box::new(self.clone()), None));
129        }
130        // `spot_price` has no fee markup, so the limit and the swap both use fee 0.
131        let Some(limit) = raw_target_price(target, tolerance, 0) else {
132            return crate::evm::query_pool_swap::query_pool_swap(self, params);
133        };
134        let Ok((amount_in, amount_out, result)) = clmm_swap_to_price(
135            self.sqrt_price,
136            &token_in.address,
137            &token_out.address,
138            &limit,
139            0,
140            Sign::Positive,
141            |zero_for_one, amount_specified, sqrt_price_limit| {
142                self.swap(zero_for_one, amount_specified, Some(sqrt_price_limit))
143            },
144        ) else {
145            return crate::evm::query_pool_swap::query_pool_swap(self, params);
146        };
147
148        let new_state = if amount_in.is_zero() {
149            self.clone()
150        } else {
151            self.clone_post_swap(result.sqrt_price, result.tick, result.liquidity)
152        };
153        // The native swap can spend its whole input allowance before it reaches the limit.
154        let final_spot = new_state.spot_price(token_in, token_out)?;
155        if final_spot < target_f64 || final_spot > target_f64 * (1.0 + tolerance) {
156            return crate::evm::query_pool_swap::query_pool_swap(self, params);
157        }
158        Ok(PoolSwap::new(amount_in, amount_out, Box::new(new_state), None))
159    }
160
161    /// A clone of this state with the final price, tick and liquidity of a swap, and an empty
162    /// step cache.
163    fn clone_post_swap(&self, sqrt_price: U256, tick: PoolTick, liquidity: u128) -> Self {
164        let mut state = self.clone();
165        state.sqrt_price = sqrt_price;
166        state.tick = tick;
167        state.liquidity = liquidity;
168        state.step_cache = SwapStepCache::default();
169        state
170    }
171
172    fn swap(
173        &self,
174        zero_for_one: bool,
175        amount_specified: I256,
176        sqrt_price_limit: Option<U256>,
177    ) -> Result<SwapResults, SimulationError> {
178        if self.liquidity == 0 {
179            return Err(SimulationError::RecoverableError("No liquidity".to_string()));
180        }
181        let price_limit = if let Some(limit) = sqrt_price_limit {
182            limit
183        } else if zero_for_one {
184            safe_add_u256(MIN_SQRT_RATIO, U256::from(1u64))?
185        } else {
186            safe_sub_u256(MAX_SQRT_RATIO, U256::from(1u64))?
187        };
188
189        let price_limit_valid = if zero_for_one {
190            price_limit > MIN_SQRT_RATIO && price_limit < self.sqrt_price
191        } else {
192            price_limit < MAX_SQRT_RATIO && price_limit > self.sqrt_price
193        };
194        if !price_limit_valid {
195            return Err(SimulationError::InvalidInput("Price limit out of range".into(), None));
196        }
197
198        let exact_input = amount_specified > I256::from_raw(U256::from(0u64));
199
200        let mut state = SwapState {
201            amount_remaining: amount_specified,
202            amount_calculated: I256::from_raw(U256::from(0u64)),
203            sqrt_price: self.sqrt_price,
204            tick: self.tick.value().into(),
205            liquidity: self.liquidity,
206        };
207        let mut gas_used = U256::from(130_000);
208
209        let fee = self.get_fee();
210        // Only exact-input swaps with no price limit use the step cache. The cache finds a step by
211        // its input, which an exact-output swap does not know. A cached step can also lie past a
212        // price limit.
213        let cache_key = if exact_input && sqrt_price_limit.is_none() {
214            StepCacheKey::new(self.sqrt_price, state.tick.value(), self.liquidity, fee, gas_used)
215        } else {
216            None
217        };
218        let mut cache_run = StepCacheRun::new(
219            &self.step_cache,
220            zero_for_one,
221            cache_key,
222            amount_specified.unsigned_abs(),
223        );
224        if let Some(progress) = cache_run.apply_cached_progress(&mut state) {
225            gas_used = progress.gas;
226        }
227        while state.amount_remaining != I256::from_raw(U256::from(0u64)) &&
228            state.sqrt_price != price_limit
229        {
230            let (mut next_tick, next_tick_info) = match self
231                .ticks
232                .next_initialized_tick_within_one_word(state.tick.value(), zero_for_one)
233            {
234                Ok((tick, info)) => (tick, info),
235                Err(tick_err) => match tick_err.kind {
236                    TickListErrorKind::TicksExeeded => {
237                        let new_state = self.clone_post_swap(
238                            state.sqrt_price,
239                            state.tick.clone(),
240                            state.liquidity,
241                        );
242                        return Err(SimulationError::InvalidInput(
243                            "Ticks exceeded".into(),
244                            Some(GetAmountOutResult::new(
245                                u256_to_biguint(state.amount_calculated.abs().into_raw()),
246                                u256_to_biguint(gas_used),
247                                Box::new(new_state),
248                            )),
249                        ));
250                    }
251                    _ => return Err(SimulationError::FatalError("Unknown error".to_string())),
252                },
253            };
254
255            next_tick = next_tick.clamp(MIN_TICK, MAX_TICK);
256
257            let sqrt_price_start = state.sqrt_price;
258            let sqrt_price_next = match next_tick_info {
259                Some(info) => info.sqrt_price,
260                None => get_sqrt_ratio_at_tick_cached(next_tick)?,
261            };
262            let SwapStepResult { sqrt_price, amount_in_with_fee, amount_out } =
263                swap_math::compute_swap_step(
264                    state.sqrt_price,
265                    VelodromeSlipstreamsState::get_sqrt_ratio_target(
266                        sqrt_price_next,
267                        price_limit,
268                        zero_for_one,
269                    ),
270                    state.liquidity,
271                    state.amount_remaining,
272                    fee,
273                )?;
274            state.sqrt_price = sqrt_price;
275
276            let step = StepComputation {
277                sqrt_price_start,
278                tick_next: next_tick,
279                sqrt_price_next,
280                amount_in_with_fee,
281                amount_out,
282            };
283            if exact_input {
284                state.amount_remaining -=
285                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_in_with_fee)
286                        .unwrap();
287                state.amount_calculated -=
288                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
289            } else {
290                state.amount_remaining +=
291                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
292                state.amount_calculated +=
293                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_in_with_fee)
294                        .unwrap();
295            }
296            if state.sqrt_price == step.sqrt_price_next {
297                if let Some(info) = next_tick_info {
298                    let liquidity_raw = info.net_liquidity;
299                    let liquidity_net = if zero_for_one { -liquidity_raw } else { liquidity_raw };
300                    state.liquidity =
301                        liquidity_math::add_liquidity_delta(state.liquidity, liquidity_net)?;
302                    gas_used = safe_add_u256(gas_used, U256::from(GAS_PER_TICK))?;
303                }
304                state.tick =
305                    PoolTick::from(if zero_for_one { step.tick_next - 1 } else { step.tick_next });
306            } else if state.sqrt_price != step.sqrt_price_start {
307                state.tick = PoolTick::at_sqrt_price(state.sqrt_price)?;
308            }
309            gas_used = safe_add_u256(gas_used, U256::from(GAS_PER_LOOP))?;
310            cache_run.cache_progress(&step, &state, gas_used);
311        }
312        Ok(SwapResults {
313            amount_calculated: state.amount_calculated,
314            amount_specified,
315            amount_remaining: state.amount_remaining,
316            sqrt_price: state.sqrt_price,
317            liquidity: state.liquidity,
318            tick: state.tick,
319            gas_used,
320        })
321    }
322
323    fn get_sqrt_ratio_target(
324        sqrt_price_next: U256,
325        sqrt_price_limit: U256,
326        zero_for_one: bool,
327    ) -> U256 {
328        let cond1 = if zero_for_one {
329            sqrt_price_next < sqrt_price_limit
330        } else {
331            sqrt_price_next > sqrt_price_limit
332        };
333
334        if cond1 {
335            sqrt_price_limit
336        } else {
337            sqrt_price_next
338        }
339    }
340}
341
342#[typetag::serde]
343impl ProtocolSim for VelodromeSlipstreamsState {
344    fn fee(&self) -> f64 {
345        self.get_fee() as f64 / 1_000_000.0
346    }
347
348    fn spot_price(&self, a: &Token, b: &Token) -> Result<f64, SimulationError> {
349        if a < b {
350            sqrt_price_q96_to_f64(self.sqrt_price, a.decimals, b.decimals)
351        } else {
352            sqrt_price_q96_to_f64(self.sqrt_price, b.decimals, a.decimals)
353                .map(|price| 1.0f64 / price)
354        }
355    }
356
357    fn get_amount_out(
358        &self,
359        amount_in: BigUint,
360        token_a: &Token,
361        token_b: &Token,
362    ) -> Result<GetAmountOutResult, SimulationError> {
363        let zero_for_one = token_a < token_b;
364        let amount_specified = I256::checked_from_sign_and_abs(
365            Sign::Positive,
366            U256::from_be_slice(&amount_in.to_bytes_be()),
367        )
368        .ok_or_else(|| {
369            SimulationError::InvalidInput("I256 overflow: amount_in".to_string(), None)
370        })?;
371
372        let result = self.swap(zero_for_one, amount_specified, None)?;
373
374        trace!(?amount_in, ?token_a, ?token_b, ?zero_for_one, ?result, "SLIPSTREAMS SWAP");
375        let new_state = self.clone_post_swap(result.sqrt_price, result.tick, result.liquidity);
376
377        Ok(GetAmountOutResult::new(
378            u256_to_biguint(
379                result
380                    .amount_calculated
381                    .abs()
382                    .into_raw(),
383            ),
384            u256_to_biguint(result.gas_used),
385            Box::new(new_state),
386        ))
387    }
388
389    fn get_limits(
390        &self,
391        token_in: Bytes,
392        token_out: Bytes,
393    ) -> Result<(BigUint, BigUint), SimulationError> {
394        // If the pool has no liquidity, return zeros for both limits
395        if self.liquidity == 0 {
396            return Ok((BigUint::zero(), BigUint::zero()));
397        }
398
399        let zero_for_one = token_in < token_out;
400        let mut current_tick = self.tick.value();
401        let mut current_sqrt_price = self.sqrt_price;
402        let mut current_liquidity = self.liquidity;
403        let mut total_amount_in = U256::from(0u64);
404        let mut total_amount_out = U256::from(0u64);
405
406        // Iterate through all ticks in the direction of the swap
407        // Continues until there is no more liquidity in the pool or no more ticks to process
408        while let Ok((tick, next_tick_info)) = self
409            .ticks
410            .next_initialized_tick_within_one_word(current_tick, zero_for_one)
411        {
412            // Clamp the tick value to ensure it's within valid range
413            let next_tick = tick.clamp(MIN_TICK, MAX_TICK);
414
415            let sqrt_price_next = match next_tick_info {
416                Some(info) => info.sqrt_price,
417                None => get_sqrt_ratio_at_tick_cached(next_tick)?,
418            };
419
420            // Calculate the amount of tokens swapped when moving from current_sqrt_price to
421            // sqrt_price_next. Direction determines which token is being swapped in vs out
422            let (amount_in, amount_out) = if zero_for_one {
423                let amount0 = get_amount0_delta(
424                    sqrt_price_next,
425                    current_sqrt_price,
426                    current_liquidity,
427                    true,
428                )?;
429                let amount1 = get_amount1_delta(
430                    sqrt_price_next,
431                    current_sqrt_price,
432                    current_liquidity,
433                    false,
434                )?;
435                (amount0, amount1)
436            } else {
437                let amount0 = get_amount0_delta(
438                    sqrt_price_next,
439                    current_sqrt_price,
440                    current_liquidity,
441                    false,
442                )?;
443                let amount1 = get_amount1_delta(
444                    sqrt_price_next,
445                    current_sqrt_price,
446                    current_liquidity,
447                    true,
448                )?;
449                (amount1, amount0)
450            };
451
452            // Accumulate total amounts for this tick range
453            total_amount_in = safe_add_u256(total_amount_in, amount_in)?;
454            total_amount_out = safe_add_u256(total_amount_out, amount_out)?;
455
456            // If this tick is "initialized" (meaning its someone's position boundary), update the
457            // liquidity when crossing it
458            // For zero_for_one, liquidity is removed when crossing a tick
459            // For one_for_zero, liquidity is added when crossing a tick
460            if let Some(info) = next_tick_info {
461                let liquidity_raw = info.net_liquidity;
462                let liquidity_delta = if zero_for_one { -liquidity_raw } else { liquidity_raw };
463                current_liquidity =
464                    liquidity_math::add_liquidity_delta(current_liquidity, liquidity_delta)?;
465            }
466
467            // Move to the next tick position
468            current_tick = if zero_for_one { next_tick - 1 } else { next_tick };
469            current_sqrt_price = sqrt_price_next;
470        }
471
472        Ok((u256_to_biguint(total_amount_in), u256_to_biguint(total_amount_out)))
473    }
474
475    fn delta_transition(
476        &mut self,
477        delta: ProtocolStateDelta,
478        _tokens: &HashMap<Bytes, Token>,
479        _balances: &Balances,
480    ) -> Result<(), TransitionError> {
481        // Swap steps taken from the old state do not hold for the new one.
482        self.step_cache = SwapStepCache::default();
483        // apply attribute changes
484        if let Some(liquidity) = delta
485            .updated_attributes
486            .get("liquidity")
487        {
488            self.liquidity = u128::from(liquidity.clone());
489        }
490        if let Some(sqrt_price) = delta
491            .updated_attributes
492            .get("sqrt_price_x96")
493        {
494            self.sqrt_price = U256::from_be_slice(sqrt_price);
495        }
496        if let Some(default_fee) = delta
497            .updated_attributes
498            .get("default_fee")
499        {
500            self.default_fee = u32::from(default_fee.clone());
501        }
502        if let Some(custom_fee) = delta
503            .updated_attributes
504            .get("custom_fee")
505        {
506            self.custom_fee = u32::from(custom_fee.clone());
507        }
508        if let Some(tick) = delta.updated_attributes.get("tick") {
509            self.tick = i32::from(tick.clone()).into();
510        }
511
512        // apply tick & observations changes
513        for (key, value) in delta.updated_attributes.iter() {
514            // tick liquidity keys are in the format "ticks/{tick_index}/net_liquidity"
515            if key.starts_with("ticks/") {
516                let parts: Vec<&str> = key.split('/').collect();
517                Arc::make_mut(&mut self.ticks)
518                    .set_tick_liquidity(
519                        parts[1]
520                            .parse::<i32>()
521                            .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
522                        i128::from(value.clone()),
523                    )
524                    .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
525            }
526        }
527        // delete ticks - ignores deletes for attributes other than tick liquidity
528        for key in delta.deleted_attributes.iter() {
529            // tick liquidity keys are in the format "ticks/{tick_index}/net_liquidity"
530            if key.starts_with("ticks/") {
531                let parts: Vec<&str> = key.split('/').collect();
532                Arc::make_mut(&mut self.ticks)
533                    .set_tick_liquidity(
534                        parts[1]
535                            .parse::<i32>()
536                            .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
537                        0,
538                    )
539                    .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
540            }
541        }
542        Ok(())
543    }
544
545    /// Answers [`SwapConstraint::PoolTargetPrice`] with one native swap and no `price_points`.
546    /// [`SwapConstraint::TradeLimitPrice`] uses the numerical search.
547    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
548        match params.swap_constraint() {
549            SwapConstraint::TradeLimitPrice { .. } => {
550                crate::evm::query_pool_swap::query_pool_swap(self, params)
551            }
552            SwapConstraint::PoolTargetPrice {
553                target,
554                tolerance,
555                min_amount_in: _,
556                max_amount_in: _,
557            } => self.swap_to_target_price(params, target, *tolerance),
558        }
559    }
560
561    fn clone_box(&self) -> Box<dyn ProtocolSim> {
562        Box::new(self.clone())
563    }
564
565    fn as_any(&self) -> &dyn Any {
566        self
567    }
568
569    fn as_any_mut(&mut self) -> &mut dyn Any {
570        self
571    }
572
573    fn eq(&self, other: &dyn ProtocolSim) -> bool {
574        if let Some(other_state) = other
575            .as_any()
576            .downcast_ref::<VelodromeSlipstreamsState>()
577        {
578            self.liquidity == other_state.liquidity &&
579                self.sqrt_price == other_state.sqrt_price &&
580                self.get_fee() == other_state.get_fee() &&
581                self.tick == other_state.tick &&
582                self.ticks == other_state.ticks
583        } else {
584            false
585        }
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use alloy::primitives::{Sign, I256, U256};
592    use rstest::rstest;
593    use tycho_common::{models::Chain, simulation::errors::SimulationError};
594
595    use super::*;
596    use crate::evm::{
597        protocol::utils::uniswap::{
598            tick_list::TickInfo,
599            tick_math::{
600                get_sqrt_ratio_at_tick, get_tick_at_sqrt_ratio, MAX_SQRT_RATIO, MIN_SQRT_RATIO,
601                MIN_TICK,
602            },
603        },
604        query_pool_swap::test_helpers::{target_price_params, to_price},
605    };
606
607    fn create_basic_test_pool() -> VelodromeSlipstreamsState {
608        let sqrt_price = get_sqrt_ratio_at_tick(0).expect("Failed to calculate sqrt price");
609        let ticks = vec![TickInfo::new(-120, 0).unwrap(), TickInfo::new(120, 0).unwrap()];
610        VelodromeSlipstreamsState::new(
611            100_000_000_000_000_000_000u128,
612            sqrt_price,
613            3000,
614            0,
615            1,
616            0,
617            ticks,
618        )
619        .expect("Failed to create pool")
620    }
621
622    #[rstest]
623    #[case::no_custom_fee(0, 0.003)]
624    #[case::zero_fee_indicator(420, 0.0)]
625    #[case::custom_fee(500, 0.0005)]
626    fn test_fee_resolves_custom_fee(#[case] custom_fee: u32, #[case] expected: f64) {
627        let sqrt_price = get_sqrt_ratio_at_tick(0).unwrap();
628        let ticks = vec![TickInfo::new(-120, 0).unwrap(), TickInfo::new(120, 0).unwrap()];
629        let pool = VelodromeSlipstreamsState::new(
630            10u128.pow(20),
631            sqrt_price,
632            3000,
633            custom_fee,
634            1,
635            0,
636            ticks,
637        )
638        .unwrap();
639
640        assert_eq!(pool.fee(), expected);
641    }
642
643    #[test]
644    fn test_partial_step_updates_tick_when_price_moves_without_crossing_initialized_tick() {
645        let pool = create_basic_test_pool();
646        let amount =
647            I256::checked_from_sign_and_abs(Sign::Positive, U256::from(100_000_000_000_000_000u64))
648                .unwrap();
649
650        let result = pool
651            .swap(true, amount, None)
652            .expect("swap should stay within the current liquidity range");
653        let expected_tick =
654            get_tick_at_sqrt_ratio(result.sqrt_price).expect("new sqrt price should map to a tick");
655
656        assert_ne!(result.sqrt_price, pool.sqrt_price);
657        assert_ne!(result.sqrt_price, get_sqrt_ratio_at_tick(-120).unwrap());
658        assert_ne!(expected_tick, pool.tick.value());
659        assert_eq!(result.tick.value(), expected_tick);
660    }
661
662    #[test]
663    fn test_swap_keeps_boundary_tick_when_price_does_not_move() {
664        let mut pool = create_basic_test_pool();
665        pool.tick = PoolTick::from(-1);
666        let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1u64)).unwrap();
667
668        let result = pool
669            .swap(true, amount, None)
670            .expect("swap should consume the input as fee without moving price");
671
672        assert_eq!(result.sqrt_price, pool.sqrt_price);
673        assert_eq!(get_tick_at_sqrt_ratio(result.sqrt_price).unwrap(), 0);
674        assert_eq!(result.tick, pool.tick);
675    }
676
677    #[test]
678    fn test_swap_price_limit_out_of_range_returns_error() {
679        let pool = create_basic_test_pool();
680        let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1000u64)).unwrap();
681
682        let result = pool.swap(true, amount, Some(pool.sqrt_price));
683        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
684
685        let result = pool.swap(true, amount, Some(MIN_SQRT_RATIO));
686        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
687
688        let result = pool.swap(false, amount, Some(pool.sqrt_price));
689        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
690
691        let result = pool.swap(false, amount, Some(MAX_SQRT_RATIO));
692        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
693    }
694
695    #[test]
696    fn test_swap_at_extreme_price_returns_error() {
697        let sqrt_price = MIN_SQRT_RATIO + U256::from(1u64);
698        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("Failed to calculate tick");
699        let ticks =
700            vec![TickInfo::new(MIN_TICK, 0).unwrap(), TickInfo::new(MIN_TICK + 1, 0).unwrap()];
701        let pool = VelodromeSlipstreamsState::new(
702            100_000_000_000_000_000_000u128,
703            sqrt_price,
704            3000,
705            0,
706            1,
707            tick,
708            ticks,
709        )
710        .expect("Failed to create pool");
711
712        let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1000u64)).unwrap();
713        let result = pool.swap(true, amount, None);
714        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
715    }
716
717    fn token_pair() -> (Token, Token) {
718        let token_x = Token::new(&Bytes::from([0x01; 20]), "X", 18, 0, &[], Chain::Ethereum, 100);
719        let token_y = Token::new(&Bytes::from([0x02; 20]), "Y", 18, 0, &[], Chain::Ethereum, 100);
720        (token_x, token_y)
721    }
722
723    #[rstest]
724    fn test_query_pool_swap_target_price_lands_in_band(#[values(true, false)] sell_x: bool) {
725        let pool = create_basic_test_pool();
726        let (token_x, token_y) = token_pair();
727        let (token_in, token_out) =
728            if sell_x { (&token_x, &token_y) } else { (&token_y, &token_x) };
729        let target = 0.995;
730        let tolerance = 1e-4;
731        let params = target_price_params(
732            token_in,
733            token_out,
734            to_price(target, token_in, token_out),
735            tolerance,
736        );
737
738        let swap = pool.query_pool_swap(&params).unwrap();
739
740        let new_spot = swap
741            .new_state()
742            .spot_price(token_in, token_out)
743            .unwrap();
744        assert!(new_spot >= target && new_spot <= target * (1.0 + tolerance), "spot {new_spot}");
745        assert!(swap.price_points().is_none(), "the native path returns no price points");
746        let quote = pool
747            .get_amount_out(swap.amount_in().clone(), token_in, token_out)
748            .unwrap();
749        assert_eq!(&quote.amount, swap.amount_out());
750    }
751
752    /// A target less than half the tolerance below spot puts the swap limit above spot.
753    #[test]
754    fn test_query_pool_swap_target_price_falls_back_to_search() {
755        let pool = create_basic_test_pool();
756        let (token_x, token_y) = token_pair();
757        let params =
758            target_price_params(&token_x, &token_y, to_price(1.0 - 1e-5, &token_x, &token_y), 1e-4);
759
760        let swap = pool.query_pool_swap(&params).unwrap();
761
762        assert!(swap.price_points().is_some(), "the numerical search returns price points");
763    }
764
765    /// With maximum liquidity, the native swap spends its whole input allowance far above a very
766    /// low target.
767    #[test]
768    fn test_query_pool_swap_target_price_out_of_reach_falls_back_to_search() {
769        let sqrt_price = get_sqrt_ratio_at_tick(0).unwrap();
770        let ticks = vec![TickInfo::new(MIN_TICK, 0).unwrap(), TickInfo::new(-MIN_TICK, 0).unwrap()];
771        let pool =
772            VelodromeSlipstreamsState::new(u128::MAX, sqrt_price, 3000, 0, 1, 0, ticks).unwrap();
773        let (token_x, token_y) = token_pair();
774        let target = Price::new(BigUint::from(1u8), BigUint::from(1u8) << 100usize);
775        let target_f64 = 2f64.powi(-100);
776        let tolerance = 1e-4;
777        let params = target_price_params(&token_x, &token_y, target, tolerance);
778
779        let swap = pool.query_pool_swap(&params).unwrap();
780
781        let new_spot = swap
782            .new_state()
783            .spot_price(&token_x, &token_y)
784            .unwrap();
785        assert!(
786            new_spot >= target_f64 && new_spot <= target_f64 * (1.0 + tolerance),
787            "spot {new_spot} is outside the band of target {target_f64}"
788        );
789        assert!(swap.price_points().is_some(), "the numerical search returns price points");
790    }
791
792    #[test]
793    fn test_query_pool_swap_target_price_at_spot() {
794        let pool = create_basic_test_pool();
795        let (token_x, token_y) = token_pair();
796        let params = target_price_params(
797            &token_x,
798            &token_y,
799            Price::new(BigUint::from(1u64), BigUint::from(1u64)),
800            1e-4,
801        );
802
803        let swap = pool.query_pool_swap(&params).unwrap();
804
805        assert!(swap.amount_in().is_zero());
806        assert!(swap.amount_out().is_zero());
807        assert!(swap.new_state().eq(&pool));
808    }
809
810    #[test]
811    fn test_query_pool_swap_target_price_above_spot() {
812        let pool = create_basic_test_pool();
813        let (token_x, token_y) = token_pair();
814        let params =
815            target_price_params(&token_x, &token_y, to_price(1.01, &token_x, &token_y), 1e-4);
816
817        let result = pool.query_pool_swap(&params);
818
819        assert!(matches!(result, Err(SimulationError::InvalidInput(..))), "got {result:?}");
820    }
821}
822
823#[cfg(test)]
824mod tick_list_sharing_tests {
825    use std::collections::{HashMap, HashSet};
826
827    use tycho_common::{dto::ProtocolStateDelta, hex_bytes::Bytes};
828
829    use super::*;
830    use crate::evm::protocol::utils::uniswap::tick_math::get_sqrt_ratio_at_tick;
831
832    #[test]
833    fn test_delta_transition_leaves_clones_ticks_unchanged() {
834        let original = VelodromeSlipstreamsState::new(
835            100_000_000_000_000_000_000u128,
836            get_sqrt_ratio_at_tick(0).unwrap(),
837            3000,
838            0,
839            1,
840            0,
841            vec![TickInfo::new(-120, 10000).unwrap(), TickInfo::new(120, -10000).unwrap()],
842        )
843        .unwrap();
844        let mut updated = original.clone();
845        let delta = ProtocolStateDelta {
846            component_id: "State1".to_owned(),
847            updated_attributes: HashMap::from([(
848                "ticks/-120/net_liquidity".to_string(),
849                Bytes::from(20000_i128.to_be_bytes().to_vec()),
850            )]),
851            deleted_attributes: HashSet::new(),
852        };
853
854        updated
855            .delta_transition(delta, &HashMap::new(), &Balances::default())
856            .unwrap();
857
858        assert_eq!(
859            updated
860                .ticks
861                .get_tick(-120)
862                .unwrap()
863                .net_liquidity,
864            20000
865        );
866        assert_eq!(
867            original
868                .ticks
869                .get_tick(-120)
870                .unwrap()
871                .net_liquidity,
872            10000
873        );
874    }
875}
876
877#[cfg(test)]
878mod step_cache_tests {
879    use std::{
880        collections::{HashMap, HashSet},
881        str::FromStr,
882    };
883
884    use tycho_common::{dto::ProtocolStateDelta, hex_bytes::Bytes};
885
886    use super::*;
887    use crate::evm::protocol::utils::uniswap::swap_step_cache::test_fixtures::{
888        amount_orders, test_amounts, test_ticks, wbtc_weth, LIQUIDITY, SQRT_PRICE, TICK,
889    };
890
891    fn test_pool() -> VelodromeSlipstreamsState {
892        VelodromeSlipstreamsState::new(
893            LIQUIDITY,
894            U256::from_str(SQRT_PRICE).unwrap(),
895            500,
896            0,
897            10,
898            TICK,
899            test_ticks(),
900        )
901        .unwrap()
902    }
903
904    /// A quote's amount, gas and new state, or its error, as text.
905    fn format_quote_result(
906        pool: &VelodromeSlipstreamsState,
907        amount: &BigUint,
908        sell: &Token,
909        buy: &Token,
910    ) -> String {
911        match pool.get_amount_out(amount.clone(), sell, buy) {
912            Ok(result) => {
913                let state = result
914                    .new_state
915                    .as_any()
916                    .downcast_ref::<VelodromeSlipstreamsState>()
917                    .unwrap();
918                format!(
919                    "{} {} {} {} {}",
920                    result.amount,
921                    result.gas,
922                    state.sqrt_price,
923                    state.tick.value(),
924                    state.liquidity
925                )
926            }
927            Err(error) => format!("error {error}"),
928        }
929    }
930
931    #[test]
932    fn test_step_cache_quotes_match_a_fresh_state_in_any_order() {
933        let (wbtc, weth) = wbtc_weth();
934        for (sell, buy, smallest) in [(&wbtc, &weth, 1_000u64), (&weth, &wbtc, 1_000_000_000)] {
935            for order in amount_orders(&test_amounts(smallest)) {
936                let shared = test_pool();
937                for amount in &order {
938                    assert_eq!(
939                        format_quote_result(&shared, amount, sell, buy),
940                        format_quote_result(&test_pool(), amount, sell, buy),
941                        "selling {amount} {}",
942                        sell.symbol
943                    );
944                }
945                let steps = shared
946                    .step_cache
947                    .min_inputs(sell < buy)
948                    .len();
949                assert!(steps > 2, "only {steps} steps cached");
950            }
951        }
952    }
953
954    #[test]
955    fn test_step_cache_quotes_match_a_fresh_state_at_each_cached_step() {
956        let (wbtc, weth) = wbtc_weth();
957        // Amounts that run past the last tick, so the cache covers the whole tick list. Selling
958        // WETH needs a far larger amount to leave its first tick.
959        for (sell, buy, doublings) in [(&wbtc, &weth, 30u32), (&weth, &wbtc, 70)] {
960            let cached = test_pool();
961            let _ = cached.get_amount_out(BigUint::from(10u32) << doublings, sell, buy);
962            let boundaries = cached.step_cache.min_inputs(sell < buy);
963            assert!(boundaries.len() > 2, "only {} steps cached", boundaries.len());
964            for boundary in boundaries.into_iter().skip(1) {
965                for amount in [boundary - U256::from(1u64), boundary, boundary + U256::from(1u64)] {
966                    let amount = u256_to_biguint(amount);
967                    assert_eq!(
968                        format_quote_result(&cached, &amount, sell, buy),
969                        format_quote_result(&test_pool(), &amount, sell, buy),
970                        "selling {amount} {}",
971                        sell.symbol
972                    );
973                }
974            }
975        }
976    }
977
978    #[test]
979    fn test_step_cache_of_a_swapped_state() {
980        let (wbtc, weth) = wbtc_weth();
981        let pool = test_pool();
982        let swapped = pool
983            .get_amount_out(BigUint::from(300_000_000u64), &wbtc, &weth)
984            .unwrap()
985            .new_state;
986        let swapped = swapped
987            .as_any()
988            .downcast_ref::<VelodromeSlipstreamsState>()
989            .unwrap();
990        // A serde round trip gives the same state with an empty step cache.
991        let fresh: VelodromeSlipstreamsState =
992            serde_json::from_value(serde_json::to_value(swapped).unwrap()).unwrap();
993
994        for amount in test_amounts(1_000).iter().rev() {
995            assert_eq!(
996                format_quote_result(swapped, amount, &wbtc, &weth),
997                format_quote_result(&fresh, amount, &wbtc, &weth),
998                "selling {amount} WBTC"
999            );
1000        }
1001    }
1002
1003    #[test]
1004    fn test_step_cache_restarts_after_delta_transition() {
1005        let (wbtc, weth) = wbtc_weth();
1006        let large = BigUint::from(3_000_000_000u64);
1007        // Only a crossed tick's liquidity changes, so the price, tick and liquidity the cache
1008        // starts from stay the same.
1009        let delta = || ProtocolStateDelta {
1010            component_id: "State1".to_owned(),
1011            updated_attributes: HashMap::from([(
1012                "ticks/255820/net_liquidity".to_string(),
1013                Bytes::from(
1014                    1_000_000_000_000_000i128
1015                        .to_be_bytes()
1016                        .to_vec(),
1017                ),
1018            )]),
1019            deleted_attributes: HashSet::new(),
1020        };
1021        let mut cached = test_pool();
1022        cached
1023            .get_amount_out(large.clone(), &wbtc, &weth)
1024            .unwrap();
1025        let mut fresh = test_pool();
1026
1027        cached
1028            .delta_transition(delta(), &HashMap::new(), &Balances::default())
1029            .unwrap();
1030        fresh
1031            .delta_transition(delta(), &HashMap::new(), &Balances::default())
1032            .unwrap();
1033
1034        assert_eq!(
1035            format_quote_result(&cached, &large, &wbtc, &weth),
1036            format_quote_result(&fresh, &large, &wbtc, &weth)
1037        );
1038    }
1039}