Skip to main content

tycho_simulation/evm/protocol/aerodrome_v1/
state.rs

1use std::{any::Any, collections::HashMap};
2
3use alloy::primitives::U256;
4use num_bigint::BigUint;
5use num_traits::Zero;
6use serde::{Deserialize, Serialize};
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 super::solidly_stable::{
21    get_amount_out as solidly_stable_get_amount_out, get_limits as solidly_stable_get_limits,
22};
23use crate::evm::protocol::{
24    cpmm::protocol::{cpmm_fee, cpmm_get_limits, cpmm_spot_price, cpmm_swap_to_price, ProtocolFee},
25    safe_math::{safe_add_u256, safe_div_u256, safe_mul_u256, safe_sub_u256},
26    u256_num::{biguint_to_u256, u256_to_biguint},
27    utils::add_fee_markup,
28};
29
30const FEE_PRECISION_BPS: u32 = 10_000;
31const FEE_PRECISION: U256 = U256::from_limbs([10_000, 0, 0, 0]);
32const AERODROME_V1_STABLE_FEE_BPS: u32 = 5;
33const AERODROME_V1_VOLATILE_FEE_BPS: u32 = 30;
34const AERODROME_V1_ZERO_FEE_INDICATOR_BPS: u32 = 420;
35
36#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
37pub struct AerodromeV1State {
38    pub reserve0: U256,
39    pub reserve1: U256,
40    pub stable: bool,
41    pub fee: u32,
42    pub decimals0: u8,
43    pub decimals1: u8,
44}
45
46impl AerodromeV1State {
47    /// Creates a new instance of `AerodromeV1State` with the given reserves and raw fee.
48    pub fn new(
49        reserve0: U256,
50        reserve1: U256,
51        stable: bool,
52        fee: u32,
53        decimals0: u8,
54        decimals1: u8,
55    ) -> Self {
56        Self { reserve0, reserve1, stable, fee, decimals0, decimals1 }
57    }
58
59    fn default_fee_bps(&self) -> u32 {
60        if self.stable {
61            AERODROME_V1_STABLE_FEE_BPS
62        } else {
63            AERODROME_V1_VOLATILE_FEE_BPS
64        }
65    }
66
67    fn resolved_fee_bps(&self) -> u32 {
68        if self.fee == AERODROME_V1_ZERO_FEE_INDICATOR_BPS {
69            0
70        } else if self.fee != 0 {
71            self.fee
72        } else {
73            self.default_fee_bps()
74        }
75    }
76
77    fn protocol_fee(&self) -> Result<ProtocolFee, SimulationError> {
78        let fee_bps = self.resolved_fee_bps();
79
80        if fee_bps > FEE_PRECISION_BPS {
81            return Err(SimulationError::FatalError(format!(
82                "Invalid resolved fee value {}, expected <= {} bps",
83                fee_bps, FEE_PRECISION_BPS
84            )));
85        }
86
87        Ok(ProtocolFee::new(U256::from(FEE_PRECISION_BPS - fee_bps), FEE_PRECISION))
88    }
89
90    /// Aerodrome V1 volatile pools do not match our generic CPMM helper exactly.
91    ///
92    /// The on-chain pool first computes `floor(amount_in * fee / 10000)`, subtracts that fee from
93    /// `amount_in`, and only then applies the constant-product formula. Reusing
94    /// `cpmm_get_amount_out` would instead fold the fee into the numerator/denominator as
95    /// `amount_in * (10000 - fee) / 10000`, which is algebraically equivalent over reals but not
96    /// under Solidity integer division. That rounding difference is observable on-chain, so we
97    /// mirror the contract implementation here.
98    fn volatile_get_amount_out(
99        &self,
100        amount_in: U256,
101        reserve_in: U256,
102        reserve_out: U256,
103    ) -> Result<U256, SimulationError> {
104        if amount_in == U256::ZERO {
105            return Err(SimulationError::InvalidInput("Amount in cannot be zero".to_string(), None));
106        }
107
108        if reserve_in == U256::ZERO || reserve_out == U256::ZERO {
109            return Err(SimulationError::RecoverableError("No liquidity".to_string()));
110        }
111
112        let fee_bps = self.resolved_fee_bps();
113        let fee_amount =
114            safe_div_u256(safe_mul_u256(amount_in, U256::from(fee_bps))?, FEE_PRECISION)?;
115        let amount_in_after_fee = safe_sub_u256(amount_in, fee_amount)?;
116        let numerator = safe_mul_u256(amount_in_after_fee, reserve_out)?;
117        let denominator = safe_add_u256(reserve_in, amount_in_after_fee)?;
118
119        safe_div_u256(numerator, denominator)
120    }
121}
122
123#[typetag::serde]
124impl ProtocolSim for AerodromeV1State {
125    fn fee(&self) -> f64 {
126        cpmm_fee(self.resolved_fee_bps())
127    }
128
129    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
130        let price = cpmm_spot_price(base, quote, self.reserve0, self.reserve1)?;
131        Ok(add_fee_markup(price, self.fee()))
132    }
133
134    fn get_amount_out(
135        &self,
136        amount_in: BigUint,
137        token_in: &Token,
138        token_out: &Token,
139    ) -> Result<GetAmountOutResult, SimulationError> {
140        let amount_in = biguint_to_u256(&amount_in);
141        let zero2one = token_in.address < token_out.address;
142        let amount_out = if self.stable {
143            solidly_stable_get_amount_out(
144                amount_in,
145                zero2one,
146                self.reserve0,
147                self.reserve1,
148                self.resolved_fee_bps(),
149                if zero2one { token_in.decimals as u8 } else { token_out.decimals as u8 },
150                if zero2one { token_out.decimals as u8 } else { token_in.decimals as u8 },
151            )?
152        } else {
153            let (reserve_in, reserve_out) = if zero2one {
154                (self.reserve0, self.reserve1)
155            } else {
156                (self.reserve1, self.reserve0)
157            };
158            self.volatile_get_amount_out(amount_in, reserve_in, reserve_out)?
159        };
160        let mut new_state = self.clone();
161        let (reserve0_mut, reserve1_mut) = (&mut new_state.reserve0, &mut new_state.reserve1);
162        if zero2one {
163            *reserve0_mut = safe_add_u256(self.reserve0, amount_in)?;
164            *reserve1_mut = safe_sub_u256(self.reserve1, amount_out)?;
165        } else {
166            *reserve0_mut = safe_sub_u256(self.reserve0, amount_out)?;
167            *reserve1_mut = safe_add_u256(self.reserve1, amount_in)?;
168        };
169        Ok(GetAmountOutResult::new(
170            u256_to_biguint(amount_out),
171            BigUint::from(120_000u32),
172            Box::new(new_state),
173        ))
174    }
175
176    fn get_limits(
177        &self,
178        sell_token: Bytes,
179        buy_token: Bytes,
180    ) -> Result<(BigUint, BigUint), SimulationError> {
181        if self.stable {
182            solidly_stable_get_limits(
183                sell_token,
184                buy_token,
185                self.reserve0,
186                self.reserve1,
187                self.decimals0,
188                self.decimals1,
189            )
190        } else {
191            cpmm_get_limits(
192                sell_token,
193                buy_token,
194                self.reserve0,
195                self.reserve1,
196                self.resolved_fee_bps(),
197            )
198        }
199    }
200
201    fn delta_transition(
202        &mut self,
203        delta: ProtocolStateDelta,
204        _tokens: &HashMap<Bytes, Token>,
205        _balances: &Balances,
206    ) -> Result<(), TransitionError> {
207        if let Some(reserve0) = delta.updated_attributes.get("reserve0") {
208            self.reserve0 = U256::from_be_slice(reserve0);
209        }
210        if let Some(reserve1) = delta.updated_attributes.get("reserve1") {
211            self.reserve1 = U256::from_be_slice(reserve1);
212        }
213        if let Some(fee) = delta.updated_attributes.get("fee") {
214            self.fee = u32::from(fee.clone());
215            let resolved_fee_bps = self.resolved_fee_bps();
216            if resolved_fee_bps > FEE_PRECISION_BPS {
217                return Err(TransitionError::DecodeError(format!(
218                    "Invalid resolved fee value {}, expected <= {} bps",
219                    resolved_fee_bps, FEE_PRECISION_BPS
220                )));
221            }
222        }
223        Ok(())
224    }
225
226    fn query_pool_swap(&self, params: &QueryPoolSwapParams) -> Result<PoolSwap, SimulationError> {
227        match params.swap_constraint() {
228            SwapConstraint::PoolTargetPrice {
229                target: price,
230                tolerance: _,
231                min_amount_in: _,
232                max_amount_in: _,
233            } => {
234                let zero2one = params.token_in().address < params.token_out().address;
235                let (reserve_in, reserve_out) = if zero2one {
236                    (self.reserve0, self.reserve1)
237                } else {
238                    (self.reserve1, self.reserve0)
239                };
240
241                let (amount_in, _) =
242                    cpmm_swap_to_price(reserve_in, reserve_out, price, self.protocol_fee()?)?;
243                if amount_in.is_zero() {
244                    return Ok(PoolSwap::new(
245                        BigUint::ZERO,
246                        BigUint::ZERO,
247                        Box::new(self.clone()),
248                        None,
249                    ));
250                }
251
252                let res =
253                    self.get_amount_out(amount_in.clone(), params.token_in(), params.token_out())?;
254                Ok(PoolSwap::new(amount_in, res.amount, res.new_state, None))
255            }
256            SwapConstraint::TradeLimitPrice { .. } => Err(SimulationError::InvalidInput(
257                "AerodromeV1State does not support TradeLimitPrice constraint in query_pool_swap"
258                    .to_string(),
259                None,
260            )),
261        }
262    }
263
264    fn clone_box(&self) -> Box<dyn ProtocolSim> {
265        Box::new(self.clone())
266    }
267
268    fn as_any(&self) -> &dyn Any {
269        self
270    }
271
272    fn as_any_mut(&mut self) -> &mut dyn Any {
273        self
274    }
275
276    fn eq(&self, other: &dyn ProtocolSim) -> bool {
277        if let Some(other_state) = other.as_any().downcast_ref::<Self>() {
278            self.reserve0 == other_state.reserve0 &&
279                self.reserve1 == other_state.reserve1 &&
280                self.stable == other_state.stable &&
281                self.fee == other_state.fee &&
282                self.decimals0 == other_state.decimals0 &&
283                self.decimals1 == other_state.decimals1
284        } else {
285            false
286        }
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use std::{
293        collections::{HashMap, HashSet},
294        str::FromStr,
295    };
296
297    use alloy::primitives::U256;
298    use num_bigint::BigUint;
299    use num_traits::One;
300    use tycho_common::{
301        dto::ProtocolStateDelta,
302        hex_bytes::Bytes,
303        models::{token::Token, Chain},
304        simulation::{
305            errors::TransitionError,
306            protocol_sim::{Balances, ProtocolSim},
307        },
308    };
309
310    use super::{AerodromeV1State, AERODROME_V1_ZERO_FEE_INDICATOR_BPS};
311
312    fn token_0() -> Token {
313        Token::new(&Bytes::from([0_u8; 20]), "T0", 18, 0, &[Some(10_000)], Chain::Ethereum, 100)
314    }
315
316    fn token_1() -> Token {
317        let mut addr = [0_u8; 20];
318        addr[19] = 1;
319        Token::new(&Bytes::from(addr), "T1", 18, 0, &[Some(10_000)], Chain::Ethereum, 100)
320    }
321
322    fn base_usdc() -> Token {
323        Token::new(
324            &Bytes::from_str("0x833589fcd6edb6e08f4c7c32d4f71b54bda02913").unwrap(),
325            "USDC",
326            6,
327            0,
328            &[Some(10_000)],
329            Chain::Base,
330            100,
331        )
332    }
333
334    fn base_usdt() -> Token {
335        Token::new(
336            &Bytes::from_str("0xfde4c96c8593536e31f229ea8f37b2ada2699bb2").unwrap(),
337            "USDT",
338            6,
339            0,
340            &[Some(10_000)],
341            Chain::Base,
342            100,
343        )
344    }
345
346    fn base_aero() -> Token {
347        Token::new(
348            &Bytes::from_str("0x940181a94a35a4569e4529a3cdfb74e38fd98631").unwrap(),
349            "AERO",
350            18,
351            0,
352            &[Some(10_000)],
353            Chain::Base,
354            100,
355        )
356    }
357
358    #[test]
359    fn test_get_amount_out_matches_real_volatile_pool_on_chain() {
360        // Base Aerodrome volatile USDC/AERO pool 0x6cDcb1C4A4D1C3C6d054b27AC5B77e89eAFb971d
361        // at block 44,628,997:
362        // - fee: 30 bps
363        // - reserves: 12_130_133_468_200 / 33_517_464_576_714_176_786_208_401
364        // - getAmountOut(26_225_348_558, USDC) = 72_091_968_892_551_547_616_192
365        let state = AerodromeV1State::new(
366            U256::from_str("12130133468200").unwrap(),
367            U256::from_str("33517464576714176786208401").unwrap(),
368            false,
369            30,
370            6,
371            18,
372        );
373        let out = state
374            .get_amount_out(BigUint::from_str("26225348558").unwrap(), &base_usdc(), &base_aero())
375            .expect("swap should succeed");
376
377        assert_eq!(out.amount, BigUint::from_str("72091968892551547616192").unwrap());
378    }
379
380    #[test]
381    fn test_delta_transition_supports_fee_only_update() {
382        let mut state = AerodromeV1State::new(
383            U256::from(2_000_000u32),
384            U256::from(1_000_000u32),
385            false,
386            30,
387            18,
388            18,
389        );
390        let delta = ProtocolStateDelta {
391            component_id: "pool".to_string(),
392            updated_attributes: HashMap::from([(
393                "fee".to_string(),
394                Bytes::from(5_u32.to_be_bytes().to_vec()),
395            )]),
396            deleted_attributes: HashSet::new(),
397        };
398
399        state
400            .delta_transition(delta, &HashMap::new(), &Balances::default())
401            .expect("fee-only update should succeed");
402        assert_eq!(state.fee, 5);
403        assert_eq!(state.reserve0, U256::from(2_000_000u32));
404        assert_eq!(state.reserve1, U256::from(1_000_000u32));
405    }
406
407    #[test]
408    fn test_delta_transition_rejects_invalid_fee() {
409        let mut state = AerodromeV1State::new(U256::ONE, U256::ONE, false, 30, 18, 18);
410        let delta = ProtocolStateDelta {
411            component_id: "pool".to_string(),
412            updated_attributes: HashMap::from([(
413                "fee".to_string(),
414                Bytes::from(10_101_u32.to_be_bytes().to_vec()),
415            )]),
416            deleted_attributes: HashSet::new(),
417        };
418
419        let err = state
420            .delta_transition(delta, &HashMap::new(), &Balances::default())
421            .expect_err("invalid fee should fail");
422        assert!(matches!(err, TransitionError::DecodeError(_)));
423    }
424
425    #[test]
426    fn test_fee_fn_returns_fraction() {
427        let state = AerodromeV1State::new(U256::ONE, U256::ONE, false, 30, 18, 18);
428        assert_eq!(state.fee(), 0.003);
429        let state = AerodromeV1State::new(U256::ONE, U256::ONE, true, 5, 18, 18);
430        assert_eq!(state.fee(), 0.0005);
431    }
432
433    #[test]
434    fn test_protocol_fee_accepts_zero_fee_indicator() {
435        let state = AerodromeV1State::new(
436            U256::ONE,
437            U256::ONE,
438            true,
439            AERODROME_V1_ZERO_FEE_INDICATOR_BPS,
440            18,
441            18,
442        );
443        assert!(state.protocol_fee().is_ok());
444        assert_eq!(state.fee(), 0.0);
445    }
446
447    #[test]
448    fn test_protocol_fee_rejects_out_of_range() {
449        let state = AerodromeV1State::new(U256::ONE, U256::ONE, false, 10_001, 18, 18);
450        assert!(state.protocol_fee().is_err());
451    }
452
453    #[test]
454    fn test_fee_defaults_when_custom_fee_missing() {
455        let state = AerodromeV1State::new(U256::ONE, U256::ONE, false, 0, 18, 18);
456        assert_eq!(state.fee(), 0.003);
457        let stable_state = AerodromeV1State::new(U256::ONE, U256::ONE, true, 0, 18, 18);
458        assert_eq!(stable_state.fee(), 0.0005);
459    }
460
461    #[test]
462    fn test_get_amount_out_no_fee() {
463        let state = AerodromeV1State::new(
464            U256::from(10_000u32),
465            U256::from(10_000u32),
466            false,
467            AERODROME_V1_ZERO_FEE_INDICATOR_BPS,
468            18,
469            18,
470        );
471        let out = state
472            .get_amount_out(BigUint::one(), &token_0(), &token_1())
473            .expect("swap should succeed");
474        assert_eq!(
475            out.amount,
476            BigUint::one() * BigUint::from(10_000u32) / BigUint::from(10_001u32)
477        );
478    }
479
480    #[test]
481    fn test_get_amount_out_stable_uses_cfmm_curve() {
482        let state = AerodromeV1State::new(
483            U256::from(2_642_455_102_346_776_307_825u128),
484            U256::from(3_320_301_880_379_841_502_303u128),
485            true,
486            5,
487            18,
488            18,
489        );
490
491        let out = state
492            .get_amount_out(BigUint::from(2_000_000_000_000_000_000u128), &token_0(), &token_1())
493            .expect("stable swap should succeed");
494
495        assert_eq!(out.amount, BigUint::from(2_004_830_151_166_915_124u128));
496    }
497
498    #[test]
499    fn test_get_amount_out_matches_real_stable_pool_on_chain() {
500        // Base Aerodrome stable USDC/USDT pool 0x96508AE8037c6bD16162620187691F1c1e3e07C1
501        // at block 44,629,732:
502        // - fee: 5 bps
503        // - reserves: 2_170_141_538 / 2_029_164_659
504        // - getAmountOut(123_456_789, USDC) = 123_320_126
505        let state = AerodromeV1State::new(
506            U256::from(2_170_141_538u32),
507            U256::from(2_029_164_659u32),
508            true,
509            5,
510            6,
511            6,
512        );
513
514        let out = state
515            .get_amount_out(BigUint::from(123_456_789u32), &base_usdc(), &base_usdt())
516            .expect("stable swap should succeed");
517
518        assert_eq!(out.amount, BigUint::from(123_320_126u32));
519    }
520}