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