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::BigUint;
5use num_traits::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::{
14            Balances, BlockContext, GetAmountOutResult, PoolSwap, Price, ProtocolSim,
15            QueryPoolSwapParams, SwapConstraint,
16        },
17    },
18    Bytes,
19};
20
21use crate::{
22    evm::{
23        protocol::{
24            clmm::clmm_swap_to_price,
25            safe_math::{safe_add_u256, safe_sub_u256},
26            u256_num::u256_to_biguint,
27            utils::{
28                add_fee_markup,
29                slipstreams::{
30                    dynamic_fee_module::{get_dynamic_fee, DynamicFeeConfig, ResolvedFee},
31                    observations::{Observation, Observations},
32                    raw_target_price,
33                },
34                uniswap::{
35                    i24_be_bytes_to_i32, liquidity_math,
36                    sqrt_price_math::{
37                        get_amount0_delta, get_amount1_delta, sqrt_price_q96_to_f64,
38                    },
39                    swap_math,
40                    tick_list::{TickInfo, TickList, TickListErrorKind},
41                    tick_math::{
42                        get_sqrt_ratio_at_tick, get_tick_at_sqrt_ratio, MAX_SQRT_RATIO, MAX_TICK,
43                        MIN_SQRT_RATIO, MIN_TICK,
44                    },
45                    StepComputation, SwapResults, SwapState,
46                },
47            },
48        },
49        query_pool_swap::price_to_f64_with_decimals,
50    },
51    protocol::models::BlockPositionAssumption,
52};
53
54// Cold-storage warmup on the first loop iteration:
55// nextInitializedTickWithinOneWord first call (~3,000) vs warm (~1,060)
56// calculateFees first call via cold getUnstakedFee STATICCALL (~19,050) vs warm (~6,055)
57const FIRST_LOOP_OVERHEAD: i32 = 15_000;
58// Steady-state per-loop: nextInitializedTickWithinOneWord (warm) + getSqrtRatioAtTick
59// + computeSwapStep + calculateFees (warm) + toInt256x2 + EVM opcode overhead
60const LOOP_GAS_COST: i32 = 12_500;
61// cross(): updates tick fee growth and staked reward growth slots.
62// Warm ticks (previously crossed, non-zero SSTORE slots) cost ~22k; cold ticks ~76k.
63// We bias toward the cold end to prefer overestimation: 70k.
64const TICK_CROSSING_GAS_COST: i32 = 70_000;
65// When dfc.scaling_factor != 0, fee() does a TWAP binary search on the observation ring
66// buffer (~77k–91k gas) instead of a simple slot read (~18k–27k gas). This extra cost is
67// added once per swap on top of the base.
68const TWAP_FEE_OVERHEAD: i32 = 65_000;
69// Pre/post loop overhead: fee(), slot0 reads, end-of-swap writes.
70const SWAP_BASE_GAS: i32 = 125_000;
71// Conservative max gas for a single swap. Used to cap get_limits iteration.
72const MAX_SWAP_GAS: u64 = 16_700_000;
73// Maximum initialized ticks that can be crossed within MAX_SWAP_GAS.
74const MAX_TICKS_CROSSED: u64 =
75    (MAX_SWAP_GAS - SWAP_BASE_GAS as u64) / TICK_CROSSING_GAS_COST as u64;
76
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78pub struct AerodromeSlipstreamsState {
79    id: String,
80    /// Timestamp of the block a quote against this state is expected to execute in.
81    ///
82    /// Maintained by the stream decoder via [`ProtocolSim::apply_block`], not decoded from
83    /// the pool: the fee module's initial-vs-dynamic branch keys on the *execution* block, which
84    /// is the next block for a confirmed update and the still-open block for a flashblock
85    /// update.
86    execution_block_timestamp: u64,
87    liquidity: u128,
88    sqrt_price: U256,
89    observation_index: u16,
90    observation_cardinality: u16,
91    default_fee: u32,
92    tick_spacing: i32,
93    tick: i32,
94    ticks: TickList,
95    observations: Observations,
96    dfc: DynamicFeeConfig,
97    /// What quotes may assume about the swap's position within its execution block; see
98    /// [`BlockPositionAssumption`].
99    position_assumption: BlockPositionAssumption,
100}
101
102impl AerodromeSlipstreamsState {
103    /// Creates a new instance of `AerodromeSlipstreamsState`.
104    ///
105    /// # Arguments
106    /// - `id`: The id of the protocol component.
107    /// - `execution_block_timestamp`: Timestamp of the block a quote is expected to execute in.
108    /// - `liquidity`: The initial liquidity of the pool.
109    /// - `sqrt_price`: The square root of the current price.
110    /// - `observation_index`: The index of the current observation.
111    /// - `observation_cardinality`: The cardinality of the observation.
112    /// - `default_fee`: The default fee for the pool.
113    /// - `tick_spacing`: The tick spacing for the pool.
114    /// - `tick`: The current tick of the pool.
115    /// - `ticks`: A vector of `TickInfo` representing the tick information for the pool.
116    /// - `observations`: A vector of `Observation` representing the observation information for the
117    ///   pool.
118    /// - `dfc`: The dynamic fee configuration for the pool.
119    #[allow(clippy::too_many_arguments)]
120    pub fn new(
121        id: String,
122        execution_block_timestamp: u64,
123        liquidity: u128,
124        sqrt_price: U256,
125        observation_index: u16,
126        observation_cardinality: u16,
127        default_fee: u32,
128        tick_spacing: i32,
129        tick: i32,
130        ticks: Vec<TickInfo>,
131        observations: Vec<Observation>,
132        dfc: DynamicFeeConfig,
133    ) -> Result<Self, SimulationError> {
134        let tick_list = TickList::from(tick_spacing as u16, ticks)?;
135        Ok(AerodromeSlipstreamsState {
136            id,
137            execution_block_timestamp,
138            liquidity,
139            sqrt_price,
140            observation_index,
141            observation_cardinality,
142            default_fee,
143            tick_spacing,
144            tick,
145            ticks: tick_list,
146            observations: Observations::new(observations),
147            dfc,
148            position_assumption: BlockPositionAssumption::default(),
149        })
150    }
151
152    /// Sets what quotes assume about the swap's position within its execution block.
153    ///
154    /// A consumer-side preference, independent of the pool's on-chain state.
155    pub fn with_position_assumption(mut self, assumption: BlockPositionAssumption) -> Self {
156        self.position_assumption = assumption;
157        self
158    }
159
160    fn get_fee(&self) -> Result<ResolvedFee, SimulationError> {
161        get_dynamic_fee(
162            &self.dfc,
163            self.default_fee,
164            self.tick,
165            self.liquidity,
166            self.observation_index,
167            self.observation_cardinality,
168            &self.observations,
169            self.execution_block_timestamp as u32,
170            self.position_assumption == BlockPositionAssumption::First,
171        )
172    }
173
174    /// Records the observation the pool would write for a swap that moved the tick from
175    /// `self.tick` to `post_swap_tick`, so that a second swap chained onto this state in the same
176    /// block resolves the dynamic fee instead of the initial fee.
177    ///
178    /// Mirrors `CLPool.swap`, which writes only when the tick moved and passes the pre-swap tick
179    /// and liquidity. Must be called before the caller overwrites `tick`/`liquidity`.
180    fn record_observation(&mut self, post_swap_tick: i32) -> Result<(), SimulationError> {
181        if post_swap_tick == self.tick {
182            return Ok(());
183        }
184        self.observation_index = self.observations.write(
185            self.observation_index,
186            self.execution_block_timestamp as u32,
187            self.tick,
188            self.liquidity,
189            self.observation_cardinality,
190        )?;
191        Ok(())
192    }
193
194    /// Swaps token_in until `spot_price(token_in, token_out)` reaches the middle of the band
195    /// `[target, target * (1 + tolerance)]`. Falls back to numerical search if recording the
196    /// observation changes the fee enough to move the final price outside the band.
197    fn swap_to_target_price(
198        &self,
199        params: &QueryPoolSwapParams,
200        target: &Price,
201        tolerance: f64,
202    ) -> Result<PoolSwap, SimulationError> {
203        let token_in = params.token_in();
204        let token_out = params.token_out();
205        let target_f64 = price_to_f64_with_decimals(target, token_in.decimals, token_out.decimals)?;
206        if target_f64 == self.spot_price(token_in, token_out)? {
207            return Ok(PoolSwap::new(BigUint::ZERO, BigUint::ZERO, Box::new(self.clone()), None));
208        }
209        let Some(limit) = raw_target_price(target, tolerance, self.get_fee()?.fee) else {
210            return crate::evm::query_pool_swap::query_pool_swap(self, params);
211        };
212        // `limit` already strips the fee that `spot_price` adds, so the swap uses fee 0.
213        let Ok((amount_in, amount_out, result)) = clmm_swap_to_price(
214            self.sqrt_price,
215            &token_in.address,
216            &token_out.address,
217            &limit,
218            0,
219            Sign::Positive,
220            |zero_for_one, amount_specified, sqrt_price_limit| {
221                self.swap(zero_for_one, amount_specified, Some(sqrt_price_limit))
222            },
223        ) else {
224            return crate::evm::query_pool_swap::query_pool_swap(self, params);
225        };
226
227        let mut new_state = self.clone();
228        if !amount_in.is_zero() {
229            new_state.record_observation(result.tick)?;
230            new_state.liquidity = result.liquidity;
231            new_state.tick = result.tick;
232            new_state.sqrt_price = result.sqrt_price;
233        }
234        let final_spot = new_state.spot_price(token_in, token_out)?;
235        if final_spot < target_f64 || final_spot > target_f64 * (1.0 + tolerance) {
236            return crate::evm::query_pool_swap::query_pool_swap(self, params);
237        }
238        Ok(PoolSwap::new(amount_in, amount_out, Box::new(new_state), None))
239    }
240
241    fn swap(
242        &self,
243        zero_for_one: bool,
244        amount_specified: I256,
245        sqrt_price_limit: Option<U256>,
246    ) -> Result<SwapResults, SimulationError> {
247        if self.liquidity == 0 {
248            return Err(SimulationError::RecoverableError("No liquidity".to_string()));
249        }
250        let price_limit = if let Some(limit) = sqrt_price_limit {
251            limit
252        } else if zero_for_one {
253            safe_add_u256(MIN_SQRT_RATIO, U256::from(1u64))?
254        } else {
255            safe_sub_u256(MAX_SQRT_RATIO, U256::from(1u64))?
256        };
257
258        let price_limit_valid = if zero_for_one {
259            price_limit > MIN_SQRT_RATIO && price_limit < self.sqrt_price
260        } else {
261            price_limit < MAX_SQRT_RATIO && price_limit > self.sqrt_price
262        };
263        if !price_limit_valid {
264            return Err(SimulationError::InvalidInput("Price limit out of range".into(), None));
265        }
266
267        let exact_input = amount_specified > I256::from_raw(U256::from(0u64));
268
269        let mut state = SwapState {
270            amount_remaining: amount_specified,
271            amount_calculated: I256::from_raw(U256::from(0u64)),
272            sqrt_price: self.sqrt_price,
273            tick: self.tick,
274            liquidity: self.liquidity,
275        };
276        let resolved_fee = self.get_fee()?;
277        let twap_overhead = if resolved_fee.observed_twap { TWAP_FEE_OVERHEAD } else { 0 };
278        let mut gas_used = U256::from((SWAP_BASE_GAS + twap_overhead) as u64);
279        let mut n_loops = 0;
280
281        let fee = resolved_fee.fee;
282        while state.amount_remaining != I256::from_raw(U256::from(0u64)) &&
283            state.sqrt_price != price_limit
284        {
285            let (mut next_tick, initialized) = match self
286                .ticks
287                .next_initialized_tick_within_one_word(state.tick, zero_for_one)
288            {
289                Ok((tick, init)) => (tick, init),
290                Err(tick_err) => match tick_err.kind {
291                    TickListErrorKind::TicksExeeded => {
292                        let mut new_state = self.clone();
293                        // Best effort in an error path: a failed write only degrades the fee of
294                        // a chained simulation on this partial result, and must not mask the
295                        // more informative TicksExceeded error below.
296                        if let Err(record_err) = new_state.record_observation(state.tick) {
297                            trace!(%record_err, "skipping observation write on partial result");
298                        }
299                        new_state.liquidity = state.liquidity;
300                        new_state.tick = state.tick;
301                        new_state.sqrt_price = state.sqrt_price;
302                        return Err(SimulationError::InvalidInput(
303                            "Ticks exceeded".into(),
304                            Some(GetAmountOutResult::new(
305                                u256_to_biguint(state.amount_calculated.abs().into_raw()),
306                                u256_to_biguint(gas_used),
307                                Box::new(new_state),
308                            )),
309                        ));
310                    }
311                    _ => return Err(SimulationError::FatalError("Unknown error".to_string())),
312                },
313            };
314
315            next_tick = next_tick.clamp(MIN_TICK, MAX_TICK);
316
317            let sqrt_price_start = state.sqrt_price;
318            let sqrt_price_next = get_sqrt_ratio_at_tick(next_tick)?;
319            let (sqrt_price, amount_in, amount_out, fee_amount) = swap_math::compute_swap_step(
320                state.sqrt_price,
321                AerodromeSlipstreamsState::get_sqrt_ratio_target(
322                    sqrt_price_next,
323                    price_limit,
324                    zero_for_one,
325                ),
326                state.liquidity,
327                state.amount_remaining,
328                fee,
329            )?;
330            state.sqrt_price = sqrt_price;
331
332            let step = StepComputation {
333                sqrt_price_start,
334                tick_next: next_tick,
335                initialized,
336                sqrt_price_next,
337                amount_in,
338                amount_out,
339                fee_amount,
340            };
341            if exact_input {
342                state.amount_remaining -= I256::checked_from_sign_and_abs(
343                    Sign::Positive,
344                    safe_add_u256(step.amount_in, step.fee_amount)?,
345                )
346                .unwrap();
347                state.amount_calculated -=
348                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
349            } else {
350                state.amount_remaining +=
351                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
352                state.amount_calculated += I256::checked_from_sign_and_abs(
353                    Sign::Positive,
354                    safe_add_u256(step.amount_in, step.fee_amount)?,
355                )
356                .unwrap();
357            }
358            if state.sqrt_price == step.sqrt_price_next {
359                if step.initialized {
360                    let liquidity_raw = self
361                        .ticks
362                        .get_tick(step.tick_next)
363                        .unwrap()
364                        .net_liquidity;
365                    let liquidity_net = if zero_for_one { -liquidity_raw } else { liquidity_raw };
366                    state.liquidity =
367                        liquidity_math::add_liquidity_delta(state.liquidity, liquidity_net)?;
368                    gas_used = safe_add_u256(gas_used, U256::from(TICK_CROSSING_GAS_COST))?;
369                }
370                state.tick = if zero_for_one { step.tick_next - 1 } else { step.tick_next };
371            } else if state.sqrt_price != step.sqrt_price_start {
372                state.tick = get_tick_at_sqrt_ratio(state.sqrt_price)?;
373            }
374            gas_used = safe_add_u256(gas_used, U256::from(LOOP_GAS_COST))?;
375            if n_loops == 0 {
376                gas_used = safe_add_u256(gas_used, U256::from(FIRST_LOOP_OVERHEAD))?;
377            }
378            n_loops += 1;
379        }
380        Ok(SwapResults {
381            amount_calculated: state.amount_calculated,
382            amount_specified,
383            amount_remaining: state.amount_remaining,
384            sqrt_price: state.sqrt_price,
385            liquidity: state.liquidity,
386            tick: state.tick,
387            gas_used,
388        })
389    }
390
391    fn get_sqrt_ratio_target(
392        sqrt_price_next: U256,
393        sqrt_price_limit: U256,
394        zero_for_one: bool,
395    ) -> U256 {
396        let cond1 = if zero_for_one {
397            sqrt_price_next < sqrt_price_limit
398        } else {
399            sqrt_price_next > sqrt_price_limit
400        };
401
402        if cond1 {
403            sqrt_price_limit
404        } else {
405            sqrt_price_next
406        }
407    }
408}
409
410#[typetag::serde]
411impl ProtocolSim for AerodromeSlipstreamsState {
412    fn fee(&self) -> f64 {
413        match self.get_fee() {
414            Ok(resolved) => resolved.fee as f64 / 1_000_000.0,
415            Err(err) => {
416                error!(
417                    pool = %self.id,
418                    execution_block_timestamp = self.execution_block_timestamp,
419                    %err,
420                    "Error while calculating dynamic fee"
421                );
422                f64::MAX / 1_000_000.0
423            }
424        }
425    }
426
427    fn spot_price(&self, a: &Token, b: &Token) -> Result<f64, SimulationError> {
428        let price = if a < b {
429            sqrt_price_q96_to_f64(self.sqrt_price, a.decimals, b.decimals)?
430        } else {
431            1.0f64 / sqrt_price_q96_to_f64(self.sqrt_price, b.decimals, a.decimals)?
432        };
433        Ok(add_fee_markup(price, self.get_fee()?.fee as f64 / 1_000_000.0))
434    }
435
436    fn get_amount_out(
437        &self,
438        amount_in: BigUint,
439        token_a: &Token,
440        token_b: &Token,
441    ) -> Result<GetAmountOutResult, SimulationError> {
442        let zero_for_one = token_a < token_b;
443        let amount_specified = I256::checked_from_sign_and_abs(
444            Sign::Positive,
445            U256::from_be_slice(&amount_in.to_bytes_be()),
446        )
447        .ok_or_else(|| {
448            SimulationError::InvalidInput("I256 overflow: amount_in".to_string(), None)
449        })?;
450
451        let result = self.swap(zero_for_one, amount_specified, None)?;
452
453        trace!(?amount_in, ?token_a, ?token_b, ?zero_for_one, ?result, "SLIPSTREAMS SWAP");
454        let mut new_state = self.clone();
455        new_state.record_observation(result.tick)?;
456        new_state.liquidity = result.liquidity;
457        new_state.tick = result.tick;
458        new_state.sqrt_price = result.sqrt_price;
459
460        Ok(GetAmountOutResult::new(
461            u256_to_biguint(
462                result
463                    .amount_calculated
464                    .abs()
465                    .into_raw(),
466            ),
467            u256_to_biguint(result.gas_used),
468            Box::new(new_state),
469        ))
470    }
471
472    fn get_limits(
473        &self,
474        token_in: Bytes,
475        token_out: Bytes,
476    ) -> Result<(BigUint, BigUint), SimulationError> {
477        // If the pool has no liquidity, return zeros for both limits
478        if self.liquidity == 0 {
479            return Ok((BigUint::zero(), BigUint::zero()));
480        }
481
482        let zero_for_one = token_in < token_out;
483        let mut current_tick = self.tick;
484        let mut current_sqrt_price = self.sqrt_price;
485        let mut current_liquidity = self.liquidity;
486        let mut total_amount_in = U256::from(0u64);
487        let mut total_amount_out = U256::from(0u64);
488
489        // Iterate through all ticks in the direction of the swap
490        // Continues until there is no more liquidity in the pool or no more ticks to process
491        let mut ticks_crossed: u64 = 0;
492        while let Ok((tick, initialized)) = self
493            .ticks
494            .next_initialized_tick_within_one_word(current_tick, zero_for_one)
495        {
496            if ticks_crossed >= MAX_TICKS_CROSSED {
497                break;
498            }
499            ticks_crossed += 1;
500            // Clamp the tick value to ensure it's within valid range
501            let next_tick = tick.clamp(MIN_TICK, MAX_TICK);
502
503            // Calculate the sqrt price at the next tick boundary
504            let sqrt_price_next = get_sqrt_ratio_at_tick(next_tick)?;
505
506            // Calculate the amount of tokens swapped when moving from current_sqrt_price to
507            // sqrt_price_next. Direction determines which token is being swapped in vs out
508            let (amount_in, amount_out) = if zero_for_one {
509                let amount0 = get_amount0_delta(
510                    sqrt_price_next,
511                    current_sqrt_price,
512                    current_liquidity,
513                    true,
514                )?;
515                let amount1 = get_amount1_delta(
516                    sqrt_price_next,
517                    current_sqrt_price,
518                    current_liquidity,
519                    false,
520                )?;
521                (amount0, amount1)
522            } else {
523                let amount0 = get_amount0_delta(
524                    sqrt_price_next,
525                    current_sqrt_price,
526                    current_liquidity,
527                    false,
528                )?;
529                let amount1 = get_amount1_delta(
530                    sqrt_price_next,
531                    current_sqrt_price,
532                    current_liquidity,
533                    true,
534                )?;
535                (amount1, amount0)
536            };
537
538            // Accumulate total amounts for this tick range
539            total_amount_in = safe_add_u256(total_amount_in, amount_in)?;
540            total_amount_out = safe_add_u256(total_amount_out, amount_out)?;
541
542            // If this tick is "initialized" (meaning its someone's position boundary), update the
543            // liquidity when crossing it
544            // For zero_for_one, liquidity is removed when crossing a tick
545            // For one_for_zero, liquidity is added when crossing a tick
546            if initialized {
547                let liquidity_raw = self
548                    .ticks
549                    .get_tick(next_tick)
550                    .unwrap()
551                    .net_liquidity;
552                let liquidity_delta = if zero_for_one { -liquidity_raw } else { liquidity_raw };
553                current_liquidity =
554                    liquidity_math::add_liquidity_delta(current_liquidity, liquidity_delta)?;
555            }
556
557            // Move to the next tick position
558            current_tick = if zero_for_one { next_tick - 1 } else { next_tick };
559            current_sqrt_price = sqrt_price_next;
560        }
561
562        Ok((u256_to_biguint(total_amount_in), u256_to_biguint(total_amount_out)))
563    }
564
565    fn delta_transition(
566        &mut self,
567        delta: ProtocolStateDelta,
568        _tokens: &HashMap<Bytes, Token>,
569        _balances: &Balances,
570    ) -> Result<(), TransitionError> {
571        // apply attribute changes
572        if let Some(liquidity) = delta
573            .updated_attributes
574            .get("liquidity")
575        {
576            // This is a hotfix because if the liquidity has never been updated after creation, it's
577            // currently encoded as H256::zero(), therefore, we can't decode this as u128.
578            // We can remove this once it has been fixed on the tycho side.
579            let liq_16_bytes = if liquidity.len() == 32 {
580                // Make sure it only happens for 0 values, otherwise error.
581                if liquidity == &Bytes::zero(32) {
582                    Bytes::from([0; 16])
583                } else {
584                    return Err(TransitionError::DecodeError(format!(
585                        "Liquidity bytes too long for {liquidity}, expected 16",
586                    )));
587                }
588            } else {
589                liquidity.clone()
590            };
591
592            self.liquidity = u128::from(liq_16_bytes);
593        }
594        if let Some(sqrt_price) = delta
595            .updated_attributes
596            .get("sqrt_price_x96")
597        {
598            self.sqrt_price = U256::from_be_slice(sqrt_price);
599        }
600        if let Some(observation_index) = delta
601            .updated_attributes
602            .get("observationIndex")
603        {
604            self.observation_index = u16::from(observation_index.clone());
605        }
606        if let Some(observation_cardinality) = delta
607            .updated_attributes
608            .get("observationCardinality")
609        {
610            self.observation_cardinality = u16::from(observation_cardinality.clone());
611        }
612        if let Some(default_fee) = delta
613            .updated_attributes
614            .get("default_fee")
615        {
616            self.default_fee = u32::from(default_fee.clone());
617        }
618        self.dfc
619            .update_from_attributes(&delta.updated_attributes)
620            .map_err(|err| {
621                TransitionError::DecodeError(format!(
622                    "Failed to update dynamic fee module config: {err}"
623                ))
624            })?;
625        if let Some(tick) = delta.updated_attributes.get("tick") {
626            // This is a hotfix because if the tick has never been updated after creation, it's
627            // currently encoded as H256::zero(), therefore, we can't decode this as i32.
628            // We can remove this once it has been fixed on the tycho side.
629            let ticks_4_bytes = if tick.len() == 32 {
630                // Make sure it only happens for 0 values, otherwise error.
631                if tick == &Bytes::zero(32) {
632                    Bytes::from([0; 4])
633                } else {
634                    return Err(TransitionError::DecodeError(format!(
635                        "Tick bytes too long for {tick}, expected 4"
636                    )));
637                }
638            } else {
639                tick.clone()
640            };
641            self.tick = i24_be_bytes_to_i32(&ticks_4_bytes);
642        }
643
644        // apply tick & observations changes
645        for (key, value) in delta.updated_attributes.iter() {
646            // tick liquidity keys are in the format "ticks/{tick_index}/net_liquidity"
647            if key.starts_with("ticks/") {
648                let parts: Vec<&str> = key.split('/').collect();
649                self.ticks
650                    .set_tick_liquidity(
651                        parts[1]
652                            .parse::<i32>()
653                            .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
654                        i128::from(value.clone()),
655                    )
656                    .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
657            }
658
659            // observations keys are in the format "observations/{observation_index}"
660            if let Some(idx_str) = key.strip_prefix("observations/") {
661                if let Ok(idx) = idx_str.parse::<i32>() {
662                    let _ = self
663                        .observations
664                        .upsert_observation(idx, value);
665                }
666            }
667        }
668        // delete ticks - ignores deletes for attributes other than tick liquidity
669        for key in delta.deleted_attributes.iter() {
670            // tick liquidity keys are in the format "ticks/{tick_index}/net_liquidity"
671            if key.starts_with("ticks/") {
672                let parts: Vec<&str> = key.split('/').collect();
673                self.ticks
674                    .set_tick_liquidity(
675                        parts[1]
676                            .parse::<i32>()
677                            .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
678                        0,
679                    )
680                    .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
681            }
682
683            // observations keys are in the format "observations/{observation_index}"
684            if let Some(idx_str) = key.strip_prefix("observations/") {
685                if let Ok(idx) = idx_str.parse::<i32>() {
686                    let _ = self
687                        .observations
688                        .upsert_observation(idx, &[]);
689                }
690            }
691        }
692        Ok(())
693    }
694
695    /// Re-emits only when the resolved fee actually changed: idle pools whose initial-vs-dynamic
696    /// branch stays put return `false` indefinitely, and same-block flashblocks short-circuit on
697    /// the unchanged timestamp.
698    fn apply_block(&mut self, block: &BlockContext) -> bool {
699        let timestamp = block.timestamp();
700        if timestamp == self.execution_block_timestamp {
701            return false;
702        }
703        let fee_before = self.get_fee().ok();
704        self.execution_block_timestamp = timestamp;
705        fee_before != self.get_fee().ok()
706    }
707
708    fn clone_box(&self) -> Box<dyn ProtocolSim> {
709        Box::new(self.clone())
710    }
711
712    fn as_any(&self) -> &dyn Any {
713        self
714    }
715
716    fn as_any_mut(&mut self) -> &mut dyn Any {
717        self
718    }
719
720    fn eq(&self, other: &dyn ProtocolSim) -> bool {
721        if let Some(other_state) = other
722            .as_any()
723            .downcast_ref::<AerodromeSlipstreamsState>()
724        {
725            let self_fee = match self.get_fee() {
726                Ok(fee) => fee,
727                Err(_) => return false,
728            };
729            let other_fee = match other_state.get_fee() {
730                Ok(fee) => fee,
731                Err(_) => return false,
732            };
733
734            self.liquidity == other_state.liquidity &&
735                self.sqrt_price == other_state.sqrt_price &&
736                self_fee == other_fee &&
737                self.tick == other_state.tick &&
738                self.ticks == other_state.ticks
739        } else {
740            false
741        }
742    }
743
744    /// Answers [`SwapConstraint::PoolTargetPrice`] with one native swap and no `price_points`.
745    /// [`SwapConstraint::TradeLimitPrice`] uses the numerical search.
746    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
747        match params.swap_constraint() {
748            SwapConstraint::TradeLimitPrice { .. } => {
749                crate::evm::query_pool_swap::query_pool_swap(self, params)
750            }
751            SwapConstraint::PoolTargetPrice {
752                target,
753                tolerance,
754                min_amount_in: _,
755                max_amount_in: _,
756            } => {
757                let swap = self.swap_to_target_price(params, target, *tolerance)?;
758                let target = price_to_f64_with_decimals(
759                    target,
760                    params.token_in().decimals,
761                    params.token_out().decimals,
762                )?;
763                let spot = swap
764                    .new_state()
765                    .spot_price(params.token_in(), params.token_out())?;
766                if !tolerance.is_finite() ||
767                    *tolerance < 0.0 ||
768                    !(spot >= target && spot <= target * (1.0 + tolerance))
769                {
770                    return Err(SimulationError::RecoverableError(
771                        "Aerodrome target-price search ended outside the requested tolerance"
772                            .into(),
773                    ));
774                }
775                Ok(swap)
776            }
777        }
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use std::str::FromStr;
784
785    use alloy::primitives::{Sign, I256, U256};
786    use rstest::rstest;
787    use tycho_common::{models::Chain, simulation::errors::SimulationError};
788
789    use super::*;
790    use crate::evm::{
791        protocol::utils::{
792            slipstreams::{
793                dynamic_fee_module::{DynamicFeeConfig, ZERO_FEE_INDICATOR},
794                observations::Observation,
795            },
796            uniswap::{
797                tick_list::TickInfo,
798                tick_math::{
799                    get_sqrt_ratio_at_tick, get_tick_at_sqrt_ratio, MAX_SQRT_RATIO, MIN_SQRT_RATIO,
800                    MIN_TICK,
801                },
802            },
803        },
804        query_pool_swap::test_helpers::{target_price_params, to_price},
805    };
806
807    fn create_basic_test_pool() -> AerodromeSlipstreamsState {
808        let sqrt_price = get_sqrt_ratio_at_tick(0).expect("Failed to calculate sqrt price");
809        let ticks = vec![TickInfo::new(-120, 0).unwrap(), TickInfo::new(120, 0).unwrap()];
810        AerodromeSlipstreamsState::new(
811            "test-pool".to_string(),
812            1_000_000,
813            100_000_000_000_000_000_000u128,
814            sqrt_price,
815            0,
816            1,
817            3000,
818            1,
819            0,
820            ticks,
821            vec![Observation::default()],
822            DynamicFeeConfig::new(3000, 10_000, 1, false, 0),
823        )
824        .expect("Failed to create pool")
825    }
826
827    fn dynamic_fee_delta(dynamic_fee_module: [u8; 20]) -> ProtocolStateDelta {
828        ProtocolStateDelta {
829            component_id: "test-pool".to_string(),
830            updated_attributes: HashMap::from([
831                ("dynamic_fee_module".to_string(), Bytes::from(dynamic_fee_module)),
832                ("dfc_baseFee".to_string(), Bytes::from(500_u32.to_be_bytes())),
833                ("dfc_scalingFactor".to_string(), Bytes::from(0_u64.to_be_bytes())),
834                ("dfc_feeCap".to_string(), Bytes::from(700_u32.to_be_bytes())),
835                ("dfc_initialFeeEnabled".to_string(), Bytes::from([0_u8])),
836                ("dfc_initialFee".to_string(), Bytes::from(0_u32.to_be_bytes())),
837            ]),
838            ..Default::default()
839        }
840    }
841
842    /// Pool whose last swap wrote an observation at `last_observation_ts`, with the initial fee
843    /// enabled (750 pips) and a dynamic component on top of a 2700 pip base.
844    ///
845    /// Built with the first-in-block assumption on: most tests here exercise the optimistic
846    /// path. The worst-case-default tests switch it back to `BlockPositionAssumption::WorstCase`.
847    fn initial_fee_pool(last_observation_ts: u32) -> AerodromeSlipstreamsState {
848        let mut pool = create_basic_test_pool();
849        pool.dfc = DynamicFeeConfig::new(2700, 30_000, 0, true, 750);
850        pool.position_assumption = BlockPositionAssumption::First;
851        pool.observations = Observations::new(vec![Observation {
852            block_timestamp: last_observation_ts,
853            initialized: true,
854            index: 0,
855            ..Default::default()
856        }]);
857        pool
858    }
859
860    /// Replays Base block 50166683 on pool 0xdFe5F275020def30993f042174Fc2D335678b626
861    /// (AERO/cbBTC), the pair of swaps from the original report:
862    ///
863    /// - tx 0x3b0a96e9bb376d74b4b99d651336c790b2b2b65a660491c28cae3df1a5d69def (index 67), the
864    ///   block's first tick-moving swap, paid the 750 pip initial fee;
865    /// - tx 0xe934500efe7f9ef56370daf4859c21c3a439d998a80b5bd2e5a117e3045021e1 (index 154) paid the
866    ///   2700 pip dynamic fee.
867    ///
868    /// Pool state is reconstructed from archive RPC at the parent block 50166682 (slot0,
869    /// liquidity, observations[213], DynamicSwapFeeModule config); swap amounts come from the
870    /// on-chain Swap events. Both outputs must match wei-exact, and the end-of-block oracle
871    /// index must match the chain (213 -> 214: exactly one observation written).
872    #[test]
873    fn replays_base_block_50166683_swap_pair_wei_exact() {
874        let mut observations: Vec<Observation> = (0..213)
875            .map(|index| Observation { index, ..Default::default() })
876            .collect();
877        observations.push(Observation {
878            block_timestamp: 1_787_122_711, // == parent block ts: the pool traded in that block
879            tick_cumulative: -18_995_710_863_218,
880            seconds_per_liquidity_cumulative_x128: U256::from_str(
881                "42501948193164408449462610706599523891176959",
882            )
883            .unwrap(),
884            initialized: true,
885            index: 213,
886        });
887
888        let mut pool = AerodromeSlipstreamsState::new(
889            "0xdFe5F275020def30993f042174Fc2D335678b626".to_string(),
890            1_787_122_711, // seed: decoded at the parent block
891            1_128_781_556_759_264_064u128,
892            U256::from_str("1979649713595747421731").unwrap(),
893            213,
894            360,
895            2700, // tickSpacingToFee(200)
896            200,
897            -350_116,
898            // No initialized tick is crossed (liquidity is unchanged across both swaps);
899            // zero-net bounds outside the traversed range stand in for the full tick map.
900            vec![TickInfo::new(-351_000, 0).unwrap(), TickInfo::new(-349_000, 0).unwrap()],
901            observations,
902            DynamicFeeConfig::new(2700, 0, 0, true, 750),
903        )
904        .expect("state should build")
905        // The replayed swap was in fact the block's first: the optimistic mode reproduces it.
906        .with_position_assumption(BlockPositionAssumption::First);
907
908        // The quotes execute in block 50166683 (ts 1_787_122_713).
909        assert!(pool.apply_block(&BlockContext::new(50_166_683, 1_787_122_713)));
910
911        let aero = Token::new(
912            &Bytes::from_str("0x940181a94A35A4569E4529A3CDfB74e38FD98631").unwrap(),
913            "AERO",
914            18,
915            0,
916            &[Some(10_000)],
917            Chain::Base,
918            100,
919        );
920        let cbbtc = Token::new(
921            &Bytes::from_str("0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf").unwrap(),
922            "cbBTC",
923            8,
924            0,
925            &[Some(10_000)],
926            Chain::Base,
927            100,
928        );
929
930        assert_eq!(pool.fee(), 750.0 / 1_000_000.0);
931        let first = pool
932            .get_amount_out(BigUint::from(1_688_626u32), &cbbtc, &aero)
933            .expect("first swap should succeed");
934        assert_eq!(first.amount, BigUint::from(2_702_489_253_591_513_843_346u128));
935
936        assert_eq!(first.new_state.fee(), 2700.0 / 1_000_000.0);
937        let second = first
938            .new_state
939            .get_amount_out(BigUint::from(450_733u32), &cbbtc, &aero)
940            .expect("second swap should succeed");
941        assert_eq!(second.amount, BigUint::from(719_894_300_964_297_656_776u128));
942
943        let replayed = first
944            .new_state
945            .as_any()
946            .downcast_ref::<AerodromeSlipstreamsState>()
947            .expect("state type");
948        assert_eq!(replayed.observation_index, 214, "chain slot0 shows 214 after the block");
949        assert_eq!(
950            replayed
951                .observations
952                .timestamp_at(214, 360)
953                .unwrap(),
954            1_787_122_713
955        );
956    }
957
958    #[test]
959    fn ticks_exceeded_partial_result_still_records_the_observation() {
960        // The partial result carried inside the TicksExceeded error must price a chained swap
961        // with the dynamic fee, exactly like a successful swap's new_state.
962        let mut pool = initial_fee_pool(1_000);
963        pool.apply_block(&BlockContext::new(101, 1_002));
964        let token_a =
965            Token::new(&Bytes::from([0x11; 20]), "A", 18, 0, &[Some(10_000)], Chain::Base, 100);
966        let token_b =
967            Token::new(&Bytes::from([0x22; 20]), "B", 18, 0, &[Some(10_000)], Chain::Base, 100);
968
969        let err = pool
970            .get_amount_out(
971                BigUint::from(1_000_000_000_000_000_000_000_000u128),
972                &token_a,
973                &token_b,
974            )
975            .expect_err("swap must exhaust the tick list");
976        let SimulationError::InvalidInput(_, Some(partial)) = err else {
977            panic!("expected a partial result, got {err:?}");
978        };
979
980        assert_eq!(partial.new_state.fee(), 2700.0 / 1_000_000.0);
981    }
982
983    #[test]
984    fn default_quotes_the_worse_fee_when_position_is_unknown() {
985        // Without the first-in-block assumption the quote must never over-state the output:
986        // before the pool is touched in the execution block, the worse of the two branches
987        // (here the 2700 dynamic fee) applies — which is also the pre-fix behavior.
988        let mut pool = initial_fee_pool(1_000);
989        pool.position_assumption = BlockPositionAssumption::WorstCase;
990        pool.apply_block(&BlockContext::new(101, 1_002));
991
992        assert_eq!(
993            pool.get_fee()
994                .expect("fee should be computable")
995                .fee,
996            2700
997        );
998    }
999
1000    #[test]
1001    fn worst_case_picks_the_initial_fee_when_it_is_the_higher_one() {
1002        // Nothing stops a pool from configuring initialFee above its dynamic fee, so the worst
1003        // case is max(initial, dynamic).
1004        let mut pool = initial_fee_pool(1_000);
1005        pool.dfc = DynamicFeeConfig::new(500, 30_000, 0, true, 4_000);
1006        pool.position_assumption = BlockPositionAssumption::WorstCase;
1007        pool.apply_block(&BlockContext::new(101, 1_002));
1008
1009        assert_eq!(
1010            pool.get_fee()
1011                .expect("fee should be computable")
1012                .fee,
1013            4_000
1014        );
1015    }
1016
1017    #[test]
1018    fn worst_case_keeps_a_flat_fee_pool_quiet_across_blocks() {
1019        // With scaling 0 the worst-case fee is constant, so apply_block must never request a
1020        // re-emission: the default mode adds no per-block load for such pools.
1021        let mut pool = initial_fee_pool(1_000);
1022        pool.position_assumption = BlockPositionAssumption::WorstCase;
1023        pool.apply_block(&BlockContext::new(100, 1_000));
1024
1025        assert!(!pool.apply_block(&BlockContext::new(101, 1_002)));
1026        assert!(!pool.apply_block(&BlockContext::new(102, 1_004)));
1027    }
1028
1029    #[test]
1030    fn apply_block_reports_a_fee_flip_and_is_idempotent() {
1031        // Pool traded in block 100 (ts 1_000): decoded with execution block == that block, so the
1032        // dynamic fee applies. Crossing to the next block flips the branch to the initial fee.
1033        let mut pool = initial_fee_pool(1_000);
1034        pool.apply_block(&BlockContext::new(100, 1_000));
1035
1036        assert!(pool.apply_block(&BlockContext::new(101, 1_002)), "branch flip must re-emit");
1037        assert!(!pool.apply_block(&BlockContext::new(101, 1_002)), "repeat block is a no-op");
1038    }
1039
1040    #[test]
1041    fn apply_block_stays_quiet_while_the_fee_does_not_move() {
1042        // Idle pool: the initial fee already applies and keeps applying as blocks pass, so
1043        // consumers must not be told anything changed.
1044        let mut pool = initial_fee_pool(1_000);
1045        pool.apply_block(&BlockContext::new(101, 1_002));
1046
1047        assert!(!pool.apply_block(&BlockContext::new(102, 1_004)));
1048        assert!(!pool.apply_block(&BlockContext::new(103, 1_006)));
1049    }
1050
1051    #[test]
1052    fn quotes_initial_fee_for_the_next_block_after_the_pool_traded() {
1053        // The pool wrote its observation in the block we decoded. A quote lands in the *next*
1054        // block, where no observation exists yet — under the first-in-block assumption it pays
1055        // the initial fee.
1056        let mut pool = initial_fee_pool(1_000);
1057        pool.apply_block(&BlockContext::new(101, 1_002));
1058
1059        assert_eq!(
1060            pool.get_fee()
1061                .expect("fee should be computable")
1062                .fee,
1063            750
1064        );
1065    }
1066
1067    #[test]
1068    fn quotes_dynamic_fee_when_targeting_a_block_the_pool_already_traded_in() {
1069        // Flashblock consumer: the block is still open and the pool traded in an earlier
1070        // flashblock, so a quote landing later in the same block pays the dynamic fee.
1071        let mut pool = initial_fee_pool(1_000);
1072        pool.apply_block(&BlockContext::new(100, 1_000));
1073
1074        assert_eq!(
1075            pool.get_fee()
1076                .expect("fee should be computable")
1077                .fee,
1078            2700
1079        );
1080    }
1081
1082    #[test]
1083    fn chained_swap_in_the_same_block_pays_the_dynamic_fee() {
1084        let mut pool = initial_fee_pool(1_000);
1085        pool.apply_block(&BlockContext::new(101, 1_002));
1086        let token_a =
1087            Token::new(&Bytes::from([0x11; 20]), "A", 18, 0, &[Some(10_000)], Chain::Base, 100);
1088        let token_b =
1089            Token::new(&Bytes::from([0x22; 20]), "B", 18, 0, &[Some(10_000)], Chain::Base, 100);
1090
1091        assert_eq!(pool.fee(), 750.0 / 1_000_000.0);
1092
1093        let result = pool
1094            .get_amount_out(BigUint::from(100_000_000_000_000_000u128), &token_a, &token_b)
1095            .expect("first swap should succeed");
1096
1097        // The first swap moved the tick, so it wrote an observation at the execution timestamp;
1098        // the pool state it hands back prices the next swap in that block as a follow-up.
1099        assert_eq!(result.new_state.fee(), 2700.0 / 1_000_000.0);
1100    }
1101
1102    #[test]
1103    fn swap_that_does_not_move_the_tick_leaves_the_initial_fee_available() {
1104        // `CLPool.swap` only writes an observation when the tick changed, so a swap that stays
1105        // inside the tick leaves the next swap in the block on the initial fee.
1106        let mut pool = initial_fee_pool(1_000);
1107        pool.apply_block(&BlockContext::new(101, 1_002));
1108
1109        pool.record_observation(pool.tick)
1110            .expect("no-op write should succeed");
1111
1112        assert_eq!(
1113            pool.get_fee()
1114                .expect("fee should be computable")
1115                .fee,
1116            750
1117        );
1118    }
1119
1120    #[test]
1121    fn initial_fee_branch_does_not_charge_the_twap_gas_overhead() {
1122        let mut pool = initial_fee_pool(1_000);
1123        pool.dfc = DynamicFeeConfig::new(2700, 30_000, 6_000_000, true, 750);
1124        pool.apply_block(&BlockContext::new(101, 1_002));
1125
1126        let resolved = pool
1127            .get_fee()
1128            .expect("fee should be computable");
1129
1130        assert_eq!(resolved, ResolvedFee { fee: 750, observed_twap: false });
1131    }
1132
1133    #[test]
1134    fn dynamic_fee_update_applies_for_supported_module() {
1135        let mut pool = create_basic_test_pool();
1136        pool.dfc = DynamicFeeConfig::new(4500, 10_000, 1, false, 0);
1137        let delta =
1138            dynamic_fee_delta(hex_literal::hex!("090b2A6bb475c00e2256e2095A60887cD710803b"));
1139
1140        pool.delta_transition(delta, &HashMap::new(), &Balances::default())
1141            .expect("dynamic fee update should be valid");
1142
1143        assert_eq!(
1144            pool.get_fee()
1145                .expect("fee should be computable")
1146                .fee,
1147            500
1148        );
1149    }
1150
1151    #[test]
1152    fn dynamic_fee_update_falls_back_to_default_for_unsupported_module() {
1153        // An unsupported-module delta resets to default rather than erroring; pool keeps
1154        // default_fee.
1155        let mut pool = create_basic_test_pool();
1156        pool.dfc = DynamicFeeConfig::new(4500, 10_000, 1, false, 0);
1157        let delta =
1158            dynamic_fee_delta(hex_literal::hex!("DB45818A6db280ecfeB33cbeBd445423d0216b5D"));
1159
1160        pool.delta_transition(delta, &HashMap::new(), &Balances::default())
1161            .expect("unsupported module delta should decode to the default config");
1162
1163        assert_eq!(pool.dfc, DynamicFeeConfig::default());
1164        assert_eq!(
1165            pool.get_fee()
1166                .expect("fee should be computable")
1167                .fee,
1168            3000
1169        );
1170    }
1171
1172    #[test]
1173    fn applies_partial_dynamic_fee_updates_after_module_initialization() {
1174        let mut pool = create_basic_test_pool();
1175        pool.dfc = DynamicFeeConfig::new(4500, 10_000, 1, false, 0);
1176        let delta = ProtocolStateDelta {
1177            component_id: "test-pool".to_string(),
1178            updated_attributes: HashMap::from([(
1179                "dfc_baseFee".to_string(),
1180                Bytes::from(500_u32.to_be_bytes()),
1181            )]),
1182            ..Default::default()
1183        };
1184
1185        pool.delta_transition(delta, &HashMap::new(), &Balances::default())
1186            .expect("partial dynamic fee update should be valid");
1187
1188        assert_eq!(pool.dfc, DynamicFeeConfig::new(500, 10_000, 1, false, 0));
1189    }
1190
1191    #[test]
1192    fn test_partial_step_updates_tick_when_price_moves_without_crossing_initialized_tick() {
1193        let pool = create_basic_test_pool();
1194        let amount =
1195            I256::checked_from_sign_and_abs(Sign::Positive, U256::from(100_000_000_000_000_000u64))
1196                .unwrap();
1197
1198        let result = pool
1199            .swap(true, amount, None)
1200            .expect("swap should stay within the current liquidity range");
1201        let expected_tick =
1202            get_tick_at_sqrt_ratio(result.sqrt_price).expect("new sqrt price should map to a tick");
1203
1204        assert_ne!(result.sqrt_price, pool.sqrt_price);
1205        assert_ne!(result.sqrt_price, get_sqrt_ratio_at_tick(-120).unwrap());
1206        assert_ne!(expected_tick, pool.tick);
1207        assert_eq!(result.tick, expected_tick);
1208    }
1209
1210    #[test]
1211    fn test_swap_keeps_boundary_tick_when_price_does_not_move() {
1212        let mut pool = create_basic_test_pool();
1213        pool.tick = -1;
1214        let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1u64)).unwrap();
1215
1216        let result = pool
1217            .swap(true, amount, None)
1218            .expect("swap should consume the input as fee without moving price");
1219
1220        assert_eq!(result.sqrt_price, pool.sqrt_price);
1221        assert_eq!(get_tick_at_sqrt_ratio(result.sqrt_price).unwrap(), 0);
1222        assert_eq!(result.tick, pool.tick);
1223    }
1224
1225    #[test]
1226    fn test_swap_price_limit_out_of_range_returns_error() {
1227        let pool = create_basic_test_pool();
1228        let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1000u64)).unwrap();
1229
1230        let result = pool.swap(true, amount, Some(pool.sqrt_price));
1231        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
1232
1233        let result = pool.swap(true, amount, Some(MIN_SQRT_RATIO));
1234        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
1235
1236        let result = pool.swap(false, amount, Some(pool.sqrt_price));
1237        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
1238
1239        let result = pool.swap(false, amount, Some(MAX_SQRT_RATIO));
1240        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
1241    }
1242
1243    #[test]
1244    fn test_swap_at_extreme_price_returns_error() {
1245        let sqrt_price = MIN_SQRT_RATIO + U256::from(1u64);
1246        let tick = get_tick_at_sqrt_ratio(sqrt_price).expect("Failed to calculate tick");
1247        let ticks =
1248            vec![TickInfo::new(MIN_TICK, 0).unwrap(), TickInfo::new(MIN_TICK + 1, 0).unwrap()];
1249        let pool = AerodromeSlipstreamsState::new(
1250            "test-pool".to_string(),
1251            1_000_000,
1252            100_000_000_000_000_000_000u128,
1253            sqrt_price,
1254            0,
1255            1,
1256            3000,
1257            1,
1258            tick,
1259            ticks,
1260            vec![Observation::default()],
1261            DynamicFeeConfig::new(3000, 10_000, 1, false, 0),
1262        )
1263        .expect("Failed to create pool");
1264
1265        let amount = I256::checked_from_sign_and_abs(Sign::Positive, U256::from(1000u64)).unwrap();
1266        let result = pool.swap(true, amount, None);
1267        assert!(matches!(result, Err(SimulationError::InvalidInput(_, None))));
1268    }
1269
1270    fn token_pair() -> (Token, Token) {
1271        let token_a =
1272            Token::new(&Bytes::from([0x11; 20]), "A", 18, 0, &[Some(10_000)], Chain::Base, 100);
1273        let token_b =
1274            Token::new(&Bytes::from([0x22; 20]), "B", 18, 0, &[Some(10_000)], Chain::Base, 100);
1275        (token_a, token_b)
1276    }
1277
1278    #[rstest]
1279    fn test_query_pool_swap_target_price_lands_in_band(#[values(true, false)] sell_a: bool) {
1280        let mut pool = create_basic_test_pool();
1281        pool.dfc = DynamicFeeConfig::new(3000, 10_000, 0, false, 0);
1282        let (token_a, token_b) = token_pair();
1283        let (token_in, token_out) =
1284            if sell_a { (&token_a, &token_b) } else { (&token_b, &token_a) };
1285        let target = pool
1286            .spot_price(token_in, token_out)
1287            .unwrap() *
1288            0.99;
1289        let tolerance = 1e-4;
1290        let params = target_price_params(
1291            token_in,
1292            token_out,
1293            to_price(target, token_in, token_out),
1294            tolerance,
1295        );
1296
1297        let swap = pool.query_pool_swap(&params).unwrap();
1298
1299        let new_spot = swap
1300            .new_state()
1301            .spot_price(token_in, token_out)
1302            .unwrap();
1303        assert!(new_spot >= target && new_spot <= target * (1.0 + tolerance), "spot {new_spot}");
1304        assert!(swap.price_points().is_none(), "the native path returns no price points");
1305        let quote = pool
1306            .get_amount_out(swap.amount_in().clone(), token_in, token_out)
1307            .unwrap();
1308        assert_eq!(&quote.amount, swap.amount_out());
1309    }
1310
1311    #[test]
1312    fn target_price_revalidates_after_initial_fee_changes() {
1313        let mut pool = initial_fee_pool(999_999);
1314        pool.ticks = TickList::from(
1315            1,
1316            vec![TickInfo::new(-1200, 0).unwrap(), TickInfo::new(1200, 0).unwrap()],
1317        )
1318        .unwrap();
1319        let (token_in, token_out) = token_pair();
1320        let target = 0.99;
1321        let tolerance = 1e-4;
1322        let params = target_price_params(
1323            &token_in,
1324            &token_out,
1325            to_price(target, &token_in, &token_out),
1326            tolerance,
1327        );
1328        let swap = pool
1329            .query_pool_swap(&params)
1330            .expect("search reaches the target using the final fee");
1331        let spot = swap
1332            .new_state()
1333            .spot_price(&token_in, &token_out)
1334            .unwrap();
1335        assert!(spot >= target && spot <= target * (1.0 + tolerance), "final spot {spot}");
1336        assert!(swap.price_points().is_some(), "fee change should trigger numerical search");
1337    }
1338
1339    /// A target less than half the tolerance below spot puts the swap limit above spot.
1340    #[test]
1341    fn test_query_pool_swap_target_price_falls_back_to_search() {
1342        let pool = create_basic_test_pool();
1343        let (token_a, token_b) = token_pair();
1344        let spot = pool
1345            .spot_price(&token_a, &token_b)
1346            .unwrap();
1347        let params = target_price_params(
1348            &token_a,
1349            &token_b,
1350            to_price(spot * (1.0 - 1e-5), &token_a, &token_b),
1351            1e-4,
1352        );
1353
1354        let swap = pool.query_pool_swap(&params).unwrap();
1355
1356        assert!(swap.price_points().is_some(), "the numerical search returns price points");
1357    }
1358
1359    #[test]
1360    fn test_query_pool_swap_target_price_at_spot() {
1361        let mut pool = create_basic_test_pool();
1362        pool.dfc = DynamicFeeConfig::new(ZERO_FEE_INDICATOR, 10_000, 0, false, 0);
1363        let (token_a, token_b) = token_pair();
1364        let params = target_price_params(
1365            &token_a,
1366            &token_b,
1367            Price::new(BigUint::from(1u64), BigUint::from(1u64)),
1368            1e-4,
1369        );
1370
1371        let swap = pool.query_pool_swap(&params).unwrap();
1372
1373        assert!(swap.amount_in().is_zero());
1374        assert!(swap.amount_out().is_zero());
1375        assert!(swap.new_state().eq(&pool));
1376    }
1377
1378    #[test]
1379    fn test_query_pool_swap_target_price_above_spot() {
1380        let pool = create_basic_test_pool();
1381        let (token_a, token_b) = token_pair();
1382        let spot = pool
1383            .spot_price(&token_a, &token_b)
1384            .unwrap();
1385        let params = target_price_params(
1386            &token_a,
1387            &token_b,
1388            to_price(spot * 1.01, &token_a, &token_b),
1389            1e-4,
1390        );
1391
1392        let result = pool.query_pool_swap(&params);
1393
1394        assert!(matches!(result, Err(SimulationError::InvalidInput(..))), "got {result:?}");
1395    }
1396}