Skip to main content

tycho_simulation/evm/protocol/curve/
vm.rs

1//! Reads Curve pool state from the locally indexed VM storage via view-getter calls and
2//! assembles a vendored `math::Pool` through `adapter::build_pool`.
3//!
4//! The getter set per variant mirrors the reference RPC consumer in
5//! `curve-adapter/tests/fuzz_registry.rs`, executed against the `SimulationEngine` instead of a
6//! live RPC node so values are read from the same indexed storage Tycho already tracks.
7use std::fmt::Debug;
8
9use alloy::{
10    core::sol,
11    primitives::{Address as AlloyAddress, U256},
12    sol_types::SolCall,
13};
14use revm::{state::AccountInfo, DatabaseRef};
15use tycho_common::{simulation::errors::SimulationError, Bytes};
16
17use crate::evm::{
18    engine_db::engine_db_interface::EngineDatabaseInterface,
19    protocol::{
20        curve::{
21            adapter::{build_pool, detect_eth_variant, CurveVariant, ProbingResults, RawPoolState},
22            math::Pool,
23        },
24        vm::utils::get_code_for_contract,
25    },
26    simulation::{PendingOverrides, SimulationEngine},
27};
28
29sol! {
30    #[allow(missing_docs)]
31    interface ICurve {
32        function balances(uint256 i) external view returns (uint256);
33        function A() external view returns (uint256);
34        function fee() external view returns (uint256);
35        function initial_A() external view returns (uint256);
36        function future_A() external view returns (uint256);
37        function offpeg_fee_multiplier() external view returns (uint256);
38        function gamma() external view returns (uint256);
39        function D() external view returns (uint256);
40        function mid_fee() external view returns (uint256);
41        function out_fee() external view returns (uint256);
42        function fee_gamma() external view returns (uint256);
43        function price_scale() external view returns (uint256);
44        function MATH() external view returns (address);
45        function base_pool() external view returns (address);
46        function version() external view returns (string);
47    }
48
49    #[allow(missing_docs)]
50    interface ICurveOld {
51        function balances(int128 i) external view returns (uint256);
52    }
53
54    #[allow(missing_docs)]
55    interface ICurveTri {
56        function price_scale(uint256 i) external view returns (uint256);
57        function precisions() external view returns (uint256[3]);
58    }
59
60    #[allow(missing_docs)]
61    interface ICurveTwo {
62        function precisions() external view returns (uint256[2]);
63    }
64
65    #[allow(missing_docs)]
66    interface IBasePool {
67        function get_virtual_price() external view returns (uint256);
68    }
69}
70
71/// `A_PRECISION` for StableSwap V2 / NG / Meta / ALend pools.
72const A_PRECISION: u64 = 100;
73/// `stored_rates()` selector — read via raw call because the return encoding (fixed vs dynamic
74/// array) varies across NG pool versions.
75const STORED_RATES_SELECTOR: [u8; 4] = [0xfd, 0x06, 0x84, 0xb1];
76
77/// State-delta attribute carrying a pool's [`RawPoolState`] for a pending block.
78///
79/// A pending block's state is not in the indexed VM storage, so an indexer that has already read
80/// the pool under that block's overrides passes the readings through this attribute instead.
81pub const POOL_STATE_ADJUSTED: &str = "pool_state_adjusted";
82
83/// Encode `state` for the [`POOL_STATE_ADJUSTED`] attribute.
84pub fn encode_raw_state(state: &RawPoolState) -> Result<Bytes, SimulationError> {
85    serde_json::to_vec(state)
86        .map(Bytes::from)
87        .map_err(|e| SimulationError::FatalError(format!("curve state encode failed: {e}")))
88}
89
90/// Decode the bytes of a [`POOL_STATE_ADJUSTED`] attribute.
91pub fn decode_raw_state(bytes: &[u8]) -> Result<RawPoolState, SimulationError> {
92    serde_json::from_slice(bytes)
93        .map_err(|e| SimulationError::FatalError(format!("curve state decode failed: {e}")))
94}
95
96/// Read Curve pool state for `variant` from the engine and build the matching [`Pool`].
97///
98/// `token_decimals` must be ordered to match the pool's coin indices. Returns a fully
99/// constructed [`Pool`] ready for quoting, or a [`SimulationError`] if a required getter
100/// reverts or `build_pool` rejects the assembled state.
101pub fn decode_from_vm<D: EngineDatabaseInterface + Clone + Debug>(
102    engine: &SimulationEngine<D>,
103    pool: &AlloyAddress,
104    variant: CurveVariant,
105    token_decimals: &[u8],
106) -> Result<Pool, SimulationError>
107where
108    <D as DatabaseRef>::Error: Debug,
109    <D as EngineDatabaseInterface>::Error: Debug,
110{
111    let state =
112        read_raw_pool_state(engine, pool, variant, token_decimals, &PendingOverrides::default())?;
113    build_pool(&state)
114        .map_err(|e| SimulationError::FatalError(format!("curve build_pool failed: {e}")))
115}
116
117/// Read the view getters `variant` needs from `engine`, for a pool with `n_coins` coins.
118///
119/// `overrides` allows for overriding the state the getters run against; empty [`PendingOverrides`]
120/// means normal reading from engine's confirmed state. Pass a pending block's storage, native
121/// balances and block environment to read the state that block would leave behind (block timestamp
122/// matters!).
123///
124/// Returns a [`SimulationError`] if a getter required by the variant reverts. Getters that only
125/// some deployments of a variant expose are recorded as `None` instead.
126pub fn read_raw_pool_state<D: EngineDatabaseInterface + Clone + Debug>(
127    engine: &SimulationEngine<D>,
128    pool: &AlloyAddress,
129    variant: CurveVariant,
130    decimals: &[u8],
131    overrides: &PendingOverrides,
132) -> Result<RawPoolState, SimulationError>
133where
134    <D as DatabaseRef>::Error: Debug,
135    <D as EngineDatabaseInterface>::Error: Debug,
136{
137    PoolReader::new(engine, overrides).read_pool(pool, variant, decimals)
138}
139
140/// Reads one pool's view getters from `engine`, under a fixed set of state overrides.
141///
142/// The engine and the overrides are never useful apart: every getter needs both, and a getter
143/// that lost the overrides would silently read confirmed state.
144struct PoolReader<'a, D: EngineDatabaseInterface + Clone + Debug>
145where
146    <D as DatabaseRef>::Error: Debug,
147    <D as EngineDatabaseInterface>::Error: Debug,
148{
149    engine: &'a SimulationEngine<D>,
150    overrides: &'a PendingOverrides,
151}
152
153impl<'a, D: EngineDatabaseInterface + Clone + Debug> PoolReader<'a, D>
154where
155    <D as DatabaseRef>::Error: Debug,
156    <D as EngineDatabaseInterface>::Error: Debug,
157{
158    fn new(engine: &'a SimulationEngine<D>, overrides: &'a PendingOverrides) -> Self {
159        Self { engine, overrides }
160    }
161
162    fn read_pool(
163        &self,
164        pool: &AlloyAddress,
165        variant: CurveVariant,
166        decimals: &[u8],
167    ) -> Result<RawPoolState, SimulationError> {
168        let n_coins = decimals.len();
169        match variant {
170            CurveVariant::StableSwapV0 => Ok(RawPoolState {
171                variant,
172                balances: self.read_balances_int128(pool, n_coins)?,
173                amp: self.call(pool, ICurve::ACall {})?,
174                fee: Some(self.call(pool, ICurve::feeCall {})?),
175                token_decimals: decimals.into(),
176                ..Default::default()
177            }),
178            CurveVariant::StableSwapV1 => Ok(RawPoolState {
179                variant,
180                balances: self.read_balances(pool, n_coins)?,
181                amp: self.call(pool, ICurve::ACall {})?,
182                fee: Some(self.call(pool, ICurve::feeCall {})?),
183                token_decimals: decimals.into(),
184                ..Default::default()
185            }),
186            CurveVariant::StableSwapV2 | CurveVariant::StableSwapSTETH => Ok(RawPoolState {
187                variant,
188                balances: self.read_balances(pool, n_coins)?,
189                amp: self.read_ramped_amp(pool)?,
190                fee: Some(self.call(pool, ICurve::feeCall {})?),
191                token_decimals: decimals.into(),
192                ..Default::default()
193            }),
194            CurveVariant::StableSwapALend => Ok(RawPoolState {
195                variant,
196                balances: self.read_balances(pool, n_coins)?,
197                amp: self.read_ramped_amp(pool)?,
198                fee: Some(self.call(pool, ICurve::feeCall {})?),
199                offpeg_fee_multiplier: Some(self.call(pool, ICurve::offpeg_fee_multiplierCall {})?),
200                token_decimals: decimals.into(),
201                ..Default::default()
202            }),
203            CurveVariant::StableSwapNG => Ok(RawPoolState {
204                variant,
205                balances: self.read_balances(pool, n_coins)?,
206                amp: self.read_ramped_amp(pool)?,
207                fee: Some(self.call(pool, ICurve::feeCall {})?),
208                // v5+ crvUSD factory pools lack offpeg_fee_multiplier; build_pool defaults it.
209                offpeg_fee_multiplier: self.call_opt(pool, ICurve::offpeg_fee_multiplierCall {}),
210                dynamic_rates: self.read_stored_rates(pool, n_coins),
211                token_decimals: decimals.into(),
212                ..Default::default()
213            }),
214            CurveVariant::StableSwapMeta => {
215                let mut dynamic_rates = vec![None; n_coins];
216                if let Some(last) = dynamic_rates.last_mut() {
217                    *last = Some(self.read_base_virtual_price(pool)?);
218                }
219                Ok(RawPoolState {
220                    variant,
221                    balances: self.read_balances(pool, n_coins)?,
222                    amp: self.read_ramped_amp(pool)?,
223                    fee: Some(self.call(pool, ICurve::feeCall {})?),
224                    dynamic_rates: Some(dynamic_rates),
225                    token_decimals: decimals.into(),
226                    ..Default::default()
227                })
228            }
229            CurveVariant::TwoCryptoV1 |
230            CurveVariant::TwoCryptoNG |
231            CurveVariant::TwoCryptoStable => self.read_twocrypto(pool, variant, decimals),
232            CurveVariant::TriCryptoV1 | CurveVariant::TriCryptoNG => {
233                self.read_tricrypto(pool, variant, decimals)
234            }
235        }
236    }
237
238    fn read_twocrypto(
239        &self,
240        pool: &AlloyAddress,
241        variant: CurveVariant,
242        decimals: &[u8],
243    ) -> Result<RawPoolState, SimulationError> {
244        let balances = self.read_balances(pool, 2)?;
245        let price_scale = self.call(pool, ICurve::price_scaleCall {})?;
246        let precisions = self
247            .call_opt(pool, ICurveTwo::precisionsCall {})
248            .map(|p| p.to_vec());
249        let gamma = if variant == CurveVariant::TwoCryptoStable {
250            None
251        } else {
252            Some(self.call(pool, ICurve::gammaCall {})?)
253        };
254        let eth_variant = if variant == CurveVariant::TwoCryptoV1 {
255            Some(detect_eth_variant(*pool))
256        } else {
257            None
258        };
259        Ok(RawPoolState {
260            variant,
261            balances,
262            amp: self.call(pool, ICurve::ACall {})?,
263            mid_fee: Some(self.call(pool, ICurve::mid_feeCall {})?),
264            out_fee: Some(self.call(pool, ICurve::out_feeCall {})?),
265            fee_gamma: Some(self.call(pool, ICurve::fee_gammaCall {})?),
266            d: Some(self.call(pool, ICurve::DCall {})?),
267            gamma,
268            price_scale: Some(vec![price_scale]),
269            precisions,
270            eth_variant,
271            token_decimals: decimals.into(),
272            ..Default::default()
273        })
274    }
275
276    fn read_tricrypto(
277        &self,
278        pool: &AlloyAddress,
279        variant: CurveVariant,
280        decimals: &[u8],
281    ) -> Result<RawPoolState, SimulationError> {
282        let balances = self.read_balances(pool, 3)?;
283        let ps0 = self.call(pool, ICurveTri::price_scaleCall { i: U256::from(0) })?;
284        let ps1 = self.call(pool, ICurveTri::price_scaleCall { i: U256::from(1) })?;
285        let precisions = self
286            .call_opt(pool, ICurveTri::precisionsCall {})
287            .map(|p| p.to_vec());
288        Ok(RawPoolState {
289            variant,
290            balances,
291            amp: self.call(pool, ICurve::ACall {})?,
292            mid_fee: Some(self.call(pool, ICurve::mid_feeCall {})?),
293            out_fee: Some(self.call(pool, ICurve::out_feeCall {})?),
294            fee_gamma: Some(self.call(pool, ICurve::fee_gammaCall {})?),
295            d: Some(self.call(pool, ICurve::DCall {})?),
296            gamma: Some(self.call(pool, ICurve::gammaCall {})?),
297            price_scale: Some(vec![ps0, ps1]),
298            precisions,
299            token_decimals: decimals.into(),
300            ..Default::default()
301        })
302    }
303
304    fn read_ramped_amp(&self, pool: &AlloyAddress) -> Result<U256, SimulationError> {
305        let initial_a = self.call_opt(pool, ICurve::initial_ACall {});
306        let future_a = self.call_opt(pool, ICurve::future_ACall {});
307        match (initial_a, future_a) {
308            (Some(ia), Some(fa)) if ia == fa => Ok(ia),
309            // While ramping we read `A()`, which the pool interpolates to the read block. The
310            // adapter docs suggest instead interpolating `initial_A`/`future_A` and calling
311            // `Pool::set_amp` per quote — but the pool has no access to the current block
312            // timestamp outside `delta_transition`, so per-quote interpolation isn't feasible
313            // yet. `A()` is refreshed on every `delta_transition` (i.e. on every swap), and ramps
314            // span days, so intra-interval drift is negligible. Revisit if the pool gains access
315            // to the block timestamp.
316            _ => Ok(self.call(pool, ICurve::ACall {})? * U256::from(A_PRECISION)),
317        }
318    }
319
320    fn read_balances(
321        &self,
322        pool: &AlloyAddress,
323        n_coins: usize,
324    ) -> Result<Vec<U256>, SimulationError> {
325        let mut balances = Vec::with_capacity(n_coins);
326        for i in 0..n_coins {
327            balances.push(self.call(pool, ICurve::balancesCall { i: U256::from(i) })?);
328        }
329        Ok(balances)
330    }
331
332    fn read_balances_int128(
333        &self,
334        pool: &AlloyAddress,
335        n_coins: usize,
336    ) -> Result<Vec<U256>, SimulationError> {
337        let mut balances = Vec::with_capacity(n_coins);
338        for i in 0..n_coins {
339            balances.push(self.call(pool, ICurveOld::balancesCall { i: i as i128 })?);
340        }
341        Ok(balances)
342    }
343
344    /// Resolve a StableSwapMeta pool's base LP token rate via the base pool's
345    /// `get_virtual_price()`.
346    fn read_base_virtual_price(&self, pool: &AlloyAddress) -> Result<U256, SimulationError> {
347        let base_pool = self.call(pool, ICurve::base_poolCall {})?;
348        self.call(&base_pool, IBasePool::get_virtual_priceCall {})
349    }
350
351    /// Read `stored_rates()` as `dynamic_rates`, handling both fixed-size and dynamic ABI
352    /// encodings.
353    fn read_stored_rates(&self, pool: &AlloyAddress, n_coins: usize) -> Option<Vec<Option<U256>>> {
354        let res = self
355            .engine
356            .simulate(
357                &self
358                    .overrides
359                    .view_call(*pool, STORED_RATES_SELECTOR.to_vec()),
360            )
361            .ok()?;
362        let out = res.result.as_ref();
363        if out.len() < n_coins * 32 {
364            return None;
365        }
366        // If the first word is a small ABI offset (dynamic encoding) rather than a rate
367        // (rates are >= 10^18), skip the offset + length words.
368        let first_word = U256::from_be_slice(&out[..32]);
369        let data_offset = if first_word <= U256::from(256u64) && out.len() >= (n_coins + 2) * 32 {
370            64
371        } else {
372            0
373        };
374        let rates = (0..n_coins)
375            .map(|i| {
376                let start = data_offset + i * 32;
377                Some(U256::from_be_slice(&out[start..start + 32]))
378            })
379            .collect();
380        Some(rates)
381    }
382
383    fn call<C, R>(&self, to: &AlloyAddress, sol_call: C) -> Result<R, SimulationError>
384    where
385        C: SolCall<Return = R>,
386    {
387        let res = self
388            .engine
389            .simulate(
390                &self
391                    .overrides
392                    .view_call(*to, sol_call.abi_encode()),
393            )
394            .map_err(|e| {
395                SimulationError::RecoverableError(format!("curve getter call failed: {e}"))
396            })?;
397        C::abi_decode_returns(res.result.as_ref())
398            .map_err(|e| SimulationError::FatalError(format!("curve getter decode failed: {e}")))
399    }
400
401    fn call_opt<C, R>(&self, to: &AlloyAddress, sol_call: C) -> Option<R>
402    where
403        C: SolCall<Return = R>,
404    {
405        self.call(to, sol_call).ok()
406    }
407}
408
409/// Read the pool's `MATH()` contract address, if it exposes one (TwoCrypto-NG era pools).
410pub fn read_math_address<D: EngineDatabaseInterface + Clone + Debug>(
411    engine: &SimulationEngine<D>,
412    pool: &AlloyAddress,
413) -> Option<AlloyAddress>
414where
415    <D as DatabaseRef>::Error: Debug,
416    <D as EngineDatabaseInterface>::Error: Debug,
417{
418    PoolReader::new(engine, &PendingOverrides::default()).call_opt(pool, ICurve::MATHCall {})
419}
420
421/// Ensure the code of the pool's actual `MATH()` contract is loaded into the engine.
422///
423/// TwoCrypto-NG pools delegate math to a `MATH()` contract, and the substreams may index a
424/// stale/hardcoded math address (a different version than the pool actually uses), so the real one
425/// read from `MATH()` is loaded here by fetching its code via RPC. A no-op for pools without a
426/// `MATH()` getter. Returns an error when a pool exposes `MATH()` but its code cannot be fetched or
427/// loaded — the caller must fail decoding rather than build a pool with unresolved math.
428pub async fn load_math_contract<D: EngineDatabaseInterface + Clone + Debug>(
429    engine: &SimulationEngine<D>,
430    pool: &AlloyAddress,
431) -> Result<(), SimulationError>
432where
433    <D as DatabaseRef>::Error: Debug,
434    <D as EngineDatabaseInterface>::Error: Debug,
435{
436    let Some(math) = read_math_address(engine, pool) else {
437        return Ok(());
438    };
439    let code = get_code_for_contract(&math.to_string(), None)
440        .await
441        .map_err(|e| {
442            SimulationError::RecoverableError(format!(
443                "curve: failed to fetch MATH() code for {math}: {e}"
444            ))
445        })?;
446    engine
447        .state
448        .init_account(
449            math,
450            AccountInfo {
451                balance: U256::ZERO,
452                nonce: 0,
453                code_hash: code.hash_slow(),
454                code: Some(code),
455            },
456            None,
457            false,
458        )
459        .map_err(|e| {
460            SimulationError::FatalError(format!(
461                "curve: failed to load MATH() code for {math}: {e:?}"
462            ))
463        })?;
464    Ok(())
465}
466
467/// Read the `version()` string from a contract (e.g. a TwoCrypto MATH implementation), if present.
468pub fn read_version<D: EngineDatabaseInterface + Clone + Debug>(
469    engine: &SimulationEngine<D>,
470    address: &AlloyAddress,
471) -> Option<String>
472where
473    <D as DatabaseRef>::Error: Debug,
474    <D as EngineDatabaseInterface>::Error: Debug,
475{
476    PoolReader::new(engine, &PendingOverrides::default()).call_opt(address, ICurve::versionCall {})
477}
478
479/// Probe the pool's on-chain interface to populate [`ProbingResults`] for variant detection.
480///
481/// Each field records whether the corresponding getter succeeded; only used as a fallback when
482/// the variant cannot be resolved from static attributes.
483pub fn probe<D: EngineDatabaseInterface + Clone + Debug>(
484    engine: &SimulationEngine<D>,
485    pool: &AlloyAddress,
486    n_coins: usize,
487) -> ProbingResults
488where
489    <D as DatabaseRef>::Error: Debug,
490    <D as EngineDatabaseInterface>::Error: Debug,
491{
492    // Which interface a pool exposes is fixed at deployment, so probing always reads
493    // confirmed state — a pending block cannot change the answer.
494    let overrides = PendingOverrides::default();
495    let reader = PoolReader::new(engine, &overrides);
496    let math: Option<AlloyAddress> = reader.call_opt(pool, ICurve::MATHCall {});
497    ProbingResults {
498        has_gamma: reader
499            .call_opt(pool, ICurve::gammaCall {})
500            .is_some(),
501        n_coins,
502        has_math: math.is_some(),
503        // Read `MATH().version()` so `detect_variant` can split TwoCrypto NG (v2.x) from
504        // TwoCryptoStable (v0.x) on the probe fallback path, matching `resolve_twocrypto`.
505        math_version: math.and_then(|math| reader.call_opt(&math, ICurve::versionCall {})),
506        has_offpeg_fee_multiplier: reader
507            .call_opt(pool, ICurve::offpeg_fee_multiplierCall {})
508            .is_some(),
509        has_stored_rates: reader
510            .read_stored_rates(pool, n_coins)
511            .is_some(),
512        has_version: reader
513            .call_opt(pool, ICurve::versionCall {})
514            .is_some(),
515        has_base_pool: reader
516            .call_opt(pool, ICurve::base_poolCall {})
517            .is_some(),
518        has_int128_balances: reader
519            .call_opt(pool, ICurveOld::balancesCall { i: 0 })
520            .is_some(),
521        pool_address: *pool,
522    }
523}
524
525#[cfg(test)]
526mod test {
527    use std::str::FromStr;
528
529    use alloy::sol;
530    use tycho_client::feed::BlockHeader;
531
532    use super::*;
533    use crate::evm::{
534        engine_db::{
535            simulation_db::SimulationDB,
536            utils::{get_client, get_runtime},
537        },
538        simulation::SimulationEngine,
539    };
540
541    sol! {
542        function get_dy_stable(int128 i, int128 j, uint256 dx) external view returns (uint256);
543        function coins(uint256 i) external view returns (address);
544        function decimals() external view returns (uint8);
545    }
546
547    const ETH_PLACEHOLDER: AlloyAddress =
548        alloy::primitives::address!("eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee");
549
550    /// Read coin decimals on-chain so the differential check is robust to any pool's token set.
551    fn read_decimals<D: EngineDatabaseInterface + Clone + Debug>(
552        engine: &SimulationEngine<D>,
553        pool: &AlloyAddress,
554        n: usize,
555    ) -> Vec<u8>
556    where
557        <D as DatabaseRef>::Error: Debug,
558        <D as EngineDatabaseInterface>::Error: Debug,
559    {
560        let overrides = PendingOverrides::default();
561        let reader = PoolReader::new(engine, &overrides);
562        (0..n)
563            .map(|i| {
564                let coin: AlloyAddress = reader
565                    .call(pool, coinsCall { i: U256::from(i) })
566                    .unwrap();
567                if coin == ETH_PLACEHOLDER {
568                    18
569                } else {
570                    reader
571                        .call(&coin, decimalsCall {})
572                        .unwrap()
573                }
574            })
575            .collect()
576    }
577
578    /// Decode a pool from the VM and assert our `get_amount_out` matches on-chain `get_dy`
579    /// (StableSwap `int128` signature) at the same block.
580    fn assert_stable_matches_onchain(
581        pool: &str,
582        variant: CurveVariant,
583        n_coins: usize,
584        swap: (usize, usize, U256),
585        block: (u64, u64),
586    ) {
587        let (i, j, dx) = swap;
588        let (block_number, timestamp) = block;
589        let header = BlockHeader { number: block_number, timestamp, ..Default::default() };
590        let mut db = SimulationDB::new(get_client(None).unwrap(), get_runtime().unwrap(), None);
591        db.set_block(Some(header));
592        let engine = SimulationEngine::new(db, false);
593        let pool = AlloyAddress::from_str(pool).unwrap();
594        let decimals = read_decimals(&engine, &pool, n_coins);
595
596        let decoded = decode_from_vm(&engine, &pool, variant, &decimals).expect("decode failed");
597        let ours = decoded
598            .get_amount_out(i, j, dx)
599            .expect("get_amount_out returned None");
600
601        let onchain: U256 = PoolReader::new(&engine, &PendingOverrides::default())
602            .call(&pool, get_dy_stableCall { i: i as i128, j: j as i128, dx })
603            .expect("on-chain get_dy failed");
604
605        assert_eq!(ours, onchain, "curve quote diverged from on-chain get_dy");
606    }
607
608    /// Readings must survive the attribute encoding unchanged; a lossy field would silently
609    /// misprice the pool that decodes them.
610    #[test]
611    fn raw_pool_state_roundtrips_through_the_attribute_encoding() {
612        let u = |s: &str| s.parse::<U256>().unwrap();
613        let state = RawPoolState {
614            variant: CurveVariant::StableSwapV2,
615            balances: vec![u("2466241139205"), u("4200057336")],
616            token_decimals: vec![18, 6],
617            amp: u("1707629"),
618            fee: Some(u("4000000")),
619            mid_fee: Some(u("3000000")),
620            out_fee: Some(u("30000000")),
621            fee_gamma: Some(u("500000000000000")),
622            offpeg_fee_multiplier: Some(u("20000000000")),
623            price_scale: Some(vec![u("59372627314351316239076")]),
624            d: Some(u("7457948167729606869978625")),
625            gamma: Some(u("11809167828997")),
626            dynamic_rates: Some(vec![Some(u("1000000000000000000")), None]),
627            precisions: Some(vec![u("1"), u("1000000000000")]),
628            eth_variant: Some(true),
629        };
630
631        let encoded = encode_raw_state(&state).expect("encode failed");
632
633        assert_eq!(decode_raw_state(&encoded).expect("decode failed"), state);
634    }
635
636    #[test]
637    fn decoding_malformed_readings_fails() {
638        assert!(decode_raw_state(b"not json").is_err());
639    }
640
641    /// Pure check (no RPC): assemble `RawPoolState` from on-chain getter values for the
642    /// TriCryptoNG USDC/WBTC/WETH pool (0x7f86bf…) and verify `build_pool().get_amount_out`
643    /// reproduces on-chain `get_dy`. Isolates field-mapping + curve-math from the VM engine.
644    #[test]
645    fn tricrypto_ng_build_pool_matches_onchain_get_dy() {
646        let u = |s: &str| s.parse::<U256>().unwrap();
647        let state = RawPoolState {
648            variant: CurveVariant::TriCryptoNG,
649            balances: vec![u("2466241139205"), u("4200057336"), u("1595469030050811720465")],
650            token_decimals: vec![6, 8, 18],
651            amp: u("1707629"),
652            mid_fee: Some(u("3000000")),
653            out_fee: Some(u("30000000")),
654            fee_gamma: Some(u("500000000000000")),
655            d: Some(u("7457948167729606869978625")),
656            gamma: Some(u("11809167828997")),
657            price_scale: Some(vec![u("59372627314351316239076"), u("1565715369034455123313")]),
658            ..Default::default()
659        };
660        let pool = build_pool(&state).expect("build_pool failed");
661        let dx = U256::from(1_000_000_000u64); // 1000 USDC
662        assert_eq!(pool.get_amount_out(0, 1, dx), Some(u("1690920")), "USDC->WBTC");
663        assert_eq!(pool.get_amount_out(0, 2, dx), Some(u("641654961086650131")), "USDC->WETH");
664    }
665
666    /// Pure check (no RPC): assemble `RawPoolState` from on-chain getter values for the legacy
667    /// WETH-paired CRV/ETH TwoCryptoV1 pool (0x8301AE4f…) at block 25_401_368 and verify
668    /// `build_pool().get_amount_out` reproduces on-chain `get_dy` wei-for-wei in both directions
669    /// for several dx. This pool is WETH-paired → ETH solver flavour (`eth_variant = true`). The
670    /// getters mirror `read_twocrypto`; `precisions()` reverts on this legacy pool, so precisions
671    /// fall back to `10^(18-decimals) = [1, 1]` (both coins 18-dec).
672    #[test]
673    fn twocrypto_v1_eth_variant_build_pool_matches_onchain_get_dy() {
674        let u = |s: &str| s.parse::<U256>().unwrap();
675        let make = |eth_variant: bool| {
676            build_pool(&RawPoolState {
677                variant: CurveVariant::TwoCryptoV1,
678                balances: vec![u("33389428640940997105"), u("1538654846140514380725767708")],
679                token_decimals: vec![18, 18], // coin0 WETH, coin1 CRV
680                amp: u("400000"),
681                gamma: Some(u("145000000000000")),
682                d: Some(u("3338917956508624293928")),
683                price_scale: Some(vec![u("52805053500476")]),
684                mid_fee: Some(U256::from(26_000_000u64)),
685                out_fee: Some(U256::from(45_000_000u64)),
686                fee_gamma: Some(u("230000000000000")),
687                eth_variant: Some(eth_variant),
688                ..Default::default()
689            })
690            .expect("build_pool")
691        };
692        let pool = make(true);
693        // WETH(0) -> CRV(1)
694        assert_eq!(
695            pool.get_amount_out(0, 1, u("1000000000000000000")),
696            Some(u("44131555012248406155621024")),
697            "1 WETH -> CRV",
698        );
699        assert_eq!(
700            pool.get_amount_out(0, 1, u("500000000000000000")),
701            Some(u("22389351263618692591189738")),
702            "0.5 WETH -> CRV",
703        );
704        // CRV(1) -> WETH(0)
705        assert_eq!(
706            pool.get_amount_out(1, 0, u("1000000000000000000000")),
707            Some(u("21806963109202")),
708            "1000 CRV -> WETH",
709        );
710        assert_eq!(
711            pool.get_amount_out(1, 0, u("10000000000000000000000")),
712            Some(u("218068351371449")),
713            "10000 CRV -> WETH",
714        );
715    }
716
717    /// A `twocrypto_factory` pool with MATH v0.1.1 is TwoCryptoStable (StableSwap math, gamma
718    /// ignored), not TwoCryptoNG. Confirms which variant reproduces on-chain `get_dy`.
719    #[test]
720    fn twocrypto_v011_is_stable_not_ng() {
721        let u = |s: &str| s.parse::<U256>().unwrap();
722        let base = |variant: CurveVariant, gamma: Option<U256>| {
723            build_pool(&RawPoolState {
724                variant,
725                balances: vec![u("250289528581622891700521"), u("179139571297")],
726                token_decimals: vec![18, 6],
727                amp: u("350000"),
728                mid_fee: Some(u("1000000")),
729                out_fee: Some(u("20000000")),
730                fee_gamma: Some(u("63100000000000000")),
731                d: Some(u("500000118136487176847089")),
732                gamma,
733                price_scale: Some(vec![u("1393944381226980604")]),
734                precisions: Some(vec![u("1"), u("1000000000000")]),
735                ..Default::default()
736            })
737            .expect("build_pool")
738        };
739        let dx = u("1000000000000000000"); // 1 coin0
740        let stable = base(CurveVariant::TwoCryptoStable, None).get_amount_out(0, 1, dx);
741        let ng =
742            base(CurveVariant::TwoCryptoNG, Some(u("100000000000000"))).get_amount_out(0, 1, dx);
743        eprintln!("on-chain get_dy=717271  stable={stable:?}  ng={ng:?}");
744        assert_eq!(stable, Some(u("717271")), "TwoCryptoStable matches on-chain get_dy");
745        assert_ne!(ng, Some(u("717271")), "TwoCryptoNG does NOT match (current misclassification)");
746    }
747
748    /// Decimals sensitivity: coin 2 is native ETH (zero address in the component). If the live env
749    /// feeds wrong decimals for it, `precisions[2]` is corrupted, poisoning the invariant and all
750    /// pairs. Reproduces the garbage quote pattern seen in the integration test.
751    #[test]
752    fn tricrypto_ng_wrong_coin2_decimals_breaks_quotes() {
753        let u = |s: &str| s.parse::<U256>().unwrap();
754        let make = |decimals: Vec<u8>| {
755            build_pool(&RawPoolState {
756                variant: CurveVariant::TriCryptoNG,
757                balances: vec![u("2466241139205"), u("4200057336"), u("1595469030050811720465")],
758                token_decimals: decimals,
759                amp: u("1707629"),
760                mid_fee: Some(u("3000000")),
761                out_fee: Some(u("30000000")),
762                fee_gamma: Some(u("500000000000000")),
763                d: Some(u("7457948167729606869978625")),
764                gamma: Some(u("11809167828997")),
765                price_scale: Some(vec![u("59372627314351316239076"), u("1565715369034455123313")]),
766                ..Default::default()
767            })
768            .expect("build_pool")
769        };
770        let dx_usdc = U256::from(1_000_000_000u64); // 1000 USDC
771        let dx_wbtc = U256::from(13_656_795u64); // ~0.137 WBTC
772        for dec2 in [18u8, 0, 6] {
773            let p = make(vec![6, 8, dec2]);
774            eprintln!(
775                "decimals=[6,8,{dec2}]  USDC->WETH(0,2)={:?}  WBTC->USDC(1,0)={:?}",
776                p.get_amount_out(0, 2, dx_usdc),
777                p.get_amount_out(1, 0, dx_wbtc),
778            );
779        }
780        // Correct decimals give a sane ~0.6 ETH out; wrong decimals corrupt the invariant and
781        // blow the quote up by orders of magnitude (here ~1594 ETH for the same 1000 USDC).
782        let correct_out = make(vec![6, 8, 18])
783            .get_amount_out(0, 2, dx_usdc)
784            .unwrap();
785        let wrong_out = make(vec![6, 8, 0])
786            .get_amount_out(0, 2, dx_usdc)
787            .unwrap();
788        assert!(correct_out > U256::from(10).pow(U256::from(17)), "correct ~0.6 ETH");
789        assert!(wrong_out > correct_out * U256::from(100u64), "wrong decimals corrupt the quote");
790    }
791
792    #[test]
793    #[ignore = "Requires RPC_URL to be set in environment variables or .env file"]
794    fn differential_3pool_stableswap_v1() {
795        // 3pool DAI(0)->USDC(1), 1 DAI.
796        assert_stable_matches_onchain(
797            "0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7",
798            CurveVariant::StableSwapV1,
799            3,
800            (0, 1, U256::from(1_000_000_000_000_000_000u128)),
801            (21_500_000, 1_736_000_000),
802        );
803    }
804
805    #[test]
806    #[ignore = "Requires RPC_URL to be set in environment variables or .env file"]
807    fn differential_stableswap_ng_plain() {
808        // crypto_swap_ng_factory plain pool, coin 0 -> coin 1.
809        assert_stable_matches_onchain(
810            "0xf55b0f6f2da5ffddb104b58a60f2862745960442",
811            CurveVariant::StableSwapNG,
812            2,
813            (0, 1, U256::from(1_000_000_000_000_000_000u128)),
814            (21_500_000, 1_736_000_000),
815        );
816    }
817}