Skip to main content

tycho_simulation/evm/protocol/aerodrome_slipstreams/
state.rs

1use std::{any::Any, collections::HashMap};
2
3use alloy::primitives::{Sign, I256, U256};
4use num_bigint::{BigInt, BigUint};
5use num_traits::{ToPrimitive, Zero};
6use serde::{Deserialize, Serialize};
7use tracing::{error, trace};
8use tycho_common::{
9    dto::ProtocolStateDelta,
10    models::token::Token,
11    simulation::{
12        errors::{SimulationError, TransitionError},
13        protocol_sim::{Balances, GetAmountOutResult, ProtocolSim},
14    },
15    Bytes,
16};
17
18use crate::evm::protocol::{
19    safe_math::{safe_add_u256, safe_sub_u256},
20    u256_num::u256_to_biguint,
21    utils::{
22        add_fee_markup,
23        slipstreams::{
24            dynamic_fee_module::{get_dynamic_fee, DynamicFeeConfig},
25            observations::{Observation, Observations},
26        },
27        uniswap::{
28            i24_be_bytes_to_i32, liquidity_math,
29            sqrt_price_math::{get_amount0_delta, get_amount1_delta, sqrt_price_q96_to_f64},
30            swap_math,
31            tick_list::{TickInfo, TickList, TickListErrorKind},
32            tick_math::{
33                get_sqrt_ratio_at_tick, get_tick_at_sqrt_ratio, MAX_SQRT_RATIO, MAX_TICK,
34                MIN_SQRT_RATIO, MIN_TICK,
35            },
36            StepComputation, SwapResults, SwapState,
37        },
38    },
39};
40
41// Cold-storage warmup on the first loop iteration:
42// nextInitializedTickWithinOneWord first call (~3,000) vs warm (~1,060)
43// calculateFees first call via cold getUnstakedFee STATICCALL (~19,050) vs warm (~6,055)
44const FIRST_LOOP_OVERHEAD: i32 = 15_000;
45// Steady-state per-loop: nextInitializedTickWithinOneWord (warm) + getSqrtRatioAtTick
46// + computeSwapStep + calculateFees (warm) + toInt256x2 + EVM opcode overhead
47const LOOP_GAS_COST: i32 = 12_500;
48// cross(): updates tick fee growth and staked reward growth slots.
49// Warm ticks (previously crossed, non-zero SSTORE slots) cost ~22k; cold ticks ~76k.
50// We bias toward the cold end to prefer overestimation: 70k.
51const TICK_CROSSING_GAS_COST: i32 = 70_000;
52// When dfc.scaling_factor != 0, fee() does a TWAP binary search on the observation ring
53// buffer (~77k–91k gas) instead of a simple slot read (~18k–27k gas). This extra cost is
54// added once per swap on top of the base.
55const TWAP_FEE_OVERHEAD: i32 = 65_000;
56// Pre/post loop overhead: fee(), slot0 reads, end-of-swap writes.
57const SWAP_BASE_GAS: i32 = 125_000;
58// Conservative max gas for a single swap. Used to cap get_limits iteration.
59const MAX_SWAP_GAS: u64 = 16_700_000;
60// Maximum initialized ticks that can be crossed within MAX_SWAP_GAS.
61const MAX_TICKS_CROSSED: u64 =
62    (MAX_SWAP_GAS - SWAP_BASE_GAS as u64) / TICK_CROSSING_GAS_COST as u64;
63
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65pub struct AerodromeSlipstreamsState {
66    id: String,
67    block_timestamp: u64,
68    liquidity: u128,
69    sqrt_price: U256,
70    observation_index: u16,
71    observation_cardinality: u16,
72    default_fee: u32,
73    tick_spacing: i32,
74    tick: i32,
75    ticks: TickList,
76    observations: Observations,
77    dfc: DynamicFeeConfig,
78}
79
80impl AerodromeSlipstreamsState {
81    /// Creates a new instance of `AerodromeSlipstreamsState`.
82    ///
83    /// # Arguments
84    /// - `id`: The id of the protocol component.
85    /// - `block_timestamp`: The timestamp of the block.
86    /// - `liquidity`: The initial liquidity of the pool.
87    /// - `sqrt_price`: The square root of the current price.
88    /// - `observation_index`: The index of the current observation.
89    /// - `observation_cardinality`: The cardinality of the observation.
90    /// - `default_fee`: The default fee for the pool.
91    /// - `tick_spacing`: The tick spacing for the pool.
92    /// - `tick`: The current tick of the pool.
93    /// - `ticks`: A vector of `TickInfo` representing the tick information for the pool.
94    /// - `observations`: A vector of `Observation` representing the observation information for the
95    ///   pool.
96    /// - `dfc`: The dynamic fee configuration for the pool.
97    #[allow(clippy::too_many_arguments)]
98    pub fn new(
99        id: String,
100        block_timestamp: u64,
101        liquidity: u128,
102        sqrt_price: U256,
103        observation_index: u16,
104        observation_cardinality: u16,
105        default_fee: u32,
106        tick_spacing: i32,
107        tick: i32,
108        ticks: Vec<TickInfo>,
109        observations: Vec<Observation>,
110        dfc: DynamicFeeConfig,
111    ) -> Result<Self, SimulationError> {
112        let tick_list = TickList::from(tick_spacing as u16, ticks)?;
113        Ok(AerodromeSlipstreamsState {
114            id,
115            block_timestamp,
116            liquidity,
117            sqrt_price,
118            observation_index,
119            observation_cardinality,
120            default_fee,
121            tick_spacing,
122            tick,
123            ticks: tick_list,
124            observations: Observations::new(observations),
125            dfc,
126        })
127    }
128
129    fn get_fee(&self) -> Result<u32, SimulationError> {
130        get_dynamic_fee(
131            &self.dfc,
132            self.default_fee,
133            self.tick,
134            self.liquidity,
135            self.observation_index,
136            self.observation_cardinality,
137            &self.observations,
138            self.block_timestamp as u32,
139        )
140    }
141
142    fn swap(
143        &self,
144        zero_for_one: bool,
145        amount_specified: I256,
146        sqrt_price_limit: Option<U256>,
147    ) -> Result<SwapResults, SimulationError> {
148        if self.liquidity == 0 {
149            return Err(SimulationError::RecoverableError("No liquidity".to_string()));
150        }
151        let price_limit = if let Some(limit) = sqrt_price_limit {
152            limit
153        } else if zero_for_one {
154            safe_add_u256(MIN_SQRT_RATIO, U256::from(1u64))?
155        } else {
156            safe_sub_u256(MAX_SQRT_RATIO, U256::from(1u64))?
157        };
158
159        let price_limit_valid = if zero_for_one {
160            price_limit > MIN_SQRT_RATIO && price_limit < self.sqrt_price
161        } else {
162            price_limit < MAX_SQRT_RATIO && price_limit > self.sqrt_price
163        };
164        if !price_limit_valid {
165            return Err(SimulationError::InvalidInput("Price limit out of range".into(), None));
166        }
167
168        let exact_input = amount_specified > I256::from_raw(U256::from(0u64));
169
170        let mut state = SwapState {
171            amount_remaining: amount_specified,
172            amount_calculated: I256::from_raw(U256::from(0u64)),
173            sqrt_price: self.sqrt_price,
174            tick: self.tick,
175            liquidity: self.liquidity,
176        };
177        let twap_overhead = if self.dfc.scaling_factor() != 0 { TWAP_FEE_OVERHEAD } else { 0 };
178        let mut gas_used = U256::from((SWAP_BASE_GAS + twap_overhead) as u64);
179        let mut n_loops = 0;
180
181        let fee = self.get_fee()?;
182        while state.amount_remaining != I256::from_raw(U256::from(0u64)) &&
183            state.sqrt_price != price_limit
184        {
185            let (mut next_tick, initialized) = match self
186                .ticks
187                .next_initialized_tick_within_one_word(state.tick, zero_for_one)
188            {
189                Ok((tick, init)) => (tick, init),
190                Err(tick_err) => match tick_err.kind {
191                    TickListErrorKind::TicksExeeded => {
192                        let mut new_state = self.clone();
193                        new_state.liquidity = state.liquidity;
194                        new_state.tick = state.tick;
195                        new_state.sqrt_price = state.sqrt_price;
196                        return Err(SimulationError::InvalidInput(
197                            "Ticks exceeded".into(),
198                            Some(GetAmountOutResult::new(
199                                u256_to_biguint(state.amount_calculated.abs().into_raw()),
200                                u256_to_biguint(gas_used),
201                                Box::new(new_state),
202                            )),
203                        ));
204                    }
205                    _ => return Err(SimulationError::FatalError("Unknown error".to_string())),
206                },
207            };
208
209            next_tick = next_tick.clamp(MIN_TICK, MAX_TICK);
210
211            let sqrt_price_start = state.sqrt_price;
212            let sqrt_price_next = get_sqrt_ratio_at_tick(next_tick)?;
213            let (sqrt_price, amount_in, amount_out, fee_amount) = swap_math::compute_swap_step(
214                state.sqrt_price,
215                AerodromeSlipstreamsState::get_sqrt_ratio_target(
216                    sqrt_price_next,
217                    price_limit,
218                    zero_for_one,
219                ),
220                state.liquidity,
221                state.amount_remaining,
222                fee,
223            )?;
224            state.sqrt_price = sqrt_price;
225
226            let step = StepComputation {
227                sqrt_price_start,
228                tick_next: next_tick,
229                initialized,
230                sqrt_price_next,
231                amount_in,
232                amount_out,
233                fee_amount,
234            };
235            if exact_input {
236                state.amount_remaining -= I256::checked_from_sign_and_abs(
237                    Sign::Positive,
238                    safe_add_u256(step.amount_in, step.fee_amount)?,
239                )
240                .unwrap();
241                state.amount_calculated -=
242                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
243            } else {
244                state.amount_remaining +=
245                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
246                state.amount_calculated += I256::checked_from_sign_and_abs(
247                    Sign::Positive,
248                    safe_add_u256(step.amount_in, step.fee_amount)?,
249                )
250                .unwrap();
251            }
252            if state.sqrt_price == step.sqrt_price_next {
253                if step.initialized {
254                    let liquidity_raw = self
255                        .ticks
256                        .get_tick(step.tick_next)
257                        .unwrap()
258                        .net_liquidity;
259                    let liquidity_net = if zero_for_one { -liquidity_raw } else { liquidity_raw };
260                    state.liquidity =
261                        liquidity_math::add_liquidity_delta(state.liquidity, liquidity_net)?;
262                    gas_used = safe_add_u256(gas_used, U256::from(TICK_CROSSING_GAS_COST))?;
263                }
264                state.tick = if zero_for_one { step.tick_next - 1 } else { step.tick_next };
265            } else if state.sqrt_price != step.sqrt_price_start {
266                state.tick = get_tick_at_sqrt_ratio(state.sqrt_price)?;
267            }
268            gas_used = safe_add_u256(gas_used, U256::from(LOOP_GAS_COST))?;
269            if n_loops == 0 {
270                gas_used = safe_add_u256(gas_used, U256::from(FIRST_LOOP_OVERHEAD))?;
271            }
272            n_loops += 1;
273        }
274        Ok(SwapResults {
275            amount_calculated: state.amount_calculated,
276            amount_specified,
277            amount_remaining: state.amount_remaining,
278            sqrt_price: state.sqrt_price,
279            liquidity: state.liquidity,
280            tick: state.tick,
281            gas_used,
282        })
283    }
284
285    fn get_sqrt_ratio_target(
286        sqrt_price_next: U256,
287        sqrt_price_limit: U256,
288        zero_for_one: bool,
289    ) -> U256 {
290        let cond1 = if zero_for_one {
291            sqrt_price_next < sqrt_price_limit
292        } else {
293            sqrt_price_next > sqrt_price_limit
294        };
295
296        if cond1 {
297            sqrt_price_limit
298        } else {
299            sqrt_price_next
300        }
301    }
302}
303
304#[typetag::serde]
305impl ProtocolSim for AerodromeSlipstreamsState {
306    fn fee(&self) -> f64 {
307        match self.get_fee() {
308            Ok(fee) => fee as f64 / 1_000_000.0,
309            Err(err) => {
310                error!(
311                    pool = %self.id,
312                    block_timestamp = self.block_timestamp,
313                    %err,
314                    "Error while calculating dynamic fee"
315                );
316                f64::MAX / 1_000_000.0
317            }
318        }
319    }
320
321    fn spot_price(&self, a: &Token, b: &Token) -> Result<f64, SimulationError> {
322        let price = if a < b {
323            sqrt_price_q96_to_f64(self.sqrt_price, a.decimals, b.decimals)?
324        } else {
325            1.0f64 / sqrt_price_q96_to_f64(self.sqrt_price, b.decimals, a.decimals)?
326        };
327        Ok(add_fee_markup(price, self.get_fee()? as f64 / 1_000_000.0))
328    }
329
330    fn get_amount_out(
331        &self,
332        amount_in: BigUint,
333        token_a: &Token,
334        token_b: &Token,
335    ) -> Result<GetAmountOutResult, SimulationError> {
336        let zero_for_one = token_a < token_b;
337        let amount_specified = I256::checked_from_sign_and_abs(
338            Sign::Positive,
339            U256::from_be_slice(&amount_in.to_bytes_be()),
340        )
341        .ok_or_else(|| {
342            SimulationError::InvalidInput("I256 overflow: amount_in".to_string(), None)
343        })?;
344
345        let result = self.swap(zero_for_one, amount_specified, None)?;
346
347        trace!(?amount_in, ?token_a, ?token_b, ?zero_for_one, ?result, "SLIPSTREAMS SWAP");
348        let mut new_state = self.clone();
349        new_state.liquidity = result.liquidity;
350        new_state.tick = result.tick;
351        new_state.sqrt_price = result.sqrt_price;
352
353        Ok(GetAmountOutResult::new(
354            u256_to_biguint(
355                result
356                    .amount_calculated
357                    .abs()
358                    .into_raw(),
359            ),
360            u256_to_biguint(result.gas_used),
361            Box::new(new_state),
362        ))
363    }
364
365    fn get_limits(
366        &self,
367        token_in: Bytes,
368        token_out: Bytes,
369    ) -> Result<(BigUint, BigUint), SimulationError> {
370        // If the pool has no liquidity, return zeros for both limits
371        if self.liquidity == 0 {
372            return Ok((BigUint::zero(), BigUint::zero()));
373        }
374
375        let zero_for_one = token_in < token_out;
376        let mut current_tick = self.tick;
377        let mut current_sqrt_price = self.sqrt_price;
378        let mut current_liquidity = self.liquidity;
379        let mut total_amount_in = U256::from(0u64);
380        let mut total_amount_out = U256::from(0u64);
381
382        // Iterate through all ticks in the direction of the swap
383        // Continues until there is no more liquidity in the pool or no more ticks to process
384        let mut ticks_crossed: u64 = 0;
385        while let Ok((tick, initialized)) = self
386            .ticks
387            .next_initialized_tick_within_one_word(current_tick, zero_for_one)
388        {
389            if ticks_crossed >= MAX_TICKS_CROSSED {
390                break;
391            }
392            ticks_crossed += 1;
393            // Clamp the tick value to ensure it's within valid range
394            let next_tick = tick.clamp(MIN_TICK, MAX_TICK);
395
396            // Calculate the sqrt price at the next tick boundary
397            let sqrt_price_next = get_sqrt_ratio_at_tick(next_tick)?;
398
399            // Calculate the amount of tokens swapped when moving from current_sqrt_price to
400            // sqrt_price_next. Direction determines which token is being swapped in vs out
401            let (amount_in, amount_out) = if zero_for_one {
402                let amount0 = get_amount0_delta(
403                    sqrt_price_next,
404                    current_sqrt_price,
405                    current_liquidity,
406                    true,
407                )?;
408                let amount1 = get_amount1_delta(
409                    sqrt_price_next,
410                    current_sqrt_price,
411                    current_liquidity,
412                    false,
413                )?;
414                (amount0, amount1)
415            } else {
416                let amount0 = get_amount0_delta(
417                    sqrt_price_next,
418                    current_sqrt_price,
419                    current_liquidity,
420                    false,
421                )?;
422                let amount1 = get_amount1_delta(
423                    sqrt_price_next,
424                    current_sqrt_price,
425                    current_liquidity,
426                    true,
427                )?;
428                (amount1, amount0)
429            };
430
431            // Accumulate total amounts for this tick range
432            total_amount_in = safe_add_u256(total_amount_in, amount_in)?;
433            total_amount_out = safe_add_u256(total_amount_out, amount_out)?;
434
435            // If this tick is "initialized" (meaning its someone's position boundary), update the
436            // liquidity when crossing it
437            // For zero_for_one, liquidity is removed when crossing a tick
438            // For one_for_zero, liquidity is added when crossing a tick
439            if initialized {
440                let liquidity_raw = self
441                    .ticks
442                    .get_tick(next_tick)
443                    .unwrap()
444                    .net_liquidity;
445                let liquidity_delta = if zero_for_one { -liquidity_raw } else { liquidity_raw };
446                current_liquidity =
447                    liquidity_math::add_liquidity_delta(current_liquidity, liquidity_delta)?;
448            }
449
450            // Move to the next tick position
451            current_tick = if zero_for_one { next_tick - 1 } else { next_tick };
452            current_sqrt_price = sqrt_price_next;
453        }
454
455        Ok((u256_to_biguint(total_amount_in), u256_to_biguint(total_amount_out)))
456    }
457
458    fn delta_transition(
459        &mut self,
460        delta: ProtocolStateDelta,
461        _tokens: &HashMap<Bytes, Token>,
462        _balances: &Balances,
463    ) -> Result<(), TransitionError> {
464        if let Some(block_timestamp) = delta
465            .updated_attributes
466            .get("block_timestamp")
467        {
468            self.block_timestamp = BigInt::from_signed_bytes_be(block_timestamp)
469                .to_u64()
470                .unwrap();
471        }
472        // apply attribute changes
473        if let Some(liquidity) = delta
474            .updated_attributes
475            .get("liquidity")
476        {
477            // This is a hotfix because if the liquidity has never been updated after creation, it's
478            // currently encoded as H256::zero(), therefore, we can't decode this as u128.
479            // We can remove this once it has been fixed on the tycho side.
480            let liq_16_bytes = if liquidity.len() == 32 {
481                // Make sure it only happens for 0 values, otherwise error.
482                if liquidity == &Bytes::zero(32) {
483                    Bytes::from([0; 16])
484                } else {
485                    return Err(TransitionError::DecodeError(format!(
486                        "Liquidity bytes too long for {liquidity}, expected 16",
487                    )));
488                }
489            } else {
490                liquidity.clone()
491            };
492
493            self.liquidity = u128::from(liq_16_bytes);
494        }
495        if let Some(sqrt_price) = delta
496            .updated_attributes
497            .get("sqrt_price_x96")
498        {
499            self.sqrt_price = U256::from_be_slice(sqrt_price);
500        }
501        if let Some(observation_index) = delta
502            .updated_attributes
503            .get("observationIndex")
504        {
505            self.observation_index = u16::from(observation_index.clone());
506        }
507        if let Some(observation_cardinality) = delta
508            .updated_attributes
509            .get("observationCardinality")
510        {
511            self.observation_cardinality = u16::from(observation_cardinality.clone());
512        }
513        if let Some(default_fee) = delta
514            .updated_attributes
515            .get("default_fee")
516        {
517            self.default_fee = u32::from(default_fee.clone());
518        }
519        if let Some(dfc_base_fee) = delta
520            .updated_attributes
521            .get("dfc_baseFee")
522        {
523            self.dfc
524                .update_base_fee(u32::from(dfc_base_fee.clone()));
525        }
526        if let Some(dfc_fee_cap) = delta
527            .updated_attributes
528            .get("dfc_feeCap")
529        {
530            self.dfc
531                .update_fee_cap(u32::from(dfc_fee_cap.clone()));
532        }
533        if let Some(dfc_scaling_factor) = delta
534            .updated_attributes
535            .get("dfc_scalingFactor")
536        {
537            self.dfc
538                .update_scaling_factor(u64::from(dfc_scaling_factor.clone()));
539        }
540        if let Some(tick) = delta.updated_attributes.get("tick") {
541            // This is a hotfix because if the tick has never been updated after creation, it's
542            // currently encoded as H256::zero(), therefore, we can't decode this as i32.
543            // We can remove this once it has been fixed on the tycho side.
544            let ticks_4_bytes = if tick.len() == 32 {
545                // Make sure it only happens for 0 values, otherwise error.
546                if tick == &Bytes::zero(32) {
547                    Bytes::from([0; 4])
548                } else {
549                    return Err(TransitionError::DecodeError(format!(
550                        "Tick bytes too long for {tick}, expected 4"
551                    )));
552                }
553            } else {
554                tick.clone()
555            };
556            self.tick = i24_be_bytes_to_i32(&ticks_4_bytes);
557        }
558
559        // apply tick & observations changes
560        for (key, value) in delta.updated_attributes.iter() {
561            // tick liquidity keys are in the format "ticks/{tick_index}/net_liquidity"
562            if key.starts_with("ticks/") {
563                let parts: Vec<&str> = key.split('/').collect();
564                self.ticks
565                    .set_tick_liquidity(
566                        parts[1]
567                            .parse::<i32>()
568                            .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
569                        i128::from(value.clone()),
570                    )
571                    .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
572            }
573
574            // observations keys are in the format "observations/{observation_index}"
575            if let Some(idx_str) = key.strip_prefix("observations/") {
576                if let Ok(idx) = idx_str.parse::<i32>() {
577                    let _ = self
578                        .observations
579                        .upsert_observation(idx, value);
580                }
581            }
582        }
583        // delete ticks - ignores deletes for attributes other than tick liquidity
584        for key in delta.deleted_attributes.iter() {
585            // tick liquidity keys are in the format "ticks/{tick_index}/net_liquidity"
586            if key.starts_with("ticks/") {
587                let parts: Vec<&str> = key.split('/').collect();
588                self.ticks
589                    .set_tick_liquidity(
590                        parts[1]
591                            .parse::<i32>()
592                            .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
593                        0,
594                    )
595                    .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
596            }
597
598            // observations keys are in the format "observations/{observation_index}"
599            if let Some(idx_str) = key.strip_prefix("observations/") {
600                if let Ok(idx) = idx_str.parse::<i32>() {
601                    let _ = self
602                        .observations
603                        .upsert_observation(idx, &[]);
604                }
605            }
606        }
607        Ok(())
608    }
609
610    fn clone_box(&self) -> Box<dyn ProtocolSim> {
611        Box::new(self.clone())
612    }
613
614    fn as_any(&self) -> &dyn Any {
615        self
616    }
617
618    fn as_any_mut(&mut self) -> &mut dyn Any {
619        self
620    }
621
622    fn eq(&self, other: &dyn ProtocolSim) -> bool {
623        if let Some(other_state) = other
624            .as_any()
625            .downcast_ref::<AerodromeSlipstreamsState>()
626        {
627            let self_fee = match self.get_fee() {
628                Ok(fee) => fee,
629                Err(_) => return false,
630            };
631            let other_fee = match other_state.get_fee() {
632                Ok(fee) => fee,
633                Err(_) => return false,
634            };
635
636            self.liquidity == other_state.liquidity &&
637                self.sqrt_price == other_state.sqrt_price &&
638                self_fee == other_fee &&
639                self.tick == other_state.tick &&
640                self.ticks == other_state.ticks
641        } else {
642            false
643        }
644    }
645
646    fn query_pool_swap(
647        &self,
648        params: &tycho_common::simulation::protocol_sim::QueryPoolSwapParams,
649    ) -> Result<tycho_common::simulation::protocol_sim::PoolSwap, SimulationError> {
650        crate::evm::query_pool_swap::query_pool_swap(self, params)
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use alloy::primitives::{Sign, I256, U256};
657    use tycho_common::simulation::errors::SimulationError;
658
659    use super::*;
660    use crate::evm::protocol::utils::{
661        slipstreams::{dynamic_fee_module::DynamicFeeConfig, observations::Observation},
662        uniswap::{
663            tick_list::TickInfo,
664            tick_math::{
665                get_sqrt_ratio_at_tick, get_tick_at_sqrt_ratio, MAX_SQRT_RATIO, MIN_SQRT_RATIO,
666                MIN_TICK,
667            },
668        },
669    };
670
671    fn create_basic_test_pool() -> AerodromeSlipstreamsState {
672        let sqrt_price = get_sqrt_ratio_at_tick(0).expect("Failed to calculate sqrt price");
673        let ticks = vec![TickInfo::new(-120, 0).unwrap(), TickInfo::new(120, 0).unwrap()];
674        AerodromeSlipstreamsState::new(
675            "test-pool".to_string(),
676            1_000_000,
677            100_000_000_000_000_000_000u128,
678            sqrt_price,
679            0,
680            1,
681            3000,
682            1,
683            0,
684            ticks,
685            vec![Observation::default()],
686            DynamicFeeConfig::new(3000, 10_000, 1),
687        )
688        .expect("Failed to create pool")
689    }
690
691    #[test]
692    fn test_partial_step_updates_tick_when_price_moves_without_crossing_initialized_tick() {
693        let pool = create_basic_test_pool();
694        let amount =
695            I256::checked_from_sign_and_abs(Sign::Positive, U256::from(100_000_000_000_000_000u64))
696                .unwrap();
697
698        let result = pool
699            .swap(true, amount, None)
700            .expect("swap should stay within the current liquidity range");
701        let expected_tick =
702            get_tick_at_sqrt_ratio(result.sqrt_price).expect("new sqrt price should map to a tick");
703
704        assert_ne!(result.sqrt_price, pool.sqrt_price);
705        assert_ne!(result.sqrt_price, get_sqrt_ratio_at_tick(-120).unwrap());
706        assert_ne!(expected_tick, pool.tick);
707        assert_eq!(result.tick, expected_tick);
708    }
709
710    #[test]
711    fn test_swap_keeps_boundary_tick_when_price_does_not_move() {
712        let mut pool = create_basic_test_pool();
713        pool.tick = -1;
714        let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1u64)).unwrap();
715
716        let result = pool
717            .swap(true, amount, None)
718            .expect("swap should consume the input as fee without moving price");
719
720        assert_eq!(result.sqrt_price, pool.sqrt_price);
721        assert_eq!(get_tick_at_sqrt_ratio(result.sqrt_price).unwrap(), 0);
722        assert_eq!(result.tick, pool.tick);
723    }
724
725    #[test]
726    fn test_swap_price_limit_out_of_range_returns_error() {
727        let pool = create_basic_test_pool();
728        let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1000u64)).unwrap();
729
730        let result = pool.swap(true, amount, Some(pool.sqrt_price));
731        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
732
733        let result = pool.swap(true, amount, Some(MIN_SQRT_RATIO));
734        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
735
736        let result = pool.swap(false, amount, Some(pool.sqrt_price));
737        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
738
739        let result = pool.swap(false, amount, Some(MAX_SQRT_RATIO));
740        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
741    }
742
743    #[test]
744    fn test_swap_at_extreme_price_returns_error() {
745        let sqrt_price = MIN_SQRT_RATIO + U256::from(1u64);
746        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("Failed to calculate tick");
747        let ticks =
748            vec![TickInfo::new(MIN_TICK, 0).unwrap(), TickInfo::new(MIN_TICK + 1, 0).unwrap()];
749        let pool = AerodromeSlipstreamsState::new(
750            "test-pool".to_string(),
751            1_000_000,
752            100_000_000_000_000_000_000u128,
753            sqrt_price,
754            0,
755            1,
756            3000,
757            1,
758            tick,
759            ticks,
760            vec![Observation::default()],
761            DynamicFeeConfig::new(3000, 10_000, 1),
762        )
763        .expect("Failed to create pool");
764
765        let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1000u64)).unwrap();
766        let result = pool.swap(true, amount, None);
767        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
768    }
769}