Skip to main content

tycho_simulation/evm/protocol/ramses_v3/
state.rs

1use std::{any::Any, collections::HashMap};
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            sqrt_price_math::{get_amount0_delta, get_amount1_delta, sqrt_price_q96_to_f64},
29            swap_math,
30            tick_list::{TickInfo, TickList, TickListErrorKind},
31            tick_math::{
32                get_sqrt_ratio_at_tick, get_tick_at_sqrt_ratio, MAX_SQRT_RATIO, MAX_TICK,
33                MIN_SQRT_RATIO, MIN_TICK,
34            },
35            StepComputation, SwapResults, SwapState,
36        },
37    },
38};
39
40// Gas model for a Ramses V3 swap. Constants start from the Uniswap V3 estimator and are nudged for
41// Ramses' extra gauge/period accounting (the second `grossFeeGrowthGlobal` accumulator, the
42// `advancePeriod` bookkeeping, and the richer `Tick.cross`). Calibrated against two on-chain
43// Polygon swaps on the USDC/USD.e pool:
44// - 0 tick crossings: ~141.5k gas total (~74k base, ~56k settlement, ~5k step)
45//   0x8fddae1260f40d7d22c0dc4b995ab1e84423adc523e71bbda503f735610db995
46// - 1 tick crossing:  ~184.1k gas total
47//   0xe884fa71e94c4c88d8e225fdc425e4df2321692343e51d06f8e5427729c34010
48// The ~42.5k delta = one extra math step + bitmap scan + the cross itself (its feeGrowthOutside /
49// gauge / period SSTOREs plus the oracle observation a tick change triggers).
50
51// Pre/post loop overhead: cold SLOADs (slot0, liquidity, feeGrowthGlobal) + cold SSTOREs (slot0,
52// liquidity) at end of swap. Uniswap V3 budgets ~70k here; Ramses adds the `grossFeeGrowthGlobal`
53// write and `advancePeriod` period bookkeeping (~+8k on-chain), hence ~78k.
54const SWAP_BASE_GAS: u64 = 78_000;
55// Bitmap word scan (cold SLOAD of tickBitmap word)
56const GAS_PER_BITMAP_WORD: u64 = 2_100;
57// swap math step: getSqrtRatioAtTick + computeSwapStep + amount accounting + getTickAtSqrtRatio
58const GAS_PER_SWAP_MATH_STEP: u64 = 5_400;
59// Initialized tick crossing: Ramses' cross() writes feeGrowthOutside plus gauge/period checkpoints
60// (seconds-per-liquidity, tickCumulative, period start tick) and triggers an oracle observation.
61// Measured at ~35k (the ~42.5k one-cross delta minus the extra math step + bitmap scan).
62const GAS_PER_INITIALIZED_TICK_CROSS: u64 = 35_000;
63// Output transfer + balanceBefore + callback + balanceAfter.
64const V3_CALLBACK_SETTLEMENT_GAS: u64 = 70_000;
65// Conservative max gas budget for a single swap (Ethereum transaction gas limit)
66const MAX_SWAP_GAS: u64 = 16_700_000;
67const MAX_TICKS_CROSSED: u64 = (MAX_SWAP_GAS - SWAP_BASE_GAS) / GAS_PER_INITIALIZED_TICK_CROSS;
68
69/// State of a Ramses V3 pool.
70///
71/// Mirrors `UniswapV3State` but, because the Ramses swap fee is governance-mutable and pools are
72/// keyed by tick spacing, `fee` is stored as a raw `u32` (hundredths of a bip) and `tick_spacing`
73/// is tracked explicitly rather than being derived from the fee tier.
74#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
75pub struct RamsesV3State {
76    liquidity: u128,
77    sqrt_price: U256,
78    fee: u32,
79    tick: i32,
80    tick_spacing: u16,
81    ticks: TickList,
82}
83
84impl RamsesV3State {
85    /// Creates a new instance of `RamsesV3State`.
86    ///
87    /// # Arguments
88    /// - `liquidity`: The initial liquidity of the pool.
89    /// - `sqrt_price`: The square root of the current price.
90    /// - `fee`: The current swap fee, in hundredths of a bip (1_000_000 = 100%).
91    /// - `tick_spacing`: The tick spacing of the pool (its immutable key).
92    /// - `tick`: The current tick of the pool.
93    /// - `ticks`: A vector of `TickInfo` representing the tick information for the pool.
94    pub fn new(
95        liquidity: u128,
96        sqrt_price: U256,
97        fee: u32,
98        tick_spacing: u16,
99        tick: i32,
100        ticks: Vec<TickInfo>,
101    ) -> Result<Self, SimulationError> {
102        let tick_list = TickList::from(tick_spacing, ticks)?;
103        Ok(RamsesV3State { liquidity, sqrt_price, fee, tick, tick_spacing, ticks: tick_list })
104    }
105
106    fn swap(
107        &self,
108        zero_for_one: bool,
109        amount_specified: I256,
110        sqrt_price_limit: Option<U256>,
111    ) -> Result<SwapResults, SimulationError> {
112        if !self.ticks.has_initialized_ticks() {
113            return Err(SimulationError::RecoverableError("No liquidity".to_string()));
114        }
115        let price_limit = if let Some(limit) = sqrt_price_limit {
116            limit
117        } else if zero_for_one {
118            safe_add_u256(MIN_SQRT_RATIO, U256::ONE)?
119        } else {
120            safe_sub_u256(MAX_SQRT_RATIO, U256::ONE)?
121        };
122
123        let price_limit_valid = if zero_for_one {
124            price_limit > MIN_SQRT_RATIO && price_limit < self.sqrt_price
125        } else {
126            price_limit < MAX_SQRT_RATIO && price_limit > self.sqrt_price
127        };
128        if !price_limit_valid {
129            return Err(SimulationError::InvalidInput("Price limit out of range".into(), None));
130        }
131
132        let exact_input = amount_specified > I256::from_raw(U256::from(0u64));
133
134        let mut state = SwapState {
135            amount_remaining: amount_specified,
136            amount_calculated: I256::from_raw(U256::from(0u64)),
137            sqrt_price: self.sqrt_price,
138            tick: self.tick,
139            liquidity: self.liquidity,
140        };
141        let mut gas_used = U256::from(SWAP_BASE_GAS);
142
143        while state.amount_remaining != I256::from_raw(U256::from(0u64)) &&
144            state.sqrt_price != price_limit
145        {
146            let (mut next_tick, initialized) = match self
147                .ticks
148                .next_initialized_tick_within_one_word(state.tick, zero_for_one)
149            {
150                Ok((tick, init)) => {
151                    gas_used = safe_add_u256(gas_used, U256::from(GAS_PER_BITMAP_WORD))?;
152                    (tick, init)
153                }
154                Err(tick_err) => match tick_err.kind {
155                    TickListErrorKind::TicksExeeded => {
156                        let mut new_state = self.clone();
157                        new_state.liquidity = state.liquidity;
158                        new_state.tick = state.tick;
159                        new_state.sqrt_price = state.sqrt_price;
160                        return Err(SimulationError::InvalidInput(
161                            "Ticks exceeded".into(),
162                            Some(GetAmountOutResult::new(
163                                u256_to_biguint(state.amount_calculated.abs().into_raw()),
164                                u256_to_biguint(gas_used),
165                                Box::new(new_state),
166                            )),
167                        ));
168                    }
169                    _ => return Err(SimulationError::FatalError("Unknown error".to_string())),
170                },
171            };
172
173            next_tick = next_tick.clamp(MIN_TICK, MAX_TICK);
174
175            let sqrt_price_start = state.sqrt_price;
176            let sqrt_price_next = get_sqrt_ratio_at_tick(next_tick)?;
177            let (sqrt_price, amount_in, amount_out, fee_amount) = swap_math::compute_swap_step(
178                state.sqrt_price,
179                RamsesV3State::get_sqrt_ratio_target(sqrt_price_next, price_limit, zero_for_one),
180                state.liquidity,
181                state.amount_remaining,
182                self.fee,
183            )?;
184            state.sqrt_price = sqrt_price;
185
186            let step = StepComputation {
187                sqrt_price_start,
188                tick_next: next_tick,
189                initialized,
190                sqrt_price_next,
191                amount_in,
192                amount_out,
193                fee_amount,
194            };
195
196            gas_used = safe_add_u256(gas_used, U256::from(GAS_PER_SWAP_MATH_STEP))?;
197
198            if exact_input {
199                state.amount_remaining -= I256::checked_from_sign_and_abs(
200                    Sign::Positive,
201                    safe_add_u256(step.amount_in, step.fee_amount)?,
202                )
203                .unwrap();
204                state.amount_calculated -=
205                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
206            } else {
207                state.amount_remaining +=
208                    I256::checked_from_sign_and_abs(Sign::Positive, step.amount_out).unwrap();
209                state.amount_calculated += I256::checked_from_sign_and_abs(
210                    Sign::Positive,
211                    safe_add_u256(step.amount_in, step.fee_amount)?,
212                )
213                .unwrap();
214            }
215            if state.sqrt_price == step.sqrt_price_next {
216                if step.initialized {
217                    let liquidity_raw = self
218                        .ticks
219                        .get_tick(step.tick_next)
220                        .unwrap()
221                        .net_liquidity;
222                    let liquidity_net = if zero_for_one { -liquidity_raw } else { liquidity_raw };
223                    state.liquidity =
224                        liquidity_math::add_liquidity_delta(state.liquidity, liquidity_net)?;
225                    gas_used = safe_add_u256(gas_used, U256::from(GAS_PER_INITIALIZED_TICK_CROSS))?;
226                }
227                state.tick = if zero_for_one { step.tick_next - 1 } else { step.tick_next };
228            } else if state.sqrt_price != step.sqrt_price_start {
229                state.tick = get_tick_at_sqrt_ratio(state.sqrt_price)?;
230            }
231        }
232        Ok(SwapResults {
233            amount_calculated: state.amount_calculated,
234            amount_specified,
235            amount_remaining: state.amount_remaining,
236            sqrt_price: state.sqrt_price,
237            liquidity: state.liquidity,
238            tick: state.tick,
239            gas_used: safe_add_u256(gas_used, U256::from(V3_CALLBACK_SETTLEMENT_GAS))?,
240        })
241    }
242
243    fn get_sqrt_ratio_target(
244        sqrt_price_next: U256,
245        sqrt_price_limit: U256,
246        zero_for_one: bool,
247    ) -> U256 {
248        let cond1 = if zero_for_one {
249            sqrt_price_next < sqrt_price_limit
250        } else {
251            sqrt_price_next > sqrt_price_limit
252        };
253
254        if cond1 {
255            sqrt_price_limit
256        } else {
257            sqrt_price_next
258        }
259    }
260}
261
262#[typetag::serde]
263impl ProtocolSim for RamsesV3State {
264    fn fee(&self) -> f64 {
265        self.fee as f64 / 1_000_000.0
266    }
267
268    fn spot_price(&self, a: &Token, b: &Token) -> Result<f64, SimulationError> {
269        let price = if a < b {
270            sqrt_price_q96_to_f64(self.sqrt_price, a.decimals, b.decimals)?
271        } else {
272            1.0f64 / sqrt_price_q96_to_f64(self.sqrt_price, b.decimals, a.decimals)?
273        };
274        Ok(add_fee_markup(price, self.fee()))
275    }
276
277    fn get_amount_out(
278        &self,
279        amount_in: BigUint,
280        token_a: &Token,
281        token_b: &Token,
282    ) -> Result<GetAmountOutResult, SimulationError> {
283        let zero_for_one = token_a < token_b;
284        let amount_specified = U256::try_from_be_slice(&amount_in.to_bytes_be())
285            .and_then(|unsigned_amount_in| {
286                I256::checked_from_sign_and_abs(Sign::Positive, unsigned_amount_in)
287            })
288            .ok_or_else(|| {
289                SimulationError::InvalidInput("I256 overflow: amount_in".to_string(), None)
290            })?;
291
292        let result = self.swap(zero_for_one, amount_specified, None)?;
293
294        trace!(?amount_in, ?token_a, ?token_b, ?zero_for_one, ?result, "RAMSES V3 SWAP");
295        let mut new_state = self.clone();
296        new_state.liquidity = result.liquidity;
297        new_state.tick = result.tick;
298        new_state.sqrt_price = result.sqrt_price;
299
300        Ok(GetAmountOutResult::new(
301            u256_to_biguint(
302                result
303                    .amount_calculated
304                    .saturating_abs()
305                    .into_raw(),
306            ),
307            u256_to_biguint(result.gas_used),
308            Box::new(new_state),
309        ))
310    }
311
312    fn get_limits(
313        &self,
314        token_in: Bytes,
315        token_out: Bytes,
316    ) -> Result<(BigUint, BigUint), SimulationError> {
317        if !self.ticks.has_initialized_ticks() {
318            return Ok((BigUint::ZERO, BigUint::ZERO));
319        }
320
321        let zero_for_one = token_in < token_out;
322        let mut current_tick = self.tick;
323        let mut current_sqrt_price = self.sqrt_price;
324        let mut current_liquidity = self.liquidity;
325        let mut total_amount_in = U256::ZERO;
326        let mut total_amount_out = U256::ZERO;
327        let mut ticks_crossed: u64 = 0;
328
329        // Iterate through ticks in the direction of the swap
330        // Stops when: no more liquidity, no more ticks, or gas limit would be exceeded
331        while let Ok((tick, initialized)) = self
332            .ticks
333            .next_initialized_tick_within_one_word(current_tick, zero_for_one)
334        {
335            // Cap iteration to prevent exceeding Ethereum's gas limit
336            if ticks_crossed == MAX_TICKS_CROSSED {
337                break;
338            }
339            ticks_crossed += 1;
340
341            // Clamp the tick value to ensure it's within valid range
342            let next_tick = tick.clamp(MIN_TICK, MAX_TICK);
343
344            // Calculate the sqrt price at the next tick boundary
345            let sqrt_price_next = get_sqrt_ratio_at_tick(next_tick)?;
346
347            // Calculate the amount of tokens swapped when moving from current_sqrt_price to
348            // sqrt_price_next. Direction determines which token is being swapped in vs out
349            let (amount_in, amount_out) = if zero_for_one {
350                let amount0 = get_amount0_delta(
351                    sqrt_price_next,
352                    current_sqrt_price,
353                    current_liquidity,
354                    true,
355                )?;
356                let amount1 = get_amount1_delta(
357                    sqrt_price_next,
358                    current_sqrt_price,
359                    current_liquidity,
360                    false,
361                )?;
362                (amount0, amount1)
363            } else {
364                let amount0 = get_amount0_delta(
365                    sqrt_price_next,
366                    current_sqrt_price,
367                    current_liquidity,
368                    false,
369                )?;
370                let amount1 = get_amount1_delta(
371                    sqrt_price_next,
372                    current_sqrt_price,
373                    current_liquidity,
374                    true,
375                )?;
376                (amount1, amount0)
377            };
378
379            // Accumulate total amounts for this tick range
380            total_amount_in = safe_add_u256(total_amount_in, amount_in)?;
381            total_amount_out = safe_add_u256(total_amount_out, amount_out)?;
382
383            // If this tick is "initialized" (meaning its someone's position boundary), update the
384            // liquidity when crossing it
385            // For zero_for_one, liquidity is removed when crossing a tick
386            // For one_for_zero, liquidity is added when crossing a tick
387            if initialized {
388                let liquidity_raw = self
389                    .ticks
390                    .get_tick(next_tick)
391                    .unwrap()
392                    .net_liquidity;
393                let liquidity_delta = if zero_for_one { -liquidity_raw } else { liquidity_raw };
394
395                // Check if applying this liquidity delta would cause underflow
396                // If so, stop here rather than continuing with invalid state
397                match liquidity_math::add_liquidity_delta(current_liquidity, liquidity_delta) {
398                    Ok(new_liquidity) => {
399                        current_liquidity = new_liquidity;
400                    }
401                    Err(_) => {
402                        // Liquidity would underflow, stop iteration here
403                        // This represents the maximum liquidity we can actually use
404                        break;
405                    }
406                }
407            }
408
409            // Move to the next tick position
410            current_tick = if zero_for_one { next_tick - 1 } else { next_tick };
411            current_sqrt_price = sqrt_price_next;
412        }
413
414        Ok((u256_to_biguint(total_amount_in), u256_to_biguint(total_amount_out)))
415    }
416
417    fn delta_transition(
418        &mut self,
419        mut delta: ProtocolStateDelta,
420        _tokens: &HashMap<Bytes, Token>,
421        _balances: &Balances,
422    ) -> Result<(), TransitionError> {
423        // apply attribute changes
424        if let Some(liquidity) = delta
425            .updated_attributes
426            .remove("liquidity")
427        {
428            self.liquidity = liquidity.into();
429        }
430        if let Some(sqrt_price) = delta
431            .updated_attributes
432            .get("sqrt_price_x96")
433        {
434            self.sqrt_price = U256::from_be_slice(sqrt_price);
435        }
436        if let Some(tick) = delta.updated_attributes.remove("tick") {
437            self.tick = tick.into();
438        }
439        // The Ramses swap fee is governance-mutable, so apply fee updates here.
440        if let Some(fee) = delta.updated_attributes.remove("fee") {
441            self.fee = fee.into();
442        }
443
444        // update ticks
445        for (key, value) in delta.updated_attributes {
446            let Some(tick) = key.strip_prefix("ticks/") else {
447                continue;
448            };
449
450            self.ticks
451                .set_tick_liquidity(
452                    tick.parse::<i32>()
453                        .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
454                    i128::from(value),
455                )
456                .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
457        }
458        // delete ticks
459        for key in delta.deleted_attributes {
460            let Some(tick) = key.strip_prefix("ticks/") else {
461                continue;
462            };
463
464            self.ticks
465                .set_tick_liquidity(
466                    tick.parse::<i32>()
467                        .map_err(|err| TransitionError::DecodeError(err.to_string()))?,
468                    0,
469                )
470                .map_err(|err| TransitionError::DecodeError(err.to_string()))?;
471        }
472        Ok(())
473    }
474
475    /// See [`ProtocolSim::query_pool_swap`] for the trait documentation.
476    ///
477    /// This method uses Ramses V3 internal swap logic by swapping an infinite amount of token_in
478    /// until the target price is reached.
479    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
480        if !self.ticks.has_initialized_ticks() {
481            return Err(SimulationError::RecoverableError("No liquidity".to_string()));
482        }
483
484        match params.swap_constraint() {
485            SwapConstraint::TradeLimitPrice { .. } => Err(SimulationError::InvalidInput(
486                "Ramses V3 does not support TradeLimitPrice constraint in query_pool_swap"
487                    .to_string(),
488                None,
489            )),
490            SwapConstraint::PoolTargetPrice {
491                target,
492                tolerance: _,
493                min_amount_in: _,
494                max_amount_in: _,
495            } => {
496                let (amount_in, amount_out, swap_result) = clmm_swap_to_price(
497                    self.sqrt_price,
498                    &params.token_in().address,
499                    &params.token_out().address,
500                    target,
501                    self.fee,
502                    Sign::Positive,
503                    |zero_for_one, amount_specified, sqrt_price_limit| {
504                        self.swap(zero_for_one, amount_specified, Some(sqrt_price_limit))
505                    },
506                )?;
507
508                let mut new_state = self.clone();
509                new_state.liquidity = swap_result.liquidity;
510                new_state.tick = swap_result.tick;
511                new_state.sqrt_price = swap_result.sqrt_price;
512
513                Ok(PoolSwap::new(amount_in, amount_out, Box::new(new_state), None))
514            }
515        }
516    }
517
518    fn clone_box(&self) -> Box<dyn ProtocolSim> {
519        Box::new(self.clone())
520    }
521
522    fn as_any(&self) -> &dyn Any {
523        self
524    }
525
526    fn as_any_mut(&mut self) -> &mut dyn Any {
527        self
528    }
529
530    fn eq(&self, other: &dyn ProtocolSim) -> bool {
531        let Some(RamsesV3State { liquidity, sqrt_price, fee, tick, tick_spacing, ticks }) = other
532            .as_any()
533            .downcast_ref::<RamsesV3State>()
534        else {
535            return false;
536        };
537
538        &self.liquidity == liquidity &&
539            &self.sqrt_price == sqrt_price &&
540            &self.fee == fee &&
541            &self.tick == tick &&
542            &self.tick_spacing == tick_spacing &&
543            &self.ticks == ticks
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use std::{
550        collections::{HashMap, HashSet},
551        str::FromStr,
552    };
553
554    use num_bigint::ToBigUint;
555    use tycho_common::models::Chain;
556
557    use super::*;
558
559    // Real WBTC/WETH pool data (Uniswap V3, 0.05% fee, tick spacing 10). Because the Ramses swap
560    // math is identical to Uniswap V3, the same inputs must produce the same outputs — this test
561    // guards math parity with the upstream implementation.
562    #[test]
563    fn test_get_amount_out_math_parity() {
564        let wbtc = Token::new(
565            &Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap(),
566            "WBTC",
567            8,
568            0,
569            &[Some(10_000)],
570            Chain::Ethereum,
571            100,
572        );
573        let weth = Token::new(
574            &Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(),
575            "WETH",
576            18,
577            0,
578            &[Some(10_000)],
579            Chain::Ethereum,
580            100,
581        );
582        let pool = RamsesV3State::new(
583            377952820878029838,
584            U256::from_str("28437325270877025820973479874632004").unwrap(),
585            500,
586            10,
587            255830,
588            vec![
589                TickInfo::new(255760, 1759015528199933i128).unwrap(),
590                TickInfo::new(255770, 6393138051835308i128).unwrap(),
591                TickInfo::new(255780, 228206673808681i128).unwrap(),
592                TickInfo::new(255820, 1319490609195820i128).unwrap(),
593                TickInfo::new(255830, 678916926147901i128).unwrap(),
594                TickInfo::new(255840, 12208947683433103i128).unwrap(),
595                TickInfo::new(255850, 1177970713095301i128).unwrap(),
596                TickInfo::new(255860, 8752304680520407i128).unwrap(),
597                TickInfo::new(255880, 1486478248067104i128).unwrap(),
598                TickInfo::new(255890, 1878744276123248i128).unwrap(),
599                TickInfo::new(255900, 77340284046725227i128).unwrap(),
600            ],
601        )
602        .unwrap();
603
604        let res = pool
605            .get_amount_out(500000000.to_biguint().unwrap(), &wbtc, &weth)
606            .unwrap();
607
608        assert_eq!(res.amount, BigUint::from_str("64352395915550406461").unwrap());
609    }
610
611    #[test]
612    fn test_delta_transition_updates_mutable_fee() {
613        let mut pool = RamsesV3State::new(
614            1000,
615            U256::from_str("1000").unwrap(),
616            500,
617            10,
618            100,
619            vec![TickInfo::new(255760, 10000).unwrap(), TickInfo::new(255900, -10000).unwrap()],
620        )
621        .unwrap();
622
623        let attributes: HashMap<String, Bytes> = [
624            ("liquidity".to_string(), Bytes::from(2000_u64.to_be_bytes().to_vec())),
625            // 3000 hundredths-of-a-bip, as emitted by the substreams (to_signed_bytes_be ->
626            // 0x0bb8)
627            ("fee".to_string(), Bytes::from(3000_u32.to_be_bytes().to_vec())),
628        ]
629        .into_iter()
630        .collect();
631        let delta = ProtocolStateDelta {
632            component_id: "State1".to_owned(),
633            updated_attributes: attributes,
634            deleted_attributes: HashSet::new(),
635        };
636
637        pool.delta_transition(delta, &HashMap::new(), &Balances::default())
638            .unwrap();
639
640        assert_eq!(pool.liquidity, 2000);
641        assert_eq!(pool.fee, 3000);
642        assert_eq!(pool.fee(), 0.003);
643    }
644}