Skip to main content

tycho_simulation/evm/protocol/ramses_v3/
state.rs

1use std::{any::Any, collections::HashMap, sync::Arc};
2
3use alloy::primitives::{Sign, I256, U256};
4use num_bigint::BigUint;
5use serde::{Deserialize, Serialize};
6use tracing::trace;
7use tycho_common::{
8    dto::ProtocolStateDelta,
9    models::token::Token,
10    simulation::{
11        errors::{SimulationError, TransitionError},
12        protocol_sim::{
13            Balances, GetAmountOutResult, PoolSwap, ProtocolSim, QueryPoolSwapParams,
14            SwapConstraint,
15        },
16    },
17    Bytes,
18};
19
20use crate::evm::protocol::{
21    clmm::clmm_swap_to_price,
22    safe_math::{safe_add_u256, safe_sub_u256},
23    u256_num::u256_to_biguint,
24    utils::{
25        add_fee_markup,
26        uniswap::{
27            liquidity_math,
28            pool_tick::PoolTick,
29            sqrt_price_math::{get_amount0_delta, get_amount1_delta, sqrt_price_q96_to_f64},
30            swap_math::{self, SwapStepResult},
31            swap_step_cache::{StepCacheKey, StepCacheRun, SwapStepCache},
32            tick_list::{TickInfo, TickList, TickListErrorKind},
33            tick_math::{
34                get_sqrt_ratio_at_tick_cached, MAX_SQRT_RATIO, MAX_TICK, MIN_SQRT_RATIO, MIN_TICK,
35            },
36            StepComputation, SwapResults, SwapState,
37        },
38    },
39};
40
41// Gas model for a Ramses V3 swap. Constants start from the Uniswap V3 estimator and are nudged for
42// Ramses' extra gauge/period accounting (the second `grossFeeGrowthGlobal` accumulator, the
43// `advancePeriod` bookkeeping, and the richer `Tick.cross`). Calibrated against two on-chain
44// Polygon swaps on the USDC/USD.e pool:
45// - 0 tick crossings: ~141.5k gas total (~74k base, ~56k settlement, ~5k step)
46//   0x8fddae1260f40d7d22c0dc4b995ab1e84423adc523e71bbda503f735610db995
47// - 1 tick crossing:  ~184.1k gas total
48//   0xe884fa71e94c4c88d8e225fdc425e4df2321692343e51d06f8e5427729c34010
49// The ~42.5k delta = one extra math step + bitmap scan + the cross itself (its feeGrowthOutside /
50// gauge / period SSTOREs plus the oracle observation a tick change triggers).
51
52// Pre/post loop overhead: cold SLOADs (slot0, liquidity, feeGrowthGlobal) + cold SSTOREs (slot0,
53// liquidity) at end of swap. Uniswap V3 budgets ~70k here; Ramses adds the `grossFeeGrowthGlobal`
54// write and `advancePeriod` period bookkeeping (~+8k on-chain), hence ~78k.
55const SWAP_BASE_GAS: u64 = 78_000;
56// Bitmap word scan (cold SLOAD of tickBitmap word)
57const GAS_PER_BITMAP_WORD: u64 = 2_100;
58// swap math step: getSqrtRatioAtTick + computeSwapStep + amount accounting + getTickAtSqrtRatio
59const GAS_PER_SWAP_MATH_STEP: u64 = 5_400;
60// Initialized tick crossing: Ramses' cross() writes feeGrowthOutside plus gauge/period checkpoints
61// (seconds-per-liquidity, tickCumulative, period start tick) and triggers an oracle observation.
62// Measured at ~35k (the ~42.5k one-cross delta minus the extra math step + bitmap scan).
63const GAS_PER_INITIALIZED_TICK_CROSS: u64 = 35_000;
64// Output transfer + balanceBefore + callback + balanceAfter.
65const V3_CALLBACK_SETTLEMENT_GAS: u64 = 70_000;
66// Conservative max gas budget for a single swap (Ethereum transaction gas limit)
67const MAX_SWAP_GAS: u64 = 16_700_000;
68const MAX_TICKS_CROSSED: u64 = (MAX_SWAP_GAS - SWAP_BASE_GAS) / GAS_PER_INITIALIZED_TICK_CROSS;
69
70/// State of a Ramses V3 pool.
71///
72/// Mirrors `UniswapV3State` but, because the Ramses swap fee is governance-mutable and pools are
73/// keyed by tick spacing, `fee` is stored as a raw `u32` (hundredths of a bip) and `tick_spacing`
74/// is tracked explicitly rather than being derived from the fee tier.
75#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
76pub struct RamsesV3State {
77    liquidity: u128,
78    sqrt_price: U256,
79    fee: u32,
80    tick: PoolTick,
81    tick_spacing: u16,
82    /// The tick list, shared by this state, its clones and the states its swaps return, so a quote
83    /// does not copy it. Change it only through `Arc::make_mut`, which copies the list while
84    /// another state shares it.
85    ticks: Arc<TickList>,
86    /// Swap steps already taken from this state, shared by its clones. The steps hold only for
87    /// this state's price, tick, liquidity, fee and ticks. `delta_transition` and
88    /// `clone_post_swap` replace the cache with an empty one, and any other code that changes
89    /// those fields must do the same.
90    #[serde(skip)]
91    step_cache: SwapStepCache,
92}
93
94impl RamsesV3State {
95    /// Creates a new instance of `RamsesV3State`.
96    ///
97    /// # Arguments
98    /// - `liquidity`: The initial liquidity of the pool.
99    /// - `sqrt_price`: The square root of the current price.
100    /// - `fee`: The current swap fee, in hundredths of a bip (1_000_000 = 100%).
101    /// - `tick_spacing`: The tick spacing of the pool (its immutable key).
102    /// - `tick`: The current tick of the pool.
103    /// - `ticks`: A vector of `TickInfo` representing the tick information for the pool.
104    pub fn new(
105        liquidity: u128,
106        sqrt_price: U256,
107        fee: u32,
108        tick_spacing: u16,
109        tick: i32,
110        ticks: Vec<TickInfo>,
111    ) -> Result<Self, SimulationError> {
112        let tick_list = TickList::from(tick_spacing, ticks)?;
113        Ok(RamsesV3State {
114            liquidity,
115            sqrt_price,
116            fee,
117            tick: tick.into(),
118            tick_spacing,
119            ticks: Arc::new(tick_list),
120            step_cache: SwapStepCache::default(),
121        })
122    }
123
124    /// A clone of this state with the final price, tick and liquidity of a swap, and an empty
125    /// step cache.
126    fn clone_post_swap(&self, sqrt_price: U256, tick: PoolTick, liquidity: u128) -> Self {
127        let mut state = self.clone();
128        state.sqrt_price = sqrt_price;
129        state.tick = tick;
130        state.liquidity = liquidity;
131        state.step_cache = SwapStepCache::default();
132        state
133    }
134
135    fn swap(
136        &self,
137        zero_for_one: bool,
138        amount_specified: I256,
139        sqrt_price_limit: Option<U256>,
140    ) -> Result<SwapResults, SimulationError> {
141        if !self.ticks.has_initialized_ticks() {
142            return Err(SimulationError::RecoverableError("No liquidity".to_string()));
143        }
144        let price_limit = if let Some(limit) = sqrt_price_limit {
145            limit
146        } else if zero_for_one {
147            safe_add_u256(MIN_SQRT_RATIO, U256::ONE)?
148        } else {
149            safe_sub_u256(MAX_SQRT_RATIO, U256::ONE)?
150        };
151
152        let price_limit_valid = if zero_for_one {
153            price_limit > MIN_SQRT_RATIO && price_limit < self.sqrt_price
154        } else {
155            price_limit < MAX_SQRT_RATIO && price_limit > self.sqrt_price
156        };
157        if !price_limit_valid {
158            return Err(SimulationError::InvalidInput("Price limit out of range".into(), None));
159        }
160
161        let exact_input = amount_specified > I256::from_raw(U256::from(0u64));
162
163        let mut state = SwapState {
164            amount_remaining: amount_specified,
165            amount_calculated: I256::from_raw(U256::from(0u64)),
166            sqrt_price: self.sqrt_price,
167            tick: self.tick.value().into(),
168            liquidity: self.liquidity,
169        };
170        let mut gas_used = U256::from(SWAP_BASE_GAS);
171
172        // Only exact-input swaps with no price limit use the step cache. The cache finds a step by
173        // its input, which an exact-output swap does not know. A cached step can also lie past a
174        // price limit.
175        let cache_key = if exact_input && sqrt_price_limit.is_none() {
176            StepCacheKey::new(
177                self.sqrt_price,
178                state.tick.value(),
179                self.liquidity,
180                self.fee,
181                gas_used,
182            )
183        } else {
184            None
185        };
186        let mut cache_run = StepCacheRun::new(
187            &self.step_cache,
188            zero_for_one,
189            cache_key,
190            amount_specified.unsigned_abs(),
191        );
192        if let Some(progress) = cache_run.apply_cached_progress(&mut state) {
193            gas_used = progress.gas;
194        }
195        while state.amount_remaining != I256::from_raw(U256::from(0u64)) &&
196            state.sqrt_price != price_limit
197        {
198            let (mut next_tick, next_tick_info) = match self
199                .ticks
200                .next_initialized_tick_within_one_word(state.tick.value(), zero_for_one)
201            {
202                Ok((tick, info)) => {
203                    gas_used = safe_add_u256(gas_used, U256::from(GAS_PER_BITMAP_WORD))?;
204                    (tick, info)
205                }
206                Err(tick_err) => match tick_err.kind {
207                    TickListErrorKind::TicksExeeded => {
208                        let new_state = self.clone_post_swap(
209                            state.sqrt_price,
210                            state.tick.clone(),
211                            state.liquidity,
212                        );
213                        return Err(SimulationError::InvalidInput(
214                            "Ticks exceeded".into(),
215                            Some(GetAmountOutResult::new(
216                                u256_to_biguint(state.amount_calculated.abs().into_raw()),
217                                u256_to_biguint(gas_used),
218                                Box::new(new_state),
219                            )),
220                        ));
221                    }
222                    _ => return Err(SimulationError::FatalError("Unknown error".to_string())),
223                },
224            };
225
226            next_tick = next_tick.clamp(MIN_TICK, MAX_TICK);
227
228            let sqrt_price_start = state.sqrt_price;
229            let sqrt_price_next = match next_tick_info {
230                Some(info) => info.sqrt_price,
231                None => get_sqrt_ratio_at_tick_cached(next_tick)?,
232            };
233            let SwapStepResult { sqrt_price, amount_in_with_fee, amount_out } =
234                swap_math::compute_swap_step(
235                    state.sqrt_price,
236                    RamsesV3State::get_sqrt_ratio_target(
237                        sqrt_price_next,
238                        price_limit,
239                        zero_for_one,
240                    ),
241                    state.liquidity,
242                    state.amount_remaining,
243                    self.fee,
244                )?;
245            state.sqrt_price = sqrt_price;
246
247            let step = StepComputation {
248                sqrt_price_start,
249                tick_next: next_tick,
250                sqrt_price_next,
251                amount_in_with_fee,
252                amount_out,
253            };
254
255            gas_used = safe_add_u256(gas_used, U256::from(GAS_PER_SWAP_MATH_STEP))?;
256
257            if exact_input {
258                state.amount_remaining -=
259                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_in_with_fee)
260                        .unwrap();
261                state.amount_calculated -=
262                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
263            } else {
264                state.amount_remaining +=
265                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
266                state.amount_calculated +=
267                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_in_with_fee)
268                        .unwrap();
269            }
270            if state.sqrt_price == step.sqrt_price_next {
271                if let Some(info) = next_tick_info {
272                    let liquidity_raw = info.net_liquidity;
273                    let liquidity_net = if zero_for_one { -liquidity_raw } else { liquidity_raw };
274                    state.liquidity =
275                        liquidity_math::add_liquidity_delta(state.liquidity, liquidity_net)?;
276                    gas_used = safe_add_u256(gas_used, U256::from(GAS_PER_INITIALIZED_TICK_CROSS))?;
277                }
278                state.tick =
279                    PoolTick::from(if zero_for_one { step.tick_next - 1 } else { step.tick_next });
280            } else if state.sqrt_price != step.sqrt_price_start {
281                state.tick = PoolTick::at_sqrt_price(state.sqrt_price)?;
282            }
283            cache_run.cache_progress(&step, &state, gas_used);
284        }
285        Ok(SwapResults {
286            amount_calculated: state.amount_calculated,
287            amount_specified,
288            amount_remaining: state.amount_remaining,
289            sqrt_price: state.sqrt_price,
290            liquidity: state.liquidity,
291            tick: state.tick,
292            gas_used: safe_add_u256(gas_used, U256::from(V3_CALLBACK_SETTLEMENT_GAS))?,
293        })
294    }
295
296    fn get_sqrt_ratio_target(
297        sqrt_price_next: U256,
298        sqrt_price_limit: U256,
299        zero_for_one: bool,
300    ) -> U256 {
301        let cond1 = if zero_for_one {
302            sqrt_price_next < sqrt_price_limit
303        } else {
304            sqrt_price_next > sqrt_price_limit
305        };
306
307        if cond1 {
308            sqrt_price_limit
309        } else {
310            sqrt_price_next
311        }
312    }
313}
314
315#[typetag::serde]
316impl ProtocolSim for RamsesV3State {
317    fn fee(&self) -> f64 {
318        self.fee as f64 / 1_000_000.0
319    }
320
321    fn spot_price(&self, a: &Token, b: &Token) -> Result<f64, SimulationError> {
322        let price = if a < b {
323            sqrt_price_q96_to_f64(self.sqrt_price, a.decimals, b.decimals)?
324        } else {
325            1.0f64 / sqrt_price_q96_to_f64(self.sqrt_price, b.decimals, a.decimals)?
326        };
327        Ok(add_fee_markup(price, self.fee()))
328    }
329
330    fn get_amount_out(
331        &self,
332        amount_in: BigUint,
333        token_a: &Token,
334        token_b: &Token,
335    ) -> Result<GetAmountOutResult, SimulationError> {
336        let zero_for_one = token_a < token_b;
337        let amount_specified = U256::try_from_be_slice(&amount_in.to_bytes_be())
338            .and_then(|unsigned_amount_in| {
339                I256::checked_from_sign_and_abs(Sign::Positive, unsigned_amount_in)
340            })
341            .ok_or_else(|| {
342                SimulationError::InvalidInput("I256 overflow: amount_in".to_string(), None)
343            })?;
344
345        let result = self.swap(zero_for_one, amount_specified, None)?;
346
347        trace!(?amount_in, ?token_a, ?token_b, ?zero_for_one, ?result, "RAMSES V3 SWAP");
348        let new_state = self.clone_post_swap(result.sqrt_price, result.tick, result.liquidity);
349
350        Ok(GetAmountOutResult::new(
351            u256_to_biguint(
352                result
353                    .amount_calculated
354                    .saturating_abs()
355                    .into_raw(),
356            ),
357            u256_to_biguint(result.gas_used),
358            Box::new(new_state),
359        ))
360    }
361
362    fn get_limits(
363        &self,
364        token_in: Bytes,
365        token_out: Bytes,
366    ) -> Result<(BigUint, BigUint), SimulationError> {
367        if !self.ticks.has_initialized_ticks() {
368            return Ok((BigUint::ZERO, BigUint::ZERO));
369        }
370
371        let zero_for_one = token_in < token_out;
372        let mut current_tick = self.tick.value();
373        let mut current_sqrt_price = self.sqrt_price;
374        let mut current_liquidity = self.liquidity;
375        let mut total_amount_in = U256::ZERO;
376        let mut total_amount_out = U256::ZERO;
377        let mut ticks_crossed: u64 = 0;
378
379        // Iterate through ticks in the direction of the swap
380        // Stops when: no more liquidity, no more ticks, or gas limit would be exceeded
381        while let Ok((tick, next_tick_info)) = self
382            .ticks
383            .next_initialized_tick_within_one_word(current_tick, zero_for_one)
384        {
385            // Cap iteration to prevent exceeding Ethereum's gas limit
386            if ticks_crossed == MAX_TICKS_CROSSED {
387                break;
388            }
389            ticks_crossed += 1;
390
391            // Clamp the tick value to ensure it's within valid range
392            let next_tick = tick.clamp(MIN_TICK, MAX_TICK);
393
394            let sqrt_price_next = match next_tick_info {
395                Some(info) => info.sqrt_price,
396                None => get_sqrt_ratio_at_tick_cached(next_tick)?,
397            };
398
399            // Calculate the amount of tokens swapped when moving from current_sqrt_price to
400            // sqrt_price_next. Direction determines which token is being swapped in vs out
401            let (amount_in, amount_out) = if zero_for_one {
402                let amount0 = get_amount0_delta(
403                    sqrt_price_next,
404                    current_sqrt_price,
405                    current_liquidity,
406                    true,
407                )?;
408                let amount1 = get_amount1_delta(
409                    sqrt_price_next,
410                    current_sqrt_price,
411                    current_liquidity,
412                    false,
413                )?;
414                (amount0, amount1)
415            } else {
416                let amount0 = get_amount0_delta(
417                    sqrt_price_next,
418                    current_sqrt_price,
419                    current_liquidity,
420                    false,
421                )?;
422                let amount1 = get_amount1_delta(
423                    sqrt_price_next,
424                    current_sqrt_price,
425                    current_liquidity,
426                    true,
427                )?;
428                (amount1, amount0)
429            };
430
431            // Accumulate total amounts for this tick range
432            total_amount_in = safe_add_u256(total_amount_in, amount_in)?;
433            total_amount_out = safe_add_u256(total_amount_out, amount_out)?;
434
435            // If this tick is "initialized" (meaning its someone's position boundary), update the
436            // liquidity when crossing it
437            // For zero_for_one, liquidity is removed when crossing a tick
438            // For one_for_zero, liquidity is added when crossing a tick
439            if let Some(info) = next_tick_info {
440                let liquidity_raw = info.net_liquidity;
441                let liquidity_delta = if zero_for_one { -liquidity_raw } else { liquidity_raw };
442
443                // Check if applying this liquidity delta would cause underflow
444                // If so, stop here rather than continuing with invalid state
445                match liquidity_math::add_liquidity_delta(current_liquidity, liquidity_delta) {
446                    Ok(new_liquidity) => {
447                        current_liquidity = new_liquidity;
448                    }
449                    Err(_) => {
450                        // Liquidity would underflow, stop iteration here
451                        // This represents the maximum liquidity we can actually use
452                        break;
453                    }
454                }
455            }
456
457            // Move to the next tick position
458            current_tick = if zero_for_one { next_tick - 1 } else { next_tick };
459            current_sqrt_price = sqrt_price_next;
460        }
461
462        Ok((u256_to_biguint(total_amount_in), u256_to_biguint(total_amount_out)))
463    }
464
465    fn delta_transition(
466        &mut self,
467        mut delta: ProtocolStateDelta,
468        _tokens: &HashMap<Bytes, Token>,
469        _balances: &Balances,
470    ) -> Result<(), TransitionError> {
471        // Swap steps taken from the old state do not hold for the new one.
472        self.step_cache = SwapStepCache::default();
473        // apply attribute changes
474        if let Some(liquidity) = delta
475            .updated_attributes
476            .remove("liquidity")
477        {
478            self.liquidity = liquidity.into();
479        }
480        if let Some(sqrt_price) = delta
481            .updated_attributes
482            .get("sqrt_price_x96")
483        {
484            self.sqrt_price = U256::from_be_slice(sqrt_price);
485        }
486        if let Some(tick) = delta.updated_attributes.remove("tick") {
487            self.tick = i32::from(tick).into();
488        }
489        // The Ramses swap fee is governance-mutable, so apply fee updates here.
490        if let Some(fee) = delta.updated_attributes.remove("fee") {
491            self.fee = fee.into();
492        }
493
494        // update ticks
495        for (key, value) in delta.updated_attributes {
496            let Some(tick) = key.strip_prefix("ticks/") else {
497                continue;
498            };
499
500            Arc::make_mut(&mut self.ticks)
501                .set_tick_liquidity(
502                    tick.parse::<i32>()
503                        .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
504                    i128::from(value),
505                )
506                .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
507        }
508        // delete ticks
509        for key in delta.deleted_attributes {
510            let Some(tick) = key.strip_prefix("ticks/") else {
511                continue;
512            };
513
514            Arc::make_mut(&mut self.ticks)
515                .set_tick_liquidity(
516                    tick.parse::<i32>()
517                        .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
518                    0,
519                )
520                .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
521        }
522        Ok(())
523    }
524
525    /// See [`ProtocolSim::query_pool_swap`] for the trait documentation.
526    ///
527    /// This method uses Ramses V3 internal swap logic by swapping an infinite amount of token_in
528    /// until the target price is reached.
529    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
530        if !self.ticks.has_initialized_ticks() {
531            return Err(SimulationError::RecoverableError("No liquidity".to_string()));
532        }
533
534        match params.swap_constraint() {
535            SwapConstraint::TradeLimitPrice { .. } => Err(SimulationError::InvalidInput(
536                "Ramses V3 does not support TradeLimitPrice constraint in query_pool_swap"
537                    .to_string(),
538                None,
539            )),
540            SwapConstraint::PoolTargetPrice {
541                target,
542                tolerance: _,
543                min_amount_in: _,
544                max_amount_in: _,
545            } => {
546                let (amount_in, amount_out, swap_result) = clmm_swap_to_price(
547                    self.sqrt_price,
548                    &params.token_in().address,
549                    &params.token_out().address,
550                    target,
551                    self.fee,
552                    Sign::Positive,
553                    |zero_for_one, amount_specified, sqrt_price_limit| {
554                        self.swap(zero_for_one, amount_specified, Some(sqrt_price_limit))
555                    },
556                )?;
557
558                let new_state = self.clone_post_swap(
559                    swap_result.sqrt_price,
560                    swap_result.tick,
561                    swap_result.liquidity,
562                );
563
564                Ok(PoolSwap::new(amount_in, amount_out, Box::new(new_state), None))
565            }
566        }
567    }
568
569    fn clone_box(&self) -> Box<dyn ProtocolSim> {
570        Box::new(self.clone())
571    }
572
573    fn as_any(&self) -> &dyn Any {
574        self
575    }
576
577    fn as_any_mut(&mut self) -> &mut dyn Any {
578        self
579    }
580
581    fn eq(&self, other: &dyn ProtocolSim) -> bool {
582        let Some(RamsesV3State {
583            liquidity,
584            sqrt_price,
585            fee,
586            tick,
587            tick_spacing,
588            ticks,
589            step_cache: _,
590        }) = other
591            .as_any()
592            .downcast_ref::<RamsesV3State>()
593        else {
594            return false;
595        };
596
597        &self.liquidity == liquidity &&
598            &self.sqrt_price == sqrt_price &&
599            &self.fee == fee &&
600            &self.tick == tick &&
601            &self.tick_spacing == tick_spacing &&
602            &self.ticks == ticks
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use std::{
609        collections::{HashMap, HashSet},
610        str::FromStr,
611    };
612
613    use num_bigint::ToBigUint;
614    use tycho_common::models::Chain;
615
616    use super::*;
617
618    // Real WBTC/WETH pool data (Uniswap V3, 0.05% fee, tick spacing 10). Because the Ramses swap
619    // math is identical to Uniswap V3, the same inputs must produce the same outputs — this test
620    // guards math parity with the upstream implementation.
621    #[test]
622    fn test_get_amount_out_math_parity() {
623        let wbtc = Token::new(
624            &Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap(),
625            "WBTC",
626            8,
627            0,
628            &[Some(10_000)],
629            Chain::Ethereum,
630            100,
631        );
632        let weth = Token::new(
633            &Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(),
634            "WETH",
635            18,
636            0,
637            &[Some(10_000)],
638            Chain::Ethereum,
639            100,
640        );
641        let pool = RamsesV3State::new(
642            377952820878029838,
643            U256::from_str("28437325270877025820973479874632004").unwrap(),
644            500,
645            10,
646            255830,
647            vec![
648                TickInfo::new(255760, 1759015528199933i128).unwrap(),
649                TickInfo::new(255770, 6393138051835308i128).unwrap(),
650                TickInfo::new(255780, 228206673808681i128).unwrap(),
651                TickInfo::new(255820, 1319490609195820i128).unwrap(),
652                TickInfo::new(255830, 678916926147901i128).unwrap(),
653                TickInfo::new(255840, 12208947683433103i128).unwrap(),
654                TickInfo::new(255850, 1177970713095301i128).unwrap(),
655                TickInfo::new(255860, 8752304680520407i128).unwrap(),
656                TickInfo::new(255880, 1486478248067104i128).unwrap(),
657                TickInfo::new(255890, 1878744276123248i128).unwrap(),
658                TickInfo::new(255900, 77340284046725227i128).unwrap(),
659            ],
660        )
661        .unwrap();
662
663        let res = pool
664            .get_amount_out(500000000.to_biguint().unwrap(), &wbtc, &weth)
665            .unwrap();
666
667        assert_eq!(res.amount, BigUint::from_str("64352395915550406461").unwrap());
668    }
669
670    #[test]
671    fn test_delta_transition_updates_mutable_fee() {
672        let mut pool = RamsesV3State::new(
673            1000,
674            U256::from_str("1000").unwrap(),
675            500,
676            10,
677            100,
678            vec![TickInfo::new(255760, 10000).unwrap(), TickInfo::new(255900, -10000).unwrap()],
679        )
680        .unwrap();
681
682        let attributes: HashMap<String, Bytes> = [
683            ("liquidity".to_string(), Bytes::from(2000_u64.to_be_bytes().to_vec())),
684            // 3000 hundredths-of-a-bip, as emitted by the substreams (to_signed_bytes_be ->
685            // 0x0bb8)
686            ("fee".to_string(), Bytes::from(3000_u32.to_be_bytes().to_vec())),
687        ]
688        .into_iter()
689        .collect();
690        let delta = ProtocolStateDelta {
691            component_id: "State1".to_owned(),
692            updated_attributes: attributes,
693            deleted_attributes: HashSet::new(),
694        };
695
696        pool.delta_transition(delta, &HashMap::new(), &Balances::default())
697            .unwrap();
698
699        assert_eq!(pool.liquidity, 2000);
700        assert_eq!(pool.fee, 3000);
701        assert_eq!(pool.fee(), 0.003);
702    }
703}
704
705#[cfg(test)]
706mod tick_list_sharing_tests {
707    use std::collections::{HashMap, HashSet};
708
709    use tycho_common::{dto::ProtocolStateDelta, hex_bytes::Bytes};
710
711    use super::*;
712
713    #[test]
714    fn test_delta_transition_leaves_clones_ticks_unchanged() {
715        let original = RamsesV3State::new(
716            1000,
717            U256::from(1000u64),
718            500,
719            10,
720            100,
721            vec![TickInfo::new(255760, 10000).unwrap(), TickInfo::new(255900, -10000).unwrap()],
722        )
723        .unwrap();
724        let mut updated = original.clone();
725        let delta = ProtocolStateDelta {
726            component_id: "State1".to_owned(),
727            updated_attributes: HashMap::from([(
728                "ticks/255760".to_string(),
729                Bytes::from(20000_i128.to_be_bytes().to_vec()),
730            )]),
731            deleted_attributes: HashSet::new(),
732        };
733
734        updated
735            .delta_transition(delta, &HashMap::new(), &Balances::default())
736            .unwrap();
737
738        assert_eq!(
739            updated
740                .ticks
741                .get_tick(255760)
742                .unwrap()
743                .net_liquidity,
744            20000
745        );
746        assert_eq!(
747            original
748                .ticks
749                .get_tick(255760)
750                .unwrap()
751                .net_liquidity,
752            10000
753        );
754    }
755}
756
757#[cfg(test)]
758mod step_cache_tests {
759    use std::{
760        collections::{HashMap, HashSet},
761        str::FromStr,
762    };
763
764    use tycho_common::{dto::ProtocolStateDelta, hex_bytes::Bytes};
765
766    use super::*;
767    use crate::evm::protocol::utils::uniswap::swap_step_cache::test_fixtures::{
768        amount_orders, test_amounts, test_ticks, wbtc_weth, LIQUIDITY, SQRT_PRICE, TICK,
769    };
770
771    fn test_pool() -> RamsesV3State {
772        RamsesV3State::new(
773            LIQUIDITY,
774            U256::from_str(SQRT_PRICE).unwrap(),
775            500,
776            10,
777            TICK,
778            test_ticks(),
779        )
780        .unwrap()
781    }
782
783    /// A quote's amount, gas and new state, or its error, as text.
784    fn format_quote_result(
785        pool: &RamsesV3State,
786        amount: &BigUint,
787        sell: &Token,
788        buy: &Token,
789    ) -> String {
790        match pool.get_amount_out(amount.clone(), sell, buy) {
791            Ok(result) => {
792                let state = result
793                    .new_state
794                    .as_any()
795                    .downcast_ref::<RamsesV3State>()
796                    .unwrap();
797                format!(
798                    "{} {} {} {} {}",
799                    result.amount,
800                    result.gas,
801                    state.sqrt_price,
802                    state.tick.value(),
803                    state.liquidity
804                )
805            }
806            Err(error) => format!("error {error}"),
807        }
808    }
809
810    #[test]
811    fn test_step_cache_quotes_match_a_fresh_state_in_any_order() {
812        let (wbtc, weth) = wbtc_weth();
813        for (sell, buy, smallest) in [(&wbtc, &weth, 1_000u64), (&weth, &wbtc, 1_000_000_000)] {
814            for order in amount_orders(&test_amounts(smallest)) {
815                let shared = test_pool();
816                for amount in &order {
817                    assert_eq!(
818                        format_quote_result(&shared, amount, sell, buy),
819                        format_quote_result(&test_pool(), amount, sell, buy),
820                        "selling {amount} {}",
821                        sell.symbol
822                    );
823                }
824                let steps = shared
825                    .step_cache
826                    .min_inputs(sell < buy)
827                    .len();
828                assert!(steps > 2, "only {steps} steps cached");
829            }
830        }
831    }
832
833    #[test]
834    fn test_step_cache_quotes_match_a_fresh_state_at_each_cached_step() {
835        let (wbtc, weth) = wbtc_weth();
836        // Amounts that run past the last tick, so the cache covers the whole tick list. Selling
837        // WETH needs a far larger amount to leave its first tick.
838        for (sell, buy, doublings) in [(&wbtc, &weth, 30u32), (&weth, &wbtc, 70)] {
839            let cached = test_pool();
840            let _ = cached.get_amount_out(BigUint::from(10u32) << doublings, sell, buy);
841            let boundaries = cached.step_cache.min_inputs(sell < buy);
842            assert!(boundaries.len() > 2, "only {} steps cached", boundaries.len());
843            for boundary in boundaries.into_iter().skip(1) {
844                for amount in [boundary - U256::from(1u64), boundary, boundary + U256::from(1u64)] {
845                    let amount = u256_to_biguint(amount);
846                    assert_eq!(
847                        format_quote_result(&cached, &amount, sell, buy),
848                        format_quote_result(&test_pool(), &amount, sell, buy),
849                        "selling {amount} {}",
850                        sell.symbol
851                    );
852                }
853            }
854        }
855    }
856
857    #[test]
858    fn test_step_cache_of_a_swapped_state() {
859        let (wbtc, weth) = wbtc_weth();
860        let pool = test_pool();
861        let swapped = pool
862            .get_amount_out(BigUint::from(300_000_000u64), &wbtc, &weth)
863            .unwrap()
864            .new_state;
865        let swapped = swapped
866            .as_any()
867            .downcast_ref::<RamsesV3State>()
868            .unwrap();
869        // A serde round trip gives the same state with an empty step cache.
870        let fresh: RamsesV3State =
871            serde_json::from_value(serde_json::to_value(swapped).unwrap()).unwrap();
872
873        for amount in test_amounts(1_000).iter().rev() {
874            assert_eq!(
875                format_quote_result(swapped, amount, &wbtc, &weth),
876                format_quote_result(&fresh, amount, &wbtc, &weth),
877                "selling {amount} WBTC"
878            );
879        }
880    }
881
882    #[test]
883    fn test_step_cache_restarts_after_delta_transition() {
884        let (wbtc, weth) = wbtc_weth();
885        let large = BigUint::from(3_000_000_000u64);
886        // Only a crossed tick's liquidity changes, so the price, tick and liquidity the cache
887        // starts from stay the same.
888        let delta = || ProtocolStateDelta {
889            component_id: "State1".to_owned(),
890            updated_attributes: HashMap::from([(
891                "ticks/255820".to_string(),
892                Bytes::from(
893                    1_000_000_000_000_000i128
894                        .to_be_bytes()
895                        .to_vec(),
896                ),
897            )]),
898            deleted_attributes: HashSet::new(),
899        };
900        let mut cached = test_pool();
901        cached
902            .get_amount_out(large.clone(), &wbtc, &weth)
903            .unwrap();
904        let mut fresh = test_pool();
905
906        cached
907            .delta_transition(delta(), &HashMap::new(), &Balances::default())
908            .unwrap();
909        fresh
910            .delta_transition(delta(), &HashMap::new(), &Balances::default())
911            .unwrap();
912
913        assert_eq!(
914            format_quote_result(&cached, &large, &wbtc, &weth),
915            format_quote_result(&fresh, &large, &wbtc, &weth)
916        );
917    }
918}