Skip to main content

tycho_simulation/evm/protocol/velodrome_slipstreams/
state.rs

1use std::{any::Any, collections::HashMap};
2
3use alloy::primitives::{Sign, I256, U256};
4use num_bigint::BigUint;
5use num_traits::Zero;
6use serde::{Deserialize, Serialize};
7use tracing::trace;
8use tycho_common::{
9    dto::ProtocolStateDelta,
10    models::token::Token,
11    simulation::{
12        errors::{SimulationError, TransitionError},
13        protocol_sim::{
14            Balances, GetAmountOutResult, PoolSwap, 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                sqrt_price_math::{get_amount0_delta, get_amount1_delta, sqrt_price_q96_to_f64},
31                swap_math,
32                tick_list::{TickInfo, TickList, TickListErrorKind},
33                tick_math::{
34                    get_sqrt_ratio_at_tick, get_tick_at_sqrt_ratio, MAX_SQRT_RATIO, MAX_TICK,
35                    MIN_SQRT_RATIO, MIN_TICK,
36                },
37                StepComputation, SwapResults, SwapState,
38            },
39        },
40    },
41    query_pool_swap::price_to_f64_with_decimals,
42};
43
44// The names of the constants reflect the exact method from the tenderly log.
45const GAS_PER_TICK: u64 = 25_000;
46// nextInitializedTickWithinOneWord +  computeSwapStep + calculateFees
47const GAS_PER_LOOP: u64 = 10_000;
48
49#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
50pub struct VelodromeSlipstreamsState {
51    liquidity: u128,
52    sqrt_price: U256,
53    default_fee: u32,
54    custom_fee: u32,
55    tick_spacing: i32,
56    tick: i32,
57    ticks: TickList,
58}
59
60impl VelodromeSlipstreamsState {
61    /// Creates a new instance of `AerodromeSlipstreamsState`.
62    ///
63    /// # Arguments
64    /// - `liquidity`: The initial liquidity of the pool.
65    /// - `sqrt_price`: The square root of the current price.
66    /// - `default_fee`: The default fee for the pool.
67    /// - `custom_fee`: The custom fee for the pool.
68    /// - `tick_spacing`: The tick spacing for the pool.
69    /// - `tick`: The current tick of the pool.
70    /// - `ticks`: A vector of `TickInfo` representing the tick information for the pool.
71    #[allow(clippy::too_many_arguments)]
72    pub fn new(
73        liquidity: u128,
74        sqrt_price: U256,
75        default_fee: u32,
76        custom_fee: u32,
77        tick_spacing: i32,
78        tick: i32,
79        ticks: Vec<TickInfo>,
80    ) -> Result<Self, SimulationError> {
81        let tick_list = TickList::from(tick_spacing as u16, ticks)?;
82        Ok(VelodromeSlipstreamsState {
83            liquidity,
84            sqrt_price,
85            default_fee,
86            custom_fee,
87            tick_spacing,
88            tick,
89            ticks: tick_list,
90        })
91    }
92
93    /// Returns the swap fee in pips. A custom fee of zero means that none is set, and
94    /// [`ZERO_FEE_INDICATOR`] is the value the fee module stores for an explicit zero fee.
95    fn get_fee(&self) -> u32 {
96        match self.custom_fee {
97            0 => self.default_fee,
98            ZERO_FEE_INDICATOR => 0,
99            custom_fee => custom_fee,
100        }
101    }
102
103    /// Swaps token_in until `spot_price(token_in, token_out)` reaches the middle of the band
104    /// `[target, target * (1 + tolerance)]`. Falls back to the numerical search on errors, and
105    /// when the native swap stops outside the band.
106    fn swap_to_target_price(
107        &self,
108        params: &QueryPoolSwapParams,
109        target: &Price,
110        tolerance: f64,
111    ) -> Result<PoolSwap, SimulationError> {
112        let token_in = params.token_in();
113        let token_out = params.token_out();
114        let target_f64 = price_to_f64_with_decimals(target, token_in.decimals, token_out.decimals)?;
115        if target_f64 == self.spot_price(token_in, token_out)? {
116            return Ok(PoolSwap::new(BigUint::ZERO, BigUint::ZERO, Box::new(self.clone()), None));
117        }
118        // `spot_price` has no fee markup, so the limit and the swap both use fee 0.
119        let Some(limit) = raw_target_price(target, tolerance, 0) else {
120            return crate::evm::query_pool_swap::query_pool_swap(self, params);
121        };
122        let Ok((amount_in, amount_out, result)) = clmm_swap_to_price(
123            self.sqrt_price,
124            &token_in.address,
125            &token_out.address,
126            &limit,
127            0,
128            Sign::Positive,
129            |zero_for_one, amount_specified, sqrt_price_limit| {
130                self.swap(zero_for_one, amount_specified, Some(sqrt_price_limit))
131            },
132        ) else {
133            return crate::evm::query_pool_swap::query_pool_swap(self, params);
134        };
135
136        let mut new_state = self.clone();
137        if !amount_in.is_zero() {
138            new_state.liquidity = result.liquidity;
139            new_state.tick = result.tick;
140            new_state.sqrt_price = result.sqrt_price;
141        }
142        // The native swap can spend its whole input allowance before it reaches the limit.
143        let final_spot = new_state.spot_price(token_in, token_out)?;
144        if final_spot < target_f64 || final_spot > target_f64 * (1.0 + tolerance) {
145            return crate::evm::query_pool_swap::query_pool_swap(self, params);
146        }
147        Ok(PoolSwap::new(amount_in, amount_out, Box::new(new_state), None))
148    }
149
150    fn swap(
151        &self,
152        zero_for_one: bool,
153        amount_specified: I256,
154        sqrt_price_limit: Option<U256>,
155    ) -> Result<SwapResults, SimulationError> {
156        if self.liquidity == 0 {
157            return Err(SimulationError::RecoverableError("No liquidity".to_string()));
158        }
159        let price_limit = if let Some(limit) = sqrt_price_limit {
160            limit
161        } else if zero_for_one {
162            safe_add_u256(MIN_SQRT_RATIO, U256::from(1u64))?
163        } else {
164            safe_sub_u256(MAX_SQRT_RATIO, U256::from(1u64))?
165        };
166
167        let price_limit_valid = if zero_for_one {
168            price_limit > MIN_SQRT_RATIO && price_limit < self.sqrt_price
169        } else {
170            price_limit < MAX_SQRT_RATIO && price_limit > self.sqrt_price
171        };
172        if !price_limit_valid {
173            return Err(SimulationError::InvalidInput("Price limit out of range".into(), None));
174        }
175
176        let exact_input = amount_specified > I256::from_raw(U256::from(0u64));
177
178        let mut state = SwapState {
179            amount_remaining: amount_specified,
180            amount_calculated: I256::from_raw(U256::from(0u64)),
181            sqrt_price: self.sqrt_price,
182            tick: self.tick,
183            liquidity: self.liquidity,
184        };
185        let mut gas_used = U256::from(130_000);
186
187        let fee = self.get_fee();
188        while state.amount_remaining != I256::from_raw(U256::from(0u64)) &&
189            state.sqrt_price != price_limit
190        {
191            let (mut next_tick, initialized) = match self
192                .ticks
193                .next_initialized_tick_within_one_word(state.tick, zero_for_one)
194            {
195                Ok((tick, init)) => (tick, init),
196                Err(tick_err) => match tick_err.kind {
197                    TickListErrorKind::TicksExeeded => {
198                        let mut new_state = self.clone();
199                        new_state.liquidity = state.liquidity;
200                        new_state.tick = state.tick;
201                        new_state.sqrt_price = state.sqrt_price;
202                        return Err(SimulationError::InvalidInput(
203                            "Ticks exceeded".into(),
204                            Some(GetAmountOutResult::new(
205                                u256_to_biguint(state.amount_calculated.abs().into_raw()),
206                                u256_to_biguint(gas_used),
207                                Box::new(new_state),
208                            )),
209                        ));
210                    }
211                    _ => return Err(SimulationError::FatalError("Unknown error".to_string())),
212                },
213            };
214
215            next_tick = next_tick.clamp(MIN_TICK, MAX_TICK);
216
217            let sqrt_price_start = state.sqrt_price;
218            let sqrt_price_next = get_sqrt_ratio_at_tick(next_tick)?;
219            let (sqrt_price, amount_in, amount_out, fee_amount) = swap_math::compute_swap_step(
220                state.sqrt_price,
221                VelodromeSlipstreamsState::get_sqrt_ratio_target(
222                    sqrt_price_next,
223                    price_limit,
224                    zero_for_one,
225                ),
226                state.liquidity,
227                state.amount_remaining,
228                fee,
229            )?;
230            state.sqrt_price = sqrt_price;
231
232            let step = StepComputation {
233                sqrt_price_start,
234                tick_next: next_tick,
235                initialized,
236                sqrt_price_next,
237                amount_in,
238                amount_out,
239                fee_amount,
240            };
241            if exact_input {
242                state.amount_remaining -= I256::checked_from_sign_and_abs(
243                    Sign::Positive,
244                    safe_add_u256(step.amount_in, step.fee_amount)?,
245                )
246                .unwrap();
247                state.amount_calculated -=
248                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
249            } else {
250                state.amount_remaining +=
251                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
252                state.amount_calculated += I256::checked_from_sign_and_abs(
253                    Sign::Positive,
254                    safe_add_u256(step.amount_in, step.fee_amount)?,
255                )
256                .unwrap();
257            }
258            if state.sqrt_price == step.sqrt_price_next {
259                if step.initialized {
260                    let liquidity_raw = self
261                        .ticks
262                        .get_tick(step.tick_next)
263                        .unwrap()
264                        .net_liquidity;
265                    let liquidity_net = if zero_for_one { -liquidity_raw } else { liquidity_raw };
266                    state.liquidity =
267                        liquidity_math::add_liquidity_delta(state.liquidity, liquidity_net)?;
268                    gas_used = safe_add_u256(gas_used, U256::from(GAS_PER_TICK))?;
269                }
270                state.tick = if zero_for_one { step.tick_next - 1 } else { step.tick_next };
271            } else if state.sqrt_price != step.sqrt_price_start {
272                state.tick = get_tick_at_sqrt_ratio(state.sqrt_price)?;
273            }
274            gas_used = safe_add_u256(gas_used, U256::from(GAS_PER_LOOP))?;
275        }
276        Ok(SwapResults {
277            amount_calculated: state.amount_calculated,
278            amount_specified,
279            amount_remaining: state.amount_remaining,
280            sqrt_price: state.sqrt_price,
281            liquidity: state.liquidity,
282            tick: state.tick,
283            gas_used,
284        })
285    }
286
287    fn get_sqrt_ratio_target(
288        sqrt_price_next: U256,
289        sqrt_price_limit: U256,
290        zero_for_one: bool,
291    ) -> U256 {
292        let cond1 = if zero_for_one {
293            sqrt_price_next < sqrt_price_limit
294        } else {
295            sqrt_price_next > sqrt_price_limit
296        };
297
298        if cond1 {
299            sqrt_price_limit
300        } else {
301            sqrt_price_next
302        }
303    }
304}
305
306#[typetag::serde]
307impl ProtocolSim for VelodromeSlipstreamsState {
308    fn fee(&self) -> f64 {
309        self.get_fee() as f64 / 1_000_000.0
310    }
311
312    fn spot_price(&self, a: &Token, b: &Token) -> Result<f64, SimulationError> {
313        if a < b {
314            sqrt_price_q96_to_f64(self.sqrt_price, a.decimals, b.decimals)
315        } else {
316            sqrt_price_q96_to_f64(self.sqrt_price, b.decimals, a.decimals)
317                .map(|price| 1.0f64 / price)
318        }
319    }
320
321    fn get_amount_out(
322        &self,
323        amount_in: BigUint,
324        token_a: &Token,
325        token_b: &Token,
326    ) -> Result<GetAmountOutResult, SimulationError> {
327        let zero_for_one = token_a < token_b;
328        let amount_specified = I256::checked_from_sign_and_abs(
329            Sign::Positive,
330            U256::from_be_slice(&amount_in.to_bytes_be()),
331        )
332        .ok_or_else(|| {
333            SimulationError::InvalidInput("I256 overflow: amount_in".to_string(), None)
334        })?;
335
336        let result = self.swap(zero_for_one, amount_specified, None)?;
337
338        trace!(?amount_in, ?token_a, ?token_b, ?zero_for_one, ?result, "SLIPSTREAMS SWAP");
339        let mut new_state = self.clone();
340        new_state.liquidity = result.liquidity;
341        new_state.tick = result.tick;
342        new_state.sqrt_price = result.sqrt_price;
343
344        Ok(GetAmountOutResult::new(
345            u256_to_biguint(
346                result
347                    .amount_calculated
348                    .abs()
349                    .into_raw(),
350            ),
351            u256_to_biguint(result.gas_used),
352            Box::new(new_state),
353        ))
354    }
355
356    fn get_limits(
357        &self,
358        token_in: Bytes,
359        token_out: Bytes,
360    ) -> Result<(BigUint, BigUint), SimulationError> {
361        // If the pool has no liquidity, return zeros for both limits
362        if self.liquidity == 0 {
363            return Ok((BigUint::zero(), BigUint::zero()));
364        }
365
366        let zero_for_one = token_in < token_out;
367        let mut current_tick = self.tick;
368        let mut current_sqrt_price = self.sqrt_price;
369        let mut current_liquidity = self.liquidity;
370        let mut total_amount_in = U256::from(0u64);
371        let mut total_amount_out = U256::from(0u64);
372
373        // Iterate through all ticks in the direction of the swap
374        // Continues until there is no more liquidity in the pool or no more ticks to process
375        while let Ok((tick, initialized)) = self
376            .ticks
377            .next_initialized_tick_within_one_word(current_tick, zero_for_one)
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            // Calculate the sqrt price at the next tick boundary
383            let sqrt_price_next = get_sqrt_ratio_at_tick(next_tick)?;
384
385            // Calculate the amount of tokens swapped when moving from current_sqrt_price to
386            // sqrt_price_next. Direction determines which token is being swapped in vs out
387            let (amount_in, amount_out) = if zero_for_one {
388                let amount0 = get_amount0_delta(
389                    sqrt_price_next,
390                    current_sqrt_price,
391                    current_liquidity,
392                    true,
393                )?;
394                let amount1 = get_amount1_delta(
395                    sqrt_price_next,
396                    current_sqrt_price,
397                    current_liquidity,
398                    false,
399                )?;
400                (amount0, amount1)
401            } else {
402                let amount0 = get_amount0_delta(
403                    sqrt_price_next,
404                    current_sqrt_price,
405                    current_liquidity,
406                    false,
407                )?;
408                let amount1 = get_amount1_delta(
409                    sqrt_price_next,
410                    current_sqrt_price,
411                    current_liquidity,
412                    true,
413                )?;
414                (amount1, amount0)
415            };
416
417            // Accumulate total amounts for this tick range
418            total_amount_in = safe_add_u256(total_amount_in, amount_in)?;
419            total_amount_out = safe_add_u256(total_amount_out, amount_out)?;
420
421            // If this tick is "initialized" (meaning its someone's position boundary), update the
422            // liquidity when crossing it
423            // For zero_for_one, liquidity is removed when crossing a tick
424            // For one_for_zero, liquidity is added when crossing a tick
425            if initialized {
426                let liquidity_raw = self
427                    .ticks
428                    .get_tick(next_tick)
429                    .unwrap()
430                    .net_liquidity;
431                let liquidity_delta = if zero_for_one { -liquidity_raw } else { liquidity_raw };
432                current_liquidity =
433                    liquidity_math::add_liquidity_delta(current_liquidity, liquidity_delta)?;
434            }
435
436            // Move to the next tick position
437            current_tick = if zero_for_one { next_tick - 1 } else { next_tick };
438            current_sqrt_price = sqrt_price_next;
439        }
440
441        Ok((u256_to_biguint(total_amount_in), u256_to_biguint(total_amount_out)))
442    }
443
444    fn delta_transition(
445        &mut self,
446        delta: ProtocolStateDelta,
447        _tokens: &HashMap<Bytes, Token>,
448        _balances: &Balances,
449    ) -> Result<(), TransitionError> {
450        // apply attribute changes
451        if let Some(liquidity) = delta
452            .updated_attributes
453            .get("liquidity")
454        {
455            self.liquidity = u128::from(liquidity.clone());
456        }
457        if let Some(sqrt_price) = delta
458            .updated_attributes
459            .get("sqrt_price_x96")
460        {
461            self.sqrt_price = U256::from_be_slice(sqrt_price);
462        }
463        if let Some(default_fee) = delta
464            .updated_attributes
465            .get("default_fee")
466        {
467            self.default_fee = u32::from(default_fee.clone());
468        }
469        if let Some(custom_fee) = delta
470            .updated_attributes
471            .get("custom_fee")
472        {
473            self.custom_fee = u32::from(custom_fee.clone());
474        }
475        if let Some(tick) = delta.updated_attributes.get("tick") {
476            self.tick = i32::from(tick.clone());
477        }
478
479        // apply tick & observations changes
480        for (key, value) in delta.updated_attributes.iter() {
481            // tick liquidity keys are in the format "ticks/{tick_index}/net_liquidity"
482            if key.starts_with("ticks/") {
483                let parts: Vec<&str> = key.split('/').collect();
484                self.ticks
485                    .set_tick_liquidity(
486                        parts[1]
487                            .parse::<i32>()
488                            .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
489                        i128::from(value.clone()),
490                    )
491                    .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
492            }
493        }
494        // delete ticks - ignores deletes for attributes other than tick liquidity
495        for key in delta.deleted_attributes.iter() {
496            // tick liquidity keys are in the format "ticks/{tick_index}/net_liquidity"
497            if key.starts_with("ticks/") {
498                let parts: Vec<&str> = key.split('/').collect();
499                self.ticks
500                    .set_tick_liquidity(
501                        parts[1]
502                            .parse::<i32>()
503                            .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
504                        0,
505                    )
506                    .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
507            }
508        }
509        Ok(())
510    }
511
512    /// Answers [`SwapConstraint::PoolTargetPrice`] with one native swap and no `price_points`.
513    /// [`SwapConstraint::TradeLimitPrice`] uses the numerical search.
514    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
515        match params.swap_constraint() {
516            SwapConstraint::TradeLimitPrice { .. } => {
517                crate::evm::query_pool_swap::query_pool_swap(self, params)
518            }
519            SwapConstraint::PoolTargetPrice {
520                target,
521                tolerance,
522                min_amount_in: _,
523                max_amount_in: _,
524            } => self.swap_to_target_price(params, target, *tolerance),
525        }
526    }
527
528    fn clone_box(&self) -> Box<dyn ProtocolSim> {
529        Box::new(self.clone())
530    }
531
532    fn as_any(&self) -> &dyn Any {
533        self
534    }
535
536    fn as_any_mut(&mut self) -> &mut dyn Any {
537        self
538    }
539
540    fn eq(&self, other: &dyn ProtocolSim) -> bool {
541        if let Some(other_state) = other
542            .as_any()
543            .downcast_ref::<VelodromeSlipstreamsState>()
544        {
545            self.liquidity == other_state.liquidity &&
546                self.sqrt_price == other_state.sqrt_price &&
547                self.get_fee() == other_state.get_fee() &&
548                self.tick == other_state.tick &&
549                self.ticks == other_state.ticks
550        } else {
551            false
552        }
553    }
554}
555
556#[cfg(test)]
557mod tests {
558    use alloy::primitives::{Sign, I256, U256};
559    use rstest::rstest;
560    use tycho_common::{models::Chain, simulation::errors::SimulationError};
561
562    use super::*;
563    use crate::evm::{
564        protocol::utils::uniswap::{
565            tick_list::TickInfo,
566            tick_math::{
567                get_sqrt_ratio_at_tick, get_tick_at_sqrt_ratio, MAX_SQRT_RATIO, MIN_SQRT_RATIO,
568                MIN_TICK,
569            },
570        },
571        query_pool_swap::test_helpers::{target_price_params, to_price},
572    };
573
574    fn create_basic_test_pool() -> VelodromeSlipstreamsState {
575        let sqrt_price = get_sqrt_ratio_at_tick(0).expect("Failed to calculate sqrt price");
576        let ticks = vec![TickInfo::new(-120, 0).unwrap(), TickInfo::new(120, 0).unwrap()];
577        VelodromeSlipstreamsState::new(
578            100_000_000_000_000_000_000u128,
579            sqrt_price,
580            3000,
581            0,
582            1,
583            0,
584            ticks,
585        )
586        .expect("Failed to create pool")
587    }
588
589    #[rstest]
590    #[case::no_custom_fee(0, 0.003)]
591    #[case::zero_fee_indicator(420, 0.0)]
592    #[case::custom_fee(500, 0.0005)]
593    fn test_fee_resolves_custom_fee(#[case] custom_fee: u32, #[case] expected: f64) {
594        let sqrt_price = get_sqrt_ratio_at_tick(0).unwrap();
595        let ticks = vec![TickInfo::new(-120, 0).unwrap(), TickInfo::new(120, 0).unwrap()];
596        let pool = VelodromeSlipstreamsState::new(
597            10u128.pow(20),
598            sqrt_price,
599            3000,
600            custom_fee,
601            1,
602            0,
603            ticks,
604        )
605        .unwrap();
606
607        assert_eq!(pool.fee(), expected);
608    }
609
610    #[test]
611    fn test_partial_step_updates_tick_when_price_moves_without_crossing_initialized_tick() {
612        let pool = create_basic_test_pool();
613        let amount =
614            I256::checked_from_sign_and_abs(Sign::Positive, U256::from(100_000_000_000_000_000u64))
615                .unwrap();
616
617        let result = pool
618            .swap(true, amount, None)
619            .expect("swap should stay within the current liquidity range");
620        let expected_tick =
621            get_tick_at_sqrt_ratio(result.sqrt_price).expect("new sqrt price should map to a tick");
622
623        assert_ne!(result.sqrt_price, pool.sqrt_price);
624        assert_ne!(result.sqrt_price, get_sqrt_ratio_at_tick(-120).unwrap());
625        assert_ne!(expected_tick, pool.tick);
626        assert_eq!(result.tick, expected_tick);
627    }
628
629    #[test]
630    fn test_swap_keeps_boundary_tick_when_price_does_not_move() {
631        let mut pool = create_basic_test_pool();
632        pool.tick = -1;
633        let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1u64)).unwrap();
634
635        let result = pool
636            .swap(true, amount, None)
637            .expect("swap should consume the input as fee without moving price");
638
639        assert_eq!(result.sqrt_price, pool.sqrt_price);
640        assert_eq!(get_tick_at_sqrt_ratio(result.sqrt_price).unwrap(), 0);
641        assert_eq!(result.tick, pool.tick);
642    }
643
644    #[test]
645    fn test_swap_price_limit_out_of_range_returns_error() {
646        let pool = create_basic_test_pool();
647        let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1000u64)).unwrap();
648
649        let result = pool.swap(true, amount, Some(pool.sqrt_price));
650        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
651
652        let result = pool.swap(true, amount, Some(MIN_SQRT_RATIO));
653        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
654
655        let result = pool.swap(false, amount, Some(pool.sqrt_price));
656        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
657
658        let result = pool.swap(false, amount, Some(MAX_SQRT_RATIO));
659        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
660    }
661
662    #[test]
663    fn test_swap_at_extreme_price_returns_error() {
664        let sqrt_price = MIN_SQRT_RATIO + U256::from(1u64);
665        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("Failed to calculate tick");
666        let ticks =
667            vec![TickInfo::new(MIN_TICK, 0).unwrap(), TickInfo::new(MIN_TICK + 1, 0).unwrap()];
668        let pool = VelodromeSlipstreamsState::new(
669            100_000_000_000_000_000_000u128,
670            sqrt_price,
671            3000,
672            0,
673            1,
674            tick,
675            ticks,
676        )
677        .expect("Failed to create pool");
678
679        let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1000u64)).unwrap();
680        let result = pool.swap(true, amount, None);
681        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
682    }
683
684    fn token_pair() -> (Token, Token) {
685        let token_x = Token::new(&Bytes::from([0x01; 20]), "X", 18, 0, &[], Chain::Ethereum, 100);
686        let token_y = Token::new(&Bytes::from([0x02; 20]), "Y", 18, 0, &[], Chain::Ethereum, 100);
687        (token_x, token_y)
688    }
689
690    #[rstest]
691    fn test_query_pool_swap_target_price_lands_in_band(#[values(true, false)] sell_x: bool) {
692        let pool = create_basic_test_pool();
693        let (token_x, token_y) = token_pair();
694        let (token_in, token_out) =
695            if sell_x { (&token_x, &token_y) } else { (&token_y, &token_x) };
696        let target = 0.995;
697        let tolerance = 1e-4;
698        let params = target_price_params(
699            token_in,
700            token_out,
701            to_price(target, token_in, token_out),
702            tolerance,
703        );
704
705        let swap = pool.query_pool_swap(&params).unwrap();
706
707        let new_spot = swap
708            .new_state()
709            .spot_price(token_in, token_out)
710            .unwrap();
711        assert!(new_spot >= target && new_spot <= target * (1.0 + tolerance), "spot {new_spot}");
712        assert!(swap.price_points().is_none(), "the native path returns no price points");
713        let quote = pool
714            .get_amount_out(swap.amount_in().clone(), token_in, token_out)
715            .unwrap();
716        assert_eq!(&quote.amount, swap.amount_out());
717    }
718
719    /// A target less than half the tolerance below spot puts the swap limit above spot.
720    #[test]
721    fn test_query_pool_swap_target_price_falls_back_to_search() {
722        let pool = create_basic_test_pool();
723        let (token_x, token_y) = token_pair();
724        let params =
725            target_price_params(&token_x, &token_y, to_price(1.0 - 1e-5, &token_x, &token_y), 1e-4);
726
727        let swap = pool.query_pool_swap(&params).unwrap();
728
729        assert!(swap.price_points().is_some(), "the numerical search returns price points");
730    }
731
732    /// With maximum liquidity, the native swap spends its whole input allowance far above a very
733    /// low target.
734    #[test]
735    fn test_query_pool_swap_target_price_out_of_reach_falls_back_to_search() {
736        let sqrt_price = get_sqrt_ratio_at_tick(0).unwrap();
737        let ticks = vec![TickInfo::new(MIN_TICK, 0).unwrap(), TickInfo::new(-MIN_TICK, 0).unwrap()];
738        let pool =
739            VelodromeSlipstreamsState::new(u128::MAX, sqrt_price, 3000, 0, 1, 0, ticks).unwrap();
740        let (token_x, token_y) = token_pair();
741        let target = Price::new(BigUint::from(1u8), BigUint::from(1u8) << 100usize);
742        let target_f64 = 2f64.powi(-100);
743        let tolerance = 1e-4;
744        let params = target_price_params(&token_x, &token_y, target, tolerance);
745
746        let swap = pool.query_pool_swap(&params).unwrap();
747
748        let new_spot = swap
749            .new_state()
750            .spot_price(&token_x, &token_y)
751            .unwrap();
752        assert!(
753            new_spot >= target_f64 && new_spot <= target_f64 * (1.0 + tolerance),
754            "spot {new_spot} is outside the band of target {target_f64}"
755        );
756        assert!(swap.price_points().is_some(), "the numerical search returns price points");
757    }
758
759    #[test]
760    fn test_query_pool_swap_target_price_at_spot() {
761        let pool = create_basic_test_pool();
762        let (token_x, token_y) = token_pair();
763        let params = target_price_params(
764            &token_x,
765            &token_y,
766            Price::new(BigUint::from(1u64), BigUint::from(1u64)),
767            1e-4,
768        );
769
770        let swap = pool.query_pool_swap(&params).unwrap();
771
772        assert!(swap.amount_in().is_zero());
773        assert!(swap.amount_out().is_zero());
774        assert!(swap.new_state().eq(&pool));
775    }
776
777    #[test]
778    fn test_query_pool_swap_target_price_above_spot() {
779        let pool = create_basic_test_pool();
780        let (token_x, token_y) = token_pair();
781        let params =
782            target_price_params(&token_x, &token_y, to_price(1.01, &token_x, &token_y), 1e-4);
783
784        let result = pool.query_pool_swap(&params);
785
786        assert!(matches!(result, Err(SimulationError::InvalidInput(..))), "got {result:?}");
787    }
788}