Skip to main content

tycho_simulation/evm/protocol/aerodrome_slipstreams/
state.rs

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