Skip to main content

tycho_simulation/evm/protocol/curve/
state.rs

1//! [`CurveState`] — a hybrid Curve pool: pure-Rust quote math (`curve_math::Pool`) over state read
2//! from the locally indexed VM storage.
3use std::any::Any;
4
5use alloy::primitives::{Address as AlloyAddress, U256};
6use num_bigint::{BigUint, ToBigUint};
7use serde::{Deserialize, Serialize};
8use tycho_common::{
9    dto::ProtocolStateDelta,
10    models::token::Token,
11    simulation::{
12        errors::{SimulationError, TransitionError},
13        protocol_sim::{Balances, GetAmountOutResult, ProtocolSim},
14    },
15    Bytes,
16};
17
18use crate::evm::{
19    engine_db::{create_engine, SHARED_TYCHO_DB},
20    protocol::{
21        curve::{
22            adapter::{build_pool, CurveVariant},
23            math::Pool,
24            vm,
25        },
26        u256_num::{biguint_to_u256, u256_to_biguint, u256_to_f64},
27    },
28};
29
30/// Curve fee denominator (`10^10`); both StableSwap `fee` and CryptoSwap `mid_fee` use it.
31const FEE_DENOMINATOR: f64 = 1e10;
32/// Representative gas cost of a StableSwap exchange.
33const STABLESWAP_GAS: u64 = 150_000;
34/// Representative gas cost of a CryptoSwap exchange (heavier math + price oracle update).
35const CRYPTOSWAP_GAS: u64 = 350_000;
36
37/// A single Curve pool quoted via `curve_math`.
38///
39/// `tokens` and `decimals` are ordered to match the pool's coin indices, so a token address maps
40/// directly to a `curve_math` coin index. State (`pool`) is rebuilt from the VM on every
41/// `delta_transition`.
42///
43/// Multi-hop limitation: the state returned by [`ProtocolSim::get_amount_out`] updates coin
44/// balances only, holding `D` and `price_scale` fixed. This is exact for StableSwap (which
45/// recomputes `D` from balances on every quote), but a route that re-quotes the *same* CryptoSwap
46/// pool sees an approximation on the second hop, because CryptoSwap caches `D` and would update it
47/// (via `tweak_price`) after an on-chain exchange.
48#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
49pub struct CurveState {
50    /// Pool contract address (the Tycho component id).
51    pool_address: Bytes,
52    /// Coin addresses in pool index order.
53    tokens: Vec<Bytes>,
54    /// Coin decimals in pool index order.
55    decimals: Vec<u8>,
56    /// Resolved math variant.
57    variant: CurveVariant,
58    /// Constructed math pool used for quoting.
59    pool: Pool,
60}
61
62impl CurveState {
63    /// Construct a `CurveState` from a resolved variant and a built `curve_math::Pool`.
64    pub fn new(
65        pool_address: Bytes,
66        tokens: Vec<Bytes>,
67        decimals: Vec<u8>,
68        variant: CurveVariant,
69        pool: Pool,
70    ) -> Self {
71        Self { pool_address, tokens, decimals, variant, pool }
72    }
73
74    fn coin_index(&self, token: &Bytes) -> Result<usize, SimulationError> {
75        self.tokens
76            .iter()
77            .position(|t| t == token)
78            .ok_or_else(|| {
79                SimulationError::InvalidInput(
80                    format!("token {token} is not a coin of curve pool {}", self.pool_address),
81                    None,
82                )
83            })
84    }
85
86    fn is_crypto(&self) -> bool {
87        matches!(
88            self.variant,
89            CurveVariant::TwoCryptoV1 |
90                CurveVariant::TwoCryptoNG |
91                CurveVariant::TwoCryptoStable |
92                CurveVariant::TriCryptoV1 |
93                CurveVariant::TriCryptoNG
94        )
95    }
96
97    fn gas_estimate(&self) -> u64 {
98        if self.is_crypto() {
99            CRYPTOSWAP_GAS
100        } else {
101            STABLESWAP_GAS
102        }
103    }
104}
105
106#[typetag::serde]
107impl ProtocolSim for CurveState {
108    fn fee(&self) -> f64 {
109        let fee = self.pool.fee().or_else(|| {
110            self.pool
111                .crypto_fees()
112                .map(|(mid, _, _)| mid)
113        });
114        fee.and_then(|f| u256_to_f64(f).ok())
115            .map(|f| f / FEE_DENOMINATOR)
116            .unwrap_or(0.0)
117    }
118
119    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
120        let i = self.coin_index(&base.address)?;
121        let j = self.coin_index(&quote.address)?;
122        let (numerator, denominator) = self
123            .pool
124            .spot_price(i, j)
125            .ok_or_else(|| {
126                SimulationError::RecoverableError(format!(
127                    "curve spot price unavailable for {}",
128                    self.pool_address
129                ))
130            })?;
131        // curve_math returns dy/dx (quote per base) in native token units and fee-inclusive;
132        // rescale to human units of quote per 1 base.
133        let ratio = u256_to_f64(numerator)? / u256_to_f64(denominator)?;
134        let decimal_adjustment = 10f64.powi(base.decimals as i32 - quote.decimals as i32);
135        Ok(ratio * decimal_adjustment)
136    }
137
138    fn get_amount_out(
139        &self,
140        amount_in: BigUint,
141        token_in: &Token,
142        token_out: &Token,
143    ) -> Result<GetAmountOutResult, SimulationError> {
144        let i = self.coin_index(&token_in.address)?;
145        let j = self.coin_index(&token_out.address)?;
146        let dx = biguint_to_u256(&amount_in);
147
148        let dy = self
149            .pool
150            .get_amount_out(i, j, dx)
151            .ok_or_else(|| {
152                SimulationError::RecoverableError(format!(
153                    "curve get_amount_out failed for {}",
154                    self.pool_address
155                ))
156            })?;
157
158        let mut new_pool = self.pool.clone();
159        let (balance_in, balance_out) = {
160            let balances = new_pool.balances();
161            (balances[i], balances[j])
162        };
163        // Apply the swap to coin balances for multi-hop routing. Stored D / price_scale are kept
164        // as-is (the invariant is preserved across a swap; price_scale only moves on rebalancing),
165        // which is an approximation if the same crypto pool is hit twice within one route.
166        new_pool
167            .set_balance(i, balance_in + dx)
168            .map_err(|e| SimulationError::FatalError(format!("curve set_balance failed: {e}")))?;
169        new_pool
170            .set_balance(j, balance_out.saturating_sub(dy))
171            .map_err(|e| SimulationError::FatalError(format!("curve set_balance failed: {e}")))?;
172
173        let new_state = Self { pool: new_pool, ..self.clone() };
174        Ok(GetAmountOutResult::new(
175            u256_to_biguint(dy),
176            self.gas_estimate()
177                .to_biguint()
178                .expect("u64 fits in BigUint"),
179            Box::new(new_state),
180        ))
181    }
182
183    fn get_limits(
184        &self,
185        sell_token: Bytes,
186        buy_token: Bytes,
187    ) -> Result<(BigUint, BigUint), SimulationError> {
188        let i = self.coin_index(&sell_token)?;
189        let j = self.coin_index(&buy_token)?;
190        let (balance_in, balance_out) = {
191            let balances = self.pool.balances();
192            (balances[i], balances[j])
193        };
194        if balance_in.is_zero() || balance_out.is_zero() {
195            return Ok((BigUint::ZERO, BigUint::ZERO));
196        }
197        // Soft limit: cap the input at the pool's own balance of the sell token. Beyond this the
198        // solver math becomes unreliable and output approaches the available reserve.
199        let max_out_reserve = balance_out.saturating_sub(U256::from(1));
200        let max_out = self
201            .pool
202            .get_amount_out(i, j, balance_in)
203            .ok_or_else(|| {
204                SimulationError::RecoverableError(format!(
205                    "curve get_limits: solver failed at max input for {}",
206                    self.pool_address
207                ))
208            })?
209            .min(max_out_reserve);
210        Ok((u256_to_biguint(balance_in), u256_to_biguint(max_out)))
211    }
212
213    /// When `updated_attributes` carries [`vm::POOL_STATE_ADJUSTED`], the pool is rebuilt from
214    /// those readings. Otherwise the view getters are read from the indexed VM storage.
215    ///
216    /// The attribute exists for pending blocks, whose state never reaches that storage: an
217    /// indexer that has already read the pool under the pending block's overrides passes the
218    /// readings through instead.
219    fn delta_transition(
220        &mut self,
221        delta: ProtocolStateDelta,
222        _tokens: &std::collections::HashMap<Bytes, Token>,
223        _balances: &Balances,
224    ) -> Result<(), TransitionError> {
225        self.pool = match delta
226            .updated_attributes
227            .get(vm::POOL_STATE_ADJUSTED)
228        {
229            Some(encoded) => {
230                let state = vm::decode_raw_state(encoded)?;
231                if state.variant != self.variant {
232                    return Err(SimulationError::FatalError(format!(
233                        "Variant mismatch: expected {}, got {}",
234                        self.variant, state.variant
235                    ))
236                    .into())
237                }
238                if state.token_decimals != self.decimals {
239                    return Err(SimulationError::FatalError(format!(
240                        "Token decimals mismatch: expected {:?}, got {:?}",
241                        self.decimals, state.token_decimals
242                    ))
243                    .into())
244                }
245                build_pool(&state).map_err(|e| {
246                    SimulationError::FatalError(format!("curve build_pool failed: {e}"))
247                })?
248            }
249            None => {
250                let engine = create_engine(SHARED_TYCHO_DB.clone(), false).expect("Infallible");
251                let pool_address = AlloyAddress::from_slice(self.pool_address.as_ref());
252                vm::decode_from_vm(&engine, &pool_address, self.variant, &self.decimals)?
253            }
254        };
255        Ok(())
256    }
257
258    fn clone_box(&self) -> Box<dyn ProtocolSim> {
259        Box::new(self.clone())
260    }
261
262    fn as_any(&self) -> &dyn Any {
263        self
264    }
265
266    fn as_any_mut(&mut self) -> &mut dyn Any {
267        self
268    }
269
270    fn eq(&self, other: &dyn ProtocolSim) -> bool {
271        other
272            .as_any()
273            .downcast_ref::<Self>()
274            .is_some_and(|other| self == other)
275    }
276}
277
278#[cfg(test)]
279mod tests {
280    use std::collections::HashMap;
281
282    use super::*;
283    use crate::evm::protocol::curve::{adapter::RawPoolState, vm::encode_raw_state};
284
285    const VARIANT: CurveVariant = CurveVariant::TriCryptoNG;
286    const DECIMALS: [u8; 3] = [6, 8, 18];
287
288    fn u(s: &str) -> U256 {
289        s.parse().unwrap()
290    }
291
292    /// The TriCryptoNG USDC/WBTC/WETH pool (0x7f86bf…), balances aside.
293    fn raw_pool_state(balances: Vec<U256>) -> RawPoolState {
294        RawPoolState {
295            variant: VARIANT,
296            balances,
297            token_decimals: DECIMALS.to_vec(),
298            amp: u("1707629"),
299            mid_fee: Some(u("3000000")),
300            out_fee: Some(u("30000000")),
301            fee_gamma: Some(u("500000000000000")),
302            d: Some(u("7457948167729606869978625")),
303            gamma: Some(u("11809167828997")),
304            price_scale: Some(vec![u("59372627314351316239076"), u("1565715369034455123313")]),
305            ..Default::default()
306        }
307    }
308
309    fn state(balances: Vec<U256>) -> CurveState {
310        let raw_state = raw_pool_state(balances);
311        let pool = build_pool(&raw_state).expect("build");
312        CurveState::new(
313            Bytes::from([7u8; 20]),
314            vec![Bytes::from([1u8; 20]), Bytes::from([2u8; 20]), Bytes::from([3u8; 20])],
315            DECIMALS.to_vec(),
316            VARIANT,
317            pool,
318        )
319    }
320
321    fn delta(attributes: HashMap<String, Bytes>) -> ProtocolStateDelta {
322        ProtocolStateDelta { updated_attributes: attributes, ..Default::default() }
323    }
324
325    #[test]
326    fn test_delta_transition_rebuilds_from_attribute() {
327        let confirmed = vec![u("2466241139205"), u("4200057336"), u("1595469030050811720465")];
328        let pending = vec![u("2470000000000"), u("4190000000"), u("1600000000000000000000")];
329        let mut curve = state(confirmed.clone());
330        let attribute = encode_raw_state(&raw_pool_state(pending.clone())).expect("encode");
331
332        curve
333            .delta_transition(
334                delta(HashMap::from([(vm::POOL_STATE_ADJUSTED.to_string(), attribute)])),
335                &HashMap::new(),
336                &Balances::default(),
337            )
338            .expect("delta transition from attribute failed");
339
340        // The readings must come from the attribute. A VM read would fail here anyway: the
341        // shared engine has no block set in this test.
342        assert_eq!(
343            curve.pool.balances()[..3],
344            pending[..],
345            "balances must come from the attribute"
346        );
347        assert_ne!(curve.pool.balances()[..3], confirmed[..]);
348    }
349
350    #[test]
351    fn test_delta_transition_errors_on_variant_mismatch() {
352        let mut curve = state(vec![u("1"), u("2"), u("3")]);
353        let mut pending_state = raw_pool_state(curve.pool.balances().to_vec());
354        pending_state.variant = CurveVariant::StableSwapMeta;
355        let encoded = encode_raw_state(&pending_state).expect("encode");
356
357        let result = curve.delta_transition(
358            delta(HashMap::from([(vm::POOL_STATE_ADJUSTED.to_string(), encoded)])),
359            &HashMap::new(),
360            &Balances::default(),
361        );
362
363        assert!(matches!(result, Err(TransitionError::SimulationError(_))), "got {result:?}");
364    }
365
366    #[test]
367    fn test_delta_transition_errors_on_decimals_mismatch() {
368        let mut curve = state(vec![u("1"), u("2"), u("3")]);
369        let mut pending_state = raw_pool_state(curve.pool.balances().to_vec());
370        pending_state.token_decimals = vec![18, 18, 18];
371        let encoded = encode_raw_state(&pending_state).expect("encode");
372
373        let result = curve.delta_transition(
374            delta(HashMap::from([(vm::POOL_STATE_ADJUSTED.to_string(), encoded)])),
375            &HashMap::new(),
376            &Balances::default(),
377        );
378
379        assert!(matches!(result, Err(TransitionError::SimulationError(_))), "got {result:?}");
380    }
381
382    #[test]
383    fn test_delta_transition_rejects_malformed_attribute() {
384        let mut curve = state(vec![u("1"), u("2"), u("3")]);
385
386        let result = curve.delta_transition(
387            delta(HashMap::from([(
388                vm::POOL_STATE_ADJUSTED.to_string(),
389                Bytes::from(b"not json".to_vec()),
390            )])),
391            &HashMap::new(),
392            &Balances::default(),
393        );
394
395        // Falling back to the indexed VM state would silently price a pending block against
396        // confirmed state, so a malformed attribute must fail instead.
397        assert!(matches!(result, Err(TransitionError::SimulationError(_))), "got {result:?}");
398    }
399}