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::{adapter::CurveVariant, math::Pool, vm},
22        u256_num::{biguint_to_u256, u256_to_biguint, u256_to_f64},
23    },
24};
25
26/// Curve fee denominator (`10^10`); both StableSwap `fee` and CryptoSwap `mid_fee` use it.
27const FEE_DENOMINATOR: f64 = 1e10;
28/// Representative gas cost of a StableSwap exchange.
29const STABLESWAP_GAS: u64 = 150_000;
30/// Representative gas cost of a CryptoSwap exchange (heavier math + price oracle update).
31const CRYPTOSWAP_GAS: u64 = 350_000;
32
33/// A single Curve pool quoted via `curve_math`.
34///
35/// `tokens` and `decimals` are ordered to match the pool's coin indices, so a token address maps
36/// directly to a `curve_math` coin index. State (`pool`) is rebuilt from the VM on every
37/// `delta_transition`.
38///
39/// Multi-hop limitation: the state returned by [`ProtocolSim::get_amount_out`] updates coin
40/// balances only, holding `D` and `price_scale` fixed. This is exact for StableSwap (which
41/// recomputes `D` from balances on every quote), but a route that re-quotes the *same* CryptoSwap
42/// pool sees an approximation on the second hop, because CryptoSwap caches `D` and would update it
43/// (via `tweak_price`) after an on-chain exchange.
44#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
45pub struct CurveState {
46    /// Pool contract address (the Tycho component id).
47    pool_address: Bytes,
48    /// Coin addresses in pool index order.
49    tokens: Vec<Bytes>,
50    /// Coin decimals in pool index order.
51    decimals: Vec<u8>,
52    /// Resolved math variant.
53    variant: CurveVariant,
54    /// Constructed math pool used for quoting.
55    pool: Pool,
56}
57
58impl CurveState {
59    /// Construct a `CurveState` from a resolved variant and a built `curve_math::Pool`.
60    pub(super) fn new(
61        pool_address: Bytes,
62        tokens: Vec<Bytes>,
63        decimals: Vec<u8>,
64        variant: CurveVariant,
65        pool: Pool,
66    ) -> Self {
67        Self { pool_address, tokens, decimals, variant, pool }
68    }
69
70    fn coin_index(&self, token: &Bytes) -> Result<usize, SimulationError> {
71        self.tokens
72            .iter()
73            .position(|t| t == token)
74            .ok_or_else(|| {
75                SimulationError::InvalidInput(
76                    format!("token {token} is not a coin of curve pool {}", self.pool_address),
77                    None,
78                )
79            })
80    }
81
82    fn is_crypto(&self) -> bool {
83        matches!(
84            self.variant,
85            CurveVariant::TwoCryptoV1 |
86                CurveVariant::TwoCryptoNG |
87                CurveVariant::TwoCryptoStable |
88                CurveVariant::TriCryptoV1 |
89                CurveVariant::TriCryptoNG
90        )
91    }
92
93    fn gas_estimate(&self) -> u64 {
94        if self.is_crypto() {
95            CRYPTOSWAP_GAS
96        } else {
97            STABLESWAP_GAS
98        }
99    }
100}
101
102#[typetag::serde]
103impl ProtocolSim for CurveState {
104    fn fee(&self) -> f64 {
105        let fee = self.pool.fee().or_else(|| {
106            self.pool
107                .crypto_fees()
108                .map(|(mid, _, _)| mid)
109        });
110        fee.and_then(|f| u256_to_f64(f).ok())
111            .map(|f| f / FEE_DENOMINATOR)
112            .unwrap_or(0.0)
113    }
114
115    fn spot_price(&self, base: &Token, quote: &Token) -> Result<f64, SimulationError> {
116        let i = self.coin_index(&base.address)?;
117        let j = self.coin_index(&quote.address)?;
118        let (numerator, denominator) = self
119            .pool
120            .spot_price(i, j)
121            .ok_or_else(|| {
122                SimulationError::RecoverableError(format!(
123                    "curve spot price unavailable for {}",
124                    self.pool_address
125                ))
126            })?;
127        // curve_math returns dy/dx (quote per base) in native token units and fee-inclusive;
128        // rescale to human units of quote per 1 base.
129        let ratio = u256_to_f64(numerator)? / u256_to_f64(denominator)?;
130        let decimal_adjustment = 10f64.powi(base.decimals as i32 - quote.decimals as i32);
131        Ok(ratio * decimal_adjustment)
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 i = self.coin_index(&token_in.address)?;
141        let j = self.coin_index(&token_out.address)?;
142        let dx = biguint_to_u256(&amount_in);
143
144        let dy = self
145            .pool
146            .get_amount_out(i, j, dx)
147            .ok_or_else(|| {
148                SimulationError::RecoverableError(format!(
149                    "curve get_amount_out failed for {}",
150                    self.pool_address
151                ))
152            })?;
153
154        let mut new_pool = self.pool.clone();
155        let (balance_in, balance_out) = {
156            let balances = new_pool.balances();
157            (balances[i], balances[j])
158        };
159        // Apply the swap to coin balances for multi-hop routing. Stored D / price_scale are kept
160        // as-is (the invariant is preserved across a swap; price_scale only moves on rebalancing),
161        // which is an approximation if the same crypto pool is hit twice within one route.
162        new_pool
163            .set_balance(i, balance_in + dx)
164            .map_err(|e| SimulationError::FatalError(format!("curve set_balance failed: {e}")))?;
165        new_pool
166            .set_balance(j, balance_out.saturating_sub(dy))
167            .map_err(|e| SimulationError::FatalError(format!("curve set_balance failed: {e}")))?;
168
169        let new_state = Self { pool: new_pool, ..self.clone() };
170        Ok(GetAmountOutResult::new(
171            u256_to_biguint(dy),
172            self.gas_estimate()
173                .to_biguint()
174                .expect("u64 fits in BigUint"),
175            Box::new(new_state),
176        ))
177    }
178
179    fn get_limits(
180        &self,
181        sell_token: Bytes,
182        buy_token: Bytes,
183    ) -> Result<(BigUint, BigUint), SimulationError> {
184        let i = self.coin_index(&sell_token)?;
185        let j = self.coin_index(&buy_token)?;
186        let (balance_in, balance_out) = {
187            let balances = self.pool.balances();
188            (balances[i], balances[j])
189        };
190        if balance_in.is_zero() || balance_out.is_zero() {
191            return Ok((BigUint::ZERO, BigUint::ZERO));
192        }
193        // Soft limit: cap the input at the pool's own balance of the sell token. Beyond this the
194        // solver math becomes unreliable and output approaches the available reserve.
195        let max_out_reserve = balance_out.saturating_sub(U256::from(1));
196        let max_out = self
197            .pool
198            .get_amount_out(i, j, balance_in)
199            .ok_or_else(|| {
200                SimulationError::RecoverableError(format!(
201                    "curve get_limits: solver failed at max input for {}",
202                    self.pool_address
203                ))
204            })?
205            .min(max_out_reserve);
206        Ok((u256_to_biguint(balance_in), u256_to_biguint(max_out)))
207    }
208
209    fn delta_transition(
210        &mut self,
211        _delta: ProtocolStateDelta,
212        _tokens: &std::collections::HashMap<Bytes, Token>,
213        _balances: &Balances,
214    ) -> Result<(), TransitionError> {
215        let engine = create_engine(SHARED_TYCHO_DB.clone(), false).expect("Infallible");
216        let pool_address = AlloyAddress::from_slice(self.pool_address.as_ref());
217        self.pool = vm::decode_from_vm(&engine, &pool_address, self.variant, &self.decimals)
218            .map_err(TransitionError::SimulationError)?;
219        Ok(())
220    }
221
222    fn clone_box(&self) -> Box<dyn ProtocolSim> {
223        Box::new(self.clone())
224    }
225
226    fn as_any(&self) -> &dyn Any {
227        self
228    }
229
230    fn as_any_mut(&mut self) -> &mut dyn Any {
231        self
232    }
233
234    fn eq(&self, other: &dyn ProtocolSim) -> bool {
235        other
236            .as_any()
237            .downcast_ref::<Self>()
238            .is_some_and(|other| self == other)
239    }
240}