Skip to main content

tycho_simulation/evm/protocol/curve/adapter/
build.rs

1//! Construct `crate::evm::protocol::curve::math::Pool` from raw on-chain state.
2//!
3//! This module bridges the gap between raw on-chain data (balances, decimals,
4//! storage values) and the typed `Pool` enum that curve-math needs for swap
5//! computation.
6//!
7//! # Usage
8//!
9//! ```rust
10//! use curve_adapter::{CurveVariant, RawPoolState, build_pool};
11//! use alloy_primitives::U256;
12//!
13//! let state = RawPoolState {
14//!     variant: CurveVariant::StableSwapV2,
15//!     balances: vec![
16//!         U256::from(1_000_000_000_000_000_000_000u128),
17//!         U256::from(1_000_000_000_000u128),
18//!     ],
19//!     token_decimals: vec![18, 6],
20//!     amp: U256::from(40_000u64), // A=400 * A_PRECISION=100
21//!     fee: Some(U256::from(4_000_000u64)),
22//!     ..Default::default()
23//! };
24//!
25//! let pool = build_pool(&state).unwrap();
26//! let dy = pool.get_amount_out(0, 1, U256::from(1_000_000_000_000_000_000u128));
27//! ```
28
29use alloy_primitives::U256;
30
31use crate::evm::protocol::curve::{adapter::CurveVariant, math::Pool};
32
33/// Errors from [`build_pool`].
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum BuildError {
36    /// A required field is missing for this variant.
37    MissingField { variant: CurveVariant, field: &'static str },
38    /// The number of balances doesn't match the expected coin count.
39    WrongCoinCount { variant: CurveVariant, expected: usize, actual: usize },
40    /// Token decimals length doesn't match balances length.
41    DecimalsMismatch { balances_len: usize, decimals_len: usize },
42    /// Token decimals exceed the maximum (18 for CryptoSwap, 36 for StableSwap).
43    DecimalsTooLarge { index: usize, decimals: u8, max: u8 },
44    /// Dynamic rates length doesn't match balances length.
45    DynamicRatesMismatch { balances_len: usize, rates_len: usize },
46    /// Price scale has wrong number of elements.
47    PriceScaleWrongLen { expected: usize, actual: usize },
48    /// StableSwapMeta requires `dynamic_rates` with an explicit rate for
49    /// the base pool LP token (last coin). Without it, rates[N-1] defaults
50    /// to `10^(36-decimals)` which is incorrect — it must be
51    /// `base_pool.get_virtual_price()`.
52    MetaMissingVirtualPrice,
53}
54
55impl std::fmt::Display for BuildError {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        match self {
58            Self::MissingField { variant, field } => {
59                write!(f, "{variant}: missing required field `{field}`")
60            }
61            Self::WrongCoinCount { variant, expected, actual } => {
62                write!(f, "{variant}: expected {expected} coins, got {actual} balances")
63            }
64            Self::DecimalsMismatch { balances_len, decimals_len } => write!(
65                f,
66                "token_decimals length ({decimals_len}) != balances length ({balances_len})"
67            ),
68            Self::DecimalsTooLarge { index, decimals, max } => {
69                write!(f, "token_decimals[{index}] = {decimals} exceeds maximum {max}")
70            }
71            Self::DynamicRatesMismatch { balances_len, rates_len } => {
72                write!(f, "dynamic_rates length ({rates_len}) != balances length ({balances_len})")
73            }
74            Self::PriceScaleWrongLen { expected, actual } => {
75                write!(f, "price_scale: expected {expected} elements, got {actual}")
76            }
77            Self::MetaMissingVirtualPrice => write!(
78                f,
79                "StableSwapMeta: dynamic_rates must provide an explicit rate for the last coin \
80                 (base pool LP token virtual_price). Without it, swap calculations are incorrect."
81            ),
82        }
83    }
84}
85
86impl std::error::Error for BuildError {}
87
88/// Raw on-chain pool state, as collected by a transport (RPC, Substreams, etc.).
89///
90/// The consumer fills in the fields relevant to the pool's [`CurveVariant`],
91/// then calls [`build_pool`] to get a `crate::evm::protocol::curve::math::Pool` ready for swap
92/// computation.
93#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
94pub struct RawPoolState {
95    /// Which Curve variant this pool is.
96    pub variant: CurveVariant,
97
98    /// Token balances in native token units (wei). **Updates every block.**
99    ///
100    /// Length determines coin count (2, 3, or 4+).
101    ///
102    /// # Gotchas
103    ///
104    /// **StableSwapNG:** `balances(i)` on-chain returns `balanceOf(pool) - admin_balances[i]`,
105    /// excluding uncollected admin fees. An indexer tracking ERC20 Transfer events sees
106    /// `balanceOf` changes, not `balances()`. The difference is small but causes wei-level
107    /// mismatches. Use the `balances()` getter, not `balanceOf`.
108    ///
109    /// **Rebase tokens (e.g. stETH):** balances change without Transfer events — the token
110    /// rebases in-place. Requires calling `balanceOf(pool)` on the rebase token each block.
111    pub balances: Vec<U256>,
112
113    /// Token decimals for each coin (e.g. `[18, 6]` for ETH/USDC).
114    /// Must have the same length as `balances`.
115    pub token_decimals: Vec<u8>,
116
117    /// Amplification parameter — already interpolated for the target block.
118    /// **Semi-static:** only changes during A ramping (rare admin events, days apart).
119    ///
120    /// This value must match what the on-chain `_A()` internal function returns
121    /// at the target block's timestamp. It includes the variant's precision
122    /// scaling (A_PRECISION=100 for StableSwap V2+, A_MULTIPLIER=10000 for
123    /// CryptoSwap, 1 for V0/V1).
124    ///
125    /// # How to obtain this value
126    ///
127    /// **RPC consumer:**
128    /// Call the pool's `A()` view function at the target block.
129    /// Returns the already-interpolated value in a single RPC call.
130    /// ```text
131    /// let amp = pool_contract.A().block(block_number).call().await?;
132    /// ```
133    /// **Warning:** for all A_PRECISION=100 variants (V2, Meta, NG, ALend),
134    /// `A()` returns `initial_A / A_PRECISION` via integer division, losing
135    /// the remainder:
136    /// ```text
137    /// initial_A = 79258
138    /// A() = 79258 / 100 = 792      (truncated)
139    /// A() * 100 = 79200 ≠ 79258    (lost 58)
140    /// ```
141    /// For exact precision, read `initial_A()` directly when no ramping
142    /// (`initial_A == future_A`), or use [`interpolate_a`] during ramps.
143    ///
144    /// **Substreams / storage-based consumer:**
145    /// Read `initial_A`, `future_A`, `initial_A_time`, `future_A_time` from
146    /// contract storage, then interpolate for the current block's timestamp:
147    /// ```text
148    /// let amp = curve_adapter::interpolate_a(
149    ///     initial_a, future_a,
150    ///     initial_a_time, future_a_time,
151    ///     block_timestamp,
152    /// );
153    /// ```
154    /// For V0/V1 pools that store a single `A` value (no ramping),
155    /// pass that value directly.
156    ///
157    /// **Caution with factory deploy events:** `PlainPoolDeployed` and
158    /// `MetaPoolDeployed` events emit A as the user-provided value (e.g. 400),
159    /// NOT the raw on-chain value (e.g. 40000). Multiply by A_PRECISION
160    /// (100 for V2/NG/Meta/ALend) before passing here.
161    pub amp: U256,
162
163    /// StableSwap fee. **Static** (set at pool creation).
164    /// Required for all StableSwap variants.
165    pub fee: Option<U256>,
166
167    /// CryptoSwap mid fee. **Static.** Required for all CryptoSwap variants.
168    pub mid_fee: Option<U256>,
169
170    /// CryptoSwap out fee. **Static.** Required for all CryptoSwap variants.
171    pub out_fee: Option<U256>,
172
173    /// CryptoSwap fee gamma. **Static.** Required for all CryptoSwap variants.
174    pub fee_gamma: Option<U256>,
175
176    /// Dynamic fee multiplier. **Static.** Required for StableSwapNG and StableSwapALend.
177    pub offpeg_fee_multiplier: Option<U256>,
178
179    /// Price scale(s). **Updates every block.**
180    /// TwoCrypto: 1 element, TriCrypto: 2 elements.
181    /// Required for all CryptoSwap variants.
182    pub price_scale: Option<Vec<U256>>,
183
184    /// Pool invariant D. **Updates every block.** Required for all CryptoSwap variants.
185    pub d: Option<U256>,
186
187    /// Gamma parameter. **Semi-static** (only changes during admin ramp).
188    /// Required for TwoCryptoV1, TwoCryptoNG, TriCryptoV1, TriCryptoNG.
189    /// NOT required for TwoCryptoStable (gamma is ignored).
190    pub gamma: Option<U256>,
191
192    /// Per-token dynamic rates for StableSwap variants. **Depends on token type.**
193    ///
194    /// If `None`, rates are computed from `token_decimals` as `10^(36 - decimals)`.
195    /// This is correct for v6+ crvUSD factory pools (balances variant) which
196    /// use static decimal-based rates and have no `stored_rates()` getter.
197    ///
198    /// If `Some`, must have the same length as `balances`. Each element:
199    /// - `Some(rate)` — use this dynamic rate (oracle, ERC4626, etc.)
200    /// - `None` — compute from decimals as `10^(36 - decimals)`
201    ///
202    /// # When to provide dynamic rates
203    ///
204    /// **StableSwapNG with oracle tokens:** Call `stored_rates()` on the pool
205    /// contract each block. Plain tokens return static rates, but ERC4626/oracle
206    /// tokens return rates that change per-block.
207    ///
208    /// **MetaPools:** `rates[last]` must be `base_pool.get_virtual_price()`.
209    /// To find the base pool address: try `pool.base_pool()` first. MetaPool
210    /// Factory proxy pools lack this getter — use `factory.get_base_pool(pool)`
211    /// instead. Do not assume the base pool is 3pool; BTC meta pools use sBTC.
212    ///
213    /// # Gotchas
214    ///
215    /// **Fee-on-transfer tokens:** actual received amount differs from the
216    /// transfer parameter. Curve pools generally do not support these tokens,
217    /// but if encountered, balances will be incorrect.
218    ///
219    /// **Tokens with 0 decimals:** rate becomes `10^36` — valid but rounding
220    /// impact is outsized. Every wei of such a token is worth `10^36` in
221    /// normalized space.
222    pub dynamic_rates: Option<Vec<Option<U256>>>,
223
224    /// On-chain precisions for CryptoSwap variants. **Immutable.**
225    ///
226    /// If `Some`, used directly instead of computing from `token_decimals`.
227    /// Read from the pool contract's `precisions()` getter.
228    ///
229    /// This is important because some tokens report incorrect `decimals()`
230    /// (e.g. Spectra PT tokens), and the factory computes the correct
231    /// precisions at deployment time.
232    ///
233    /// If `None`, precisions are computed as `10^(18 - decimals)`.
234    pub precisions: Option<Vec<U256>>,
235
236    /// Selects the deployed Vyper flavour for **TwoCryptoV1 only**. **Immutable.**
237    ///
238    /// Legacy 2-coin CryptoSwap V1 pools ship in two flavours whose Newton `get_y` solver differs
239    /// by a single `mul2` integer-division grouping (see
240    /// [`crate::evm::protocol::curve::math::core::twocrypto_v1`]). `Some(true)` selects the ETH
241    /// pool grouping (all factory/proxy and WETH-paired legacy pools); `Some(false)` selects the
242    /// non-ETH legacy direct-deploy grouping.
243    ///
244    /// Required for `TwoCryptoV1` — [`build_pool`] returns [`BuildError::MissingField`] if it is
245    /// `None`. Ignored for every other variant. Obtain it via
246    /// [`detect_eth_variant`](crate::evm::protocol::curve::adapter::detect_eth_variant).
247    pub eth_variant: Option<bool>,
248}
249
250impl Default for RawPoolState {
251    fn default() -> Self {
252        Self {
253            variant: CurveVariant::StableSwapV2,
254            balances: Vec::new(),
255            token_decimals: Vec::new(),
256            amp: U256::ZERO,
257            fee: None,
258            mid_fee: None,
259            out_fee: None,
260            fee_gamma: None,
261            offpeg_fee_multiplier: None,
262            price_scale: None,
263            d: None,
264            gamma: None,
265            dynamic_rates: None,
266            precisions: None,
267            eth_variant: None,
268        }
269    }
270}
271
272/// Interpolate the amplification parameter for a given block timestamp.
273///
274/// This is a 1:1 port of the Vyper `_A()` internal function from Curve
275/// StableSwap and CryptoSwap contracts. The formula is identical across all
276/// Curve variants that support A ramping (V2, Meta, NG, ALend, all CryptoSwap).
277///
278/// V0/V1 pools do not support ramping — they store a single `A` value.
279/// For those, pass `A` directly to [`RawPoolState::amp`] without calling this.
280///
281/// CryptoSwap contracts store `initial_A_gamma` and `future_A_gamma` as packed
282/// values (A and gamma in a single uint256). The caller must unpack the A
283/// component before passing it here.
284///
285/// # Arguments
286///
287/// * `initial_a` — raw start value from storage (includes A_PRECISION/A_MULTIPLIER)
288/// * `future_a` — raw end value from storage (includes A_PRECISION/A_MULTIPLIER)
289/// * `initial_a_time` — ramp start timestamp (seconds)
290/// * `future_a_time` — ramp end timestamp (seconds)
291/// * `block_timestamp` — target block's timestamp (seconds)
292///
293/// # Panics
294///
295/// Panics if `block_timestamp < initial_a_time` (block is before ramp start).
296/// This should never happen with valid on-chain data.
297pub fn interpolate_a(
298    initial_a: U256,
299    future_a: U256,
300    initial_a_time: u64,
301    future_a_time: u64,
302    block_timestamp: u64,
303) -> U256 {
304    if block_timestamp >= future_a_time {
305        return future_a;
306    }
307
308    // block_timestamp < initial_a_time should never happen with valid on-chain data.
309    // The Vyper code does not guard against this either (would underflow and revert).
310    let elapsed = U256::from(block_timestamp - initial_a_time);
311    let duration = U256::from(future_a_time - initial_a_time);
312
313    if future_a > initial_a {
314        initial_a + (future_a - initial_a) * elapsed / duration
315    } else {
316        initial_a - (initial_a - future_a) * elapsed / duration
317    }
318}
319
320/// Compute StableSwap rates from token decimals and optional dynamic rates.
321///
322/// For each token:
323/// - If a dynamic rate is provided, use it directly.
324/// - Otherwise, compute as `10^(36 - decimals)`.
325///
326/// This matches the on-chain `RATE_MULTIPLIER` / `rates` / `PRECISION * RATES`
327/// pattern used across all StableSwap variants.
328fn compute_stableswap_rates(
329    token_decimals: &[u8],
330    dynamic_rates: &Option<Vec<Option<U256>>>,
331) -> Vec<U256> {
332    token_decimals
333        .iter()
334        .enumerate()
335        .map(|(i, &decimals)| {
336            // Check if there's a dynamic rate for this token
337            if let Some(ref rates) = dynamic_rates {
338                if let Some(Some(rate)) = rates.get(i) {
339                    return *rate;
340                }
341            }
342            // Default: 10^(36 - decimals)
343            U256::from(10u64).pow(U256::from(36 - decimals as u32))
344        })
345        .collect()
346}
347
348/// Compute CryptoSwap precisions from token decimals.
349///
350/// For each token: `10^(18 - decimals)`.
351///
352/// This matches the on-chain `precisions` storage variable in all CryptoSwap
353/// contracts.
354fn compute_crypto_precisions(token_decimals: &[u8]) -> Vec<U256> {
355    token_decimals
356        .iter()
357        .map(|&decimals| U256::from(10u64).pow(U256::from(18 - decimals as u32)))
358        .collect()
359}
360
361/// Helper to extract a required Option field or return a BuildError.
362macro_rules! require {
363    ($state:expr, $field:ident) => {
364        $state
365            .$field
366            .ok_or(BuildError::MissingField {
367                variant: $state.variant,
368                field: stringify!($field),
369            })?
370    };
371}
372
373/// Construct a `crate::evm::protocol::curve::math::Pool` from raw on-chain state.
374///
375/// This function:
376/// 1. Validates that all required fields are present for the given variant.
377/// 2. Computes rates/precisions from `token_decimals` (with dynamic rate overrides).
378/// 3. Constructs the appropriate `Pool` enum variant.
379///
380/// The returned `Pool` is ready for `get_amount_out()` / `get_amount_in()` calls.
381pub fn build_pool(state: &RawPoolState) -> Result<Pool, BuildError> {
382    // Common validation
383    if state.balances.len() != state.token_decimals.len() {
384        return Err(BuildError::DecimalsMismatch {
385            balances_len: state.balances.len(),
386            decimals_len: state.token_decimals.len(),
387        });
388    }
389
390    // Validate dynamic_rates length if provided
391    if let Some(ref dr) = state.dynamic_rates {
392        if dr.len() != state.balances.len() {
393            return Err(BuildError::DynamicRatesMismatch {
394                balances_len: state.balances.len(),
395                rates_len: dr.len(),
396            });
397        }
398    }
399
400    match state.variant {
401        CurveVariant::StableSwapV0 |
402        CurveVariant::StableSwapV1 |
403        CurveVariant::StableSwapV2 |
404        CurveVariant::StableSwapSTETH |
405        CurveVariant::StableSwapMeta => build_stableswap_plain(state),
406        CurveVariant::StableSwapNG => build_stableswap_ng(state),
407        CurveVariant::StableSwapALend => build_stableswap_alend(state),
408        CurveVariant::TwoCryptoV1 | CurveVariant::TwoCryptoNG | CurveVariant::TwoCryptoStable => {
409            build_twocrypto(state)
410        }
411        CurveVariant::TriCryptoV1 | CurveVariant::TriCryptoNG => build_tricrypto(state),
412    }
413}
414
415/// Build StableSwapV0, V1, V2, or Meta — all share the same field layout:
416/// `{ balances, rates, amp, fee }`.
417fn build_stableswap_plain(state: &RawPoolState) -> Result<Pool, BuildError> {
418    let fee = require!(state, fee);
419
420    for (i, &d) in state.token_decimals.iter().enumerate() {
421        if d > 36 {
422            return Err(BuildError::DecimalsTooLarge { index: i, decimals: d, max: 36 });
423        }
424    }
425
426    // For Meta pools, the last coin is a base pool LP token whose rate must
427    // be the base pool's virtual_price. Validate that the consumer provided it.
428    if state.variant == CurveVariant::StableSwapMeta {
429        let n = state.balances.len();
430        let has_vp = state
431            .dynamic_rates
432            .as_ref()
433            .and_then(|dr| dr.get(n - 1))
434            .map(|r| r.is_some())
435            .unwrap_or(false);
436        if !has_vp {
437            return Err(BuildError::MetaMissingVirtualPrice);
438        }
439    }
440
441    let rates = compute_stableswap_rates(&state.token_decimals, &state.dynamic_rates);
442    let balances = state.balances.clone();
443    let amp = state.amp;
444
445    Ok(match state.variant {
446        CurveVariant::StableSwapV0 => Pool::StableSwapV0 { balances, rates, amp, fee },
447        CurveVariant::StableSwapV1 => Pool::StableSwapV1 { balances, rates, amp, fee },
448        CurveVariant::StableSwapV2 => Pool::StableSwapV2 { balances, rates, amp, fee },
449        CurveVariant::StableSwapSTETH => Pool::StableSwapSTETH { balances, rates, amp, fee },
450        CurveVariant::StableSwapMeta => Pool::StableSwapMeta { balances, rates, amp, fee },
451        _ => unreachable!(),
452    })
453}
454
455fn build_stableswap_ng(state: &RawPoolState) -> Result<Pool, BuildError> {
456    let fee = require!(state, fee);
457    // crvUSD factory pools (v5 and v6) lack offpeg_fee_multiplier.
458    // FEE_DENOMINATOR makes dynamic_fee() return the static fee unchanged.
459    // Consumers reading from substreams or other indexers should pass
460    // offpeg_fee_multiplier: None for these pools — this default handles it.
461    let offpeg = state
462        .offpeg_fee_multiplier
463        .unwrap_or(U256::from(10_000_000_000u64));
464
465    for (i, &d) in state.token_decimals.iter().enumerate() {
466        if d > 36 {
467            return Err(BuildError::DecimalsTooLarge { index: i, decimals: d, max: 36 });
468        }
469    }
470
471    let rates = compute_stableswap_rates(&state.token_decimals, &state.dynamic_rates);
472
473    Ok(Pool::StableSwapNG {
474        balances: state.balances.clone(),
475        rates,
476        amp: state.amp,
477        fee,
478        offpeg_fee_multiplier: offpeg,
479    })
480}
481
482fn build_stableswap_alend(state: &RawPoolState) -> Result<Pool, BuildError> {
483    let fee = require!(state, fee);
484    let offpeg = require!(state, offpeg_fee_multiplier);
485
486    for (i, &d) in state.token_decimals.iter().enumerate() {
487        if d > 18 {
488            return Err(BuildError::DecimalsTooLarge { index: i, decimals: d, max: 18 });
489        }
490    }
491
492    let precision_mul = compute_crypto_precisions(&state.token_decimals);
493
494    Ok(Pool::StableSwapALend {
495        balances: state.balances.clone(),
496        precision_mul,
497        amp: state.amp,
498        fee,
499        offpeg_fee_multiplier: offpeg,
500    })
501}
502
503fn build_twocrypto(state: &RawPoolState) -> Result<Pool, BuildError> {
504    if state.balances.len() != 2 {
505        return Err(BuildError::WrongCoinCount {
506            variant: state.variant,
507            expected: 2,
508            actual: state.balances.len(),
509        });
510    }
511
512    for (i, &d) in state.token_decimals.iter().enumerate() {
513        if d > 18 {
514            return Err(BuildError::DecimalsTooLarge { index: i, decimals: d, max: 18 });
515        }
516    }
517
518    let mid_fee = require!(state, mid_fee);
519    let out_fee = require!(state, out_fee);
520    let fee_gamma = require!(state, fee_gamma);
521    let d = require!(state, d);
522    let ann = state.amp;
523
524    let price_scale_vec = state
525        .price_scale
526        .as_ref()
527        .ok_or(BuildError::MissingField { variant: state.variant, field: "price_scale" })?;
528    if price_scale_vec.len() != 1 {
529        return Err(BuildError::PriceScaleWrongLen { expected: 1, actual: price_scale_vec.len() });
530    }
531    let price_scale = price_scale_vec[0];
532
533    let default_precs = compute_crypto_precisions(&state.token_decimals);
534    let precisions = state
535        .precisions
536        .as_deref()
537        .unwrap_or(&default_precs);
538    let balances: [U256; 2] = [state.balances[0], state.balances[1]];
539    let prec_arr: [U256; 2] = [precisions[0], precisions[1]];
540
541    match state.variant {
542        CurveVariant::TwoCryptoV1 => {
543            let gamma = require!(state, gamma);
544            // TwoCryptoV1 has two deployed Vyper flavours; the consumer must say which one via
545            // `eth_variant` (typically from `detect_eth_variant`). Other variants ignore it.
546            let eth_variant = require!(state, eth_variant);
547            Ok(Pool::TwoCryptoV1 {
548                balances,
549                precisions: prec_arr,
550                price_scale,
551                d,
552                ann,
553                gamma,
554                mid_fee,
555                out_fee,
556                fee_gamma,
557                eth_variant,
558            })
559        }
560        CurveVariant::TwoCryptoNG => {
561            let gamma = require!(state, gamma);
562            Ok(Pool::TwoCryptoNG {
563                balances,
564                precisions: prec_arr,
565                price_scale,
566                d,
567                ann,
568                gamma,
569                mid_fee,
570                out_fee,
571                fee_gamma,
572            })
573        }
574        CurveVariant::TwoCryptoStable => Ok(Pool::TwoCryptoStable {
575            balances,
576            precisions: prec_arr,
577            price_scale,
578            d,
579            ann,
580            mid_fee,
581            out_fee,
582            fee_gamma,
583        }),
584        _ => unreachable!("build_twocrypto called for non-twocrypto variant"),
585    }
586}
587
588fn build_tricrypto(state: &RawPoolState) -> Result<Pool, BuildError> {
589    if state.balances.len() != 3 {
590        return Err(BuildError::WrongCoinCount {
591            variant: state.variant,
592            expected: 3,
593            actual: state.balances.len(),
594        });
595    }
596
597    for (i, &d) in state.token_decimals.iter().enumerate() {
598        if d > 18 {
599            return Err(BuildError::DecimalsTooLarge { index: i, decimals: d, max: 18 });
600        }
601    }
602
603    let mid_fee = require!(state, mid_fee);
604    let out_fee = require!(state, out_fee);
605    let fee_gamma = require!(state, fee_gamma);
606    let d = require!(state, d);
607    let gamma = require!(state, gamma);
608    let ann = state.amp;
609
610    let price_scale_vec = state
611        .price_scale
612        .as_ref()
613        .ok_or(BuildError::MissingField { variant: state.variant, field: "price_scale" })?;
614    if price_scale_vec.len() != 2 {
615        return Err(BuildError::PriceScaleWrongLen { expected: 2, actual: price_scale_vec.len() });
616    }
617    let price_scale: [U256; 2] = [price_scale_vec[0], price_scale_vec[1]];
618
619    let default_precs = compute_crypto_precisions(&state.token_decimals);
620    let precisions = state
621        .precisions
622        .as_deref()
623        .unwrap_or(&default_precs);
624    let balances: [U256; 3] = [state.balances[0], state.balances[1], state.balances[2]];
625    let prec_arr: [U256; 3] = [precisions[0], precisions[1], precisions[2]];
626
627    match state.variant {
628        CurveVariant::TriCryptoV1 => Ok(Pool::TriCryptoV1 {
629            balances,
630            precisions: prec_arr,
631            price_scale,
632            d,
633            ann,
634            gamma,
635            mid_fee,
636            out_fee,
637            fee_gamma,
638        }),
639        CurveVariant::TriCryptoNG => Ok(Pool::TriCryptoNG {
640            balances,
641            precisions: prec_arr,
642            price_scale,
643            d,
644            ann,
645            gamma,
646            mid_fee,
647            out_fee,
648            fee_gamma,
649        }),
650        _ => unreachable!("build_tricrypto called for non-tricrypto variant"),
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    #[test]
659    fn interpolate_a_no_ramp() {
660        // initial_A == future_A, any timestamp → returns the value
661        let a = U256::from(40_000u64);
662        let result = interpolate_a(a, a, 1000, 2000, 1500);
663        assert_eq!(result, a);
664    }
665
666    #[test]
667    fn interpolate_a_ramp_complete() {
668        // timestamp >= future_a_time → returns future_a
669        let result = interpolate_a(U256::from(20_000u64), U256::from(40_000u64), 1000, 2000, 3000);
670        assert_eq!(result, U256::from(40_000u64));
671    }
672
673    #[test]
674    fn interpolate_a_ramp_exactly_at_end() {
675        let result = interpolate_a(U256::from(20_000u64), U256::from(40_000u64), 1000, 2000, 2000);
676        assert_eq!(result, U256::from(40_000u64));
677    }
678
679    #[test]
680    fn interpolate_a_ramp_up_midpoint() {
681        // Ramp from 20000 to 40000 over [1000, 2000]. At t=1500 (midpoint):
682        // 20000 + (40000-20000) * (1500-1000) / (2000-1000) = 20000 + 10000 = 30000
683        let result = interpolate_a(U256::from(20_000u64), U256::from(40_000u64), 1000, 2000, 1500);
684        assert_eq!(result, U256::from(30_000u64));
685    }
686
687    #[test]
688    fn interpolate_a_ramp_down_midpoint() {
689        // Ramp from 40000 to 20000 over [1000, 2000]. At t=1500:
690        // 40000 - (40000-20000) * 500 / 1000 = 40000 - 10000 = 30000
691        let result = interpolate_a(U256::from(40_000u64), U256::from(20_000u64), 1000, 2000, 1500);
692        assert_eq!(result, U256::from(30_000u64));
693    }
694
695    #[test]
696    fn interpolate_a_ramp_up_quarter() {
697        // Ramp from 10000 to 50000 over [0, 1000]. At t=250:
698        // 10000 + (50000-10000) * 250 / 1000 = 10000 + 10000 = 20000
699        let result = interpolate_a(U256::from(10_000u64), U256::from(50_000u64), 0, 1000, 250);
700        assert_eq!(result, U256::from(20_000u64));
701    }
702
703    #[test]
704    fn interpolate_a_ramp_at_start() {
705        // timestamp == initial_a_time → returns initial_a
706        let result = interpolate_a(U256::from(20_000u64), U256::from(40_000u64), 1000, 2000, 1000);
707        assert_eq!(result, U256::from(20_000u64));
708    }
709
710    #[test]
711    fn interpolate_a_integer_division_truncation() {
712        // Verify integer division matches Vyper behavior (truncates, not rounds).
713        // Ramp from 10000 to 10003 over [0, 1000]. At t=1:
714        // 10000 + 3 * 1 / 1000 = 10000 + 0 = 10000 (truncated)
715        let result = interpolate_a(U256::from(10_000u64), U256::from(10_003u64), 0, 1000, 1);
716        assert_eq!(result, U256::from(10_000u64));
717    }
718
719    #[test]
720    fn build_stableswap_v0_basic() {
721        let state = RawPoolState {
722            variant: CurveVariant::StableSwapV0,
723            balances: vec![
724                U256::from(1_000_000_000_000_000_000_000u128), // 1000 DAI
725                U256::from(1_000_000_000u128),                 // 1000 USDC (6 dec)
726                U256::from(1_000_000_000u128),                 // 1000 USDT (6 dec)
727                U256::from(1_000_000_000_000_000_000_000u128), // 1000 sUSD
728            ],
729            token_decimals: vec![18, 6, 6, 18],
730            amp: U256::from(200u64), // A_PRECISION=1
731            fee: Some(U256::from(4_000_000u64)),
732            ..Default::default()
733        };
734
735        let pool = build_pool(&state).unwrap();
736        // Verify it's the right variant by attempting a swap
737        let dy = pool.get_amount_out(0, 1, U256::from(1_000_000_000_000_000_000u128));
738        assert!(dy.is_some());
739    }
740
741    #[test]
742    fn build_stableswap_v2_rates_18_6() {
743        let state = RawPoolState {
744            variant: CurveVariant::StableSwapV2,
745            balances: vec![
746                U256::from(1_000_000_000_000_000_000_000u128),
747                U256::from(1_000_000_000u128),
748            ],
749            token_decimals: vec![18, 6],
750            amp: U256::from(40_000u64), // 400 * A_PRECISION(100)
751            fee: Some(U256::from(4_000_000u64)),
752            ..Default::default()
753        };
754
755        let pool = build_pool(&state).unwrap();
756
757        // Check rates are correct: [10^18, 10^30]
758        match &pool {
759            Pool::StableSwapV2 { rates, .. } => {
760                assert_eq!(rates[0], U256::from(10u64).pow(U256::from(18u64)));
761                assert_eq!(rates[1], U256::from(10u64).pow(U256::from(30u64)));
762            }
763            _ => panic!("wrong variant"),
764        }
765    }
766
767    #[test]
768    fn build_stableswap_ng_with_dynamic_rates() {
769        let oracle_rate = U256::from(1_050_000_000_000_000_000u128); // 1.05 * 10^18
770        let state = RawPoolState {
771            variant: CurveVariant::StableSwapNG,
772            balances: vec![
773                U256::from(1_000_000_000_000_000_000_000u128),
774                U256::from(1_000_000_000_000_000_000_000u128),
775            ],
776            token_decimals: vec![18, 18],
777            amp: U256::from(150_000u64),
778            fee: Some(U256::from(4_000_000u64)),
779            offpeg_fee_multiplier: Some(U256::from(20_000_000_000u128)),
780            dynamic_rates: Some(vec![
781                None,              // computed from decimals
782                Some(oracle_rate), // oracle rate
783            ]),
784            ..Default::default()
785        };
786
787        let pool = build_pool(&state).unwrap();
788        match &pool {
789            Pool::StableSwapNG { rates, .. } => {
790                assert_eq!(rates[0], U256::from(10u64).pow(U256::from(18u64)));
791                assert_eq!(rates[1], oracle_rate);
792            }
793            _ => panic!("wrong variant"),
794        }
795    }
796
797    #[test]
798    fn build_stableswap_alend_precision_mul() {
799        let state = RawPoolState {
800            variant: CurveVariant::StableSwapALend,
801            balances: vec![
802                U256::from(1_000_000_000_000_000_000_000u128),
803                U256::from(1_000_000_000_000_000_000_000u128),
804            ],
805            token_decimals: vec![18, 18],
806            amp: U256::from(10_000u64), // 100 * A_PRECISION(100)
807            fee: Some(U256::from(4_000_000u64)),
808            offpeg_fee_multiplier: Some(U256::from(20_000_000_000u128)),
809            ..Default::default()
810        };
811
812        let pool = build_pool(&state).unwrap();
813        match &pool {
814            Pool::StableSwapALend { precision_mul, .. } => {
815                // 18 decimals → 10^(18-18) = 1
816                assert_eq!(precision_mul[0], U256::from(1u64));
817                assert_eq!(precision_mul[1], U256::from(1u64));
818            }
819            _ => panic!("wrong variant"),
820        }
821    }
822
823    #[test]
824    fn build_stableswap_meta_virtual_price_rate() {
825        let virtual_price = U256::from(1_020_000_000_000_000_000u128); // 1.02
826        let state = RawPoolState {
827            variant: CurveVariant::StableSwapMeta,
828            balances: vec![
829                U256::from(1_000_000_000u128),                 // GUSD (2 dec)
830                U256::from(1_000_000_000_000_000_000_000u128), // 3CRV LP
831            ],
832            token_decimals: vec![2, 18],
833            amp: U256::from(150_000u64),
834            fee: Some(U256::from(4_000_000u64)),
835            dynamic_rates: Some(vec![
836                None,                // 10^(36-2) = 10^34
837                Some(virtual_price), // virtual_price from base pool
838            ]),
839            ..Default::default()
840        };
841
842        let pool = build_pool(&state).unwrap();
843        match &pool {
844            Pool::StableSwapMeta { rates, .. } => {
845                assert_eq!(rates[0], U256::from(10u64).pow(U256::from(34u64)));
846                assert_eq!(rates[1], virtual_price);
847            }
848            _ => panic!("wrong variant"),
849        }
850    }
851
852    #[test]
853    fn build_twocrypto_ng_basic() {
854        let state = RawPoolState {
855            variant: CurveVariant::TwoCryptoNG,
856            balances: vec![
857                U256::from(1_000_000_000_000_000_000_000u128),
858                U256::from(1_000_000_000_000_000_000_000u128),
859            ],
860            token_decimals: vec![18, 18],
861            amp: U256::from(540_000u64 * 10_000u64), // A=540000 * A_MULTIPLIER
862            mid_fee: Some(U256::from(3_000_000u64)),
863            out_fee: Some(U256::from(30_000_000u64)),
864            fee_gamma: Some(U256::from(500_000_000_000_000u128)),
865            d: Some(U256::from(2_000_000_000_000_000_000_000u128)),
866            gamma: Some(U256::from(10_000_000_000_000u128)),
867            price_scale: Some(vec![U256::from(1_000_000_000_000_000_000u128)]),
868            ..Default::default()
869        };
870
871        let pool = build_pool(&state).unwrap();
872        match &pool {
873            Pool::TwoCryptoNG { precisions, ann, .. } => {
874                assert_eq!(precisions[0], U256::from(1u64)); // 10^(18-18)
875                assert_eq!(precisions[1], U256::from(1u64));
876                assert_eq!(*ann, state.amp);
877            }
878            _ => panic!("wrong variant"),
879        }
880    }
881
882    #[test]
883    fn build_twocrypto_stable_no_gamma() {
884        let state = RawPoolState {
885            variant: CurveVariant::TwoCryptoStable,
886            balances: vec![U256::from(1_000_000_000u128), U256::from(1_000_000_000u128)],
887            token_decimals: vec![6, 6],
888            amp: U256::from(540_000u64 * 10_000u64),
889            mid_fee: Some(U256::from(3_000_000u64)),
890            out_fee: Some(U256::from(30_000_000u64)),
891            fee_gamma: Some(U256::from(500_000_000_000_000u128)),
892            d: Some(U256::from(2_000_000_000u128)),
893            // gamma intentionally NOT set
894            price_scale: Some(vec![U256::from(1_000_000_000_000_000_000u128)]),
895            ..Default::default()
896        };
897
898        let pool = build_pool(&state).unwrap();
899        match &pool {
900            Pool::TwoCryptoStable { precisions, .. } => {
901                // 6 decimals → 10^(18-6) = 10^12
902                assert_eq!(precisions[0], U256::from(10u64).pow(U256::from(12u64)));
903            }
904            _ => panic!("wrong variant"),
905        }
906    }
907
908    #[test]
909    fn build_tricrypto_ng_basic() {
910        let state = RawPoolState {
911            variant: CurveVariant::TriCryptoNG,
912            balances: vec![
913                U256::from(1_000_000_000u128),               // USDC (6 dec)
914                U256::from(50_000_000u128),                  // WBTC (8 dec)
915                U256::from(500_000_000_000_000_000_000u128), // WETH (18 dec)
916            ],
917            token_decimals: vec![6, 8, 18],
918            amp: U256::from(1_707_629u64 * 10_000u64),
919            mid_fee: Some(U256::from(3_000_000u64)),
920            out_fee: Some(U256::from(30_000_000u64)),
921            fee_gamma: Some(U256::from(500_000_000_000_000u128)),
922            d: Some(U256::from(3_000_000_000_000_000_000_000u128)),
923            gamma: Some(U256::from(11_809_167_828_997u128)),
924            price_scale: Some(vec![
925                U256::from(60_000_000_000_000_000_000_000u128), // BTC price
926                U256::from(3_000_000_000_000_000_000_000u128),  // ETH price
927            ]),
928            ..Default::default()
929        };
930
931        let pool = build_pool(&state).unwrap();
932        match &pool {
933            Pool::TriCryptoNG { precisions, price_scale, .. } => {
934                assert_eq!(precisions[0], U256::from(10u64).pow(U256::from(12u64))); // 10^(18-6)
935                assert_eq!(precisions[1], U256::from(10u64).pow(U256::from(10u64))); // 10^(18-8)
936                assert_eq!(precisions[2], U256::from(1u64)); // 10^(18-18)
937                assert_eq!(price_scale.len(), 2);
938            }
939            _ => panic!("wrong variant"),
940        }
941    }
942
943    #[test]
944    fn build_missing_fee_returns_error() {
945        let state = RawPoolState {
946            variant: CurveVariant::StableSwapV2,
947            balances: vec![U256::from(1u64), U256::from(1u64)],
948            token_decimals: vec![18, 18],
949            amp: U256::from(40_000u64),
950            // fee intentionally missing
951            ..Default::default()
952        };
953
954        let err = match build_pool(&state) {
955            Err(e) => e,
956            Ok(_) => panic!("expected error"),
957        };
958        assert!(matches!(err, BuildError::MissingField { field: "fee", .. }));
959    }
960
961    #[test]
962    fn build_decimals_mismatch_returns_error() {
963        let state = RawPoolState {
964            variant: CurveVariant::StableSwapV2,
965            balances: vec![U256::from(1u64), U256::from(1u64)],
966            token_decimals: vec![18], // wrong length
967            amp: U256::from(40_000u64),
968            fee: Some(U256::from(4_000_000u64)),
969            ..Default::default()
970        };
971
972        let err = match build_pool(&state) {
973            Err(e) => e,
974            Ok(_) => panic!("expected error"),
975        };
976        assert!(matches!(err, BuildError::DecimalsMismatch { .. }));
977    }
978
979    #[test]
980    fn build_twocrypto_wrong_coin_count() {
981        let state = RawPoolState {
982            variant: CurveVariant::TwoCryptoNG,
983            balances: vec![U256::from(1u64), U256::from(1u64), U256::from(1u64)],
984            token_decimals: vec![18, 18, 18],
985            amp: U256::from(1u64),
986            mid_fee: Some(U256::from(1u64)),
987            out_fee: Some(U256::from(1u64)),
988            fee_gamma: Some(U256::from(1u64)),
989            d: Some(U256::from(1u64)),
990            gamma: Some(U256::from(1u64)),
991            price_scale: Some(vec![U256::from(1u64)]),
992            ..Default::default()
993        };
994
995        let err = match build_pool(&state) {
996            Err(e) => e,
997            Ok(_) => panic!("expected error"),
998        };
999        assert!(matches!(err, BuildError::WrongCoinCount { expected: 2, actual: 3, .. }));
1000    }
1001
1002    #[test]
1003    fn build_tricrypto_wrong_price_scale_len() {
1004        let state = RawPoolState {
1005            variant: CurveVariant::TriCryptoNG,
1006            balances: vec![U256::from(1u64), U256::from(1u64), U256::from(1u64)],
1007            token_decimals: vec![6, 8, 18],
1008            amp: U256::from(1u64),
1009            mid_fee: Some(U256::from(1u64)),
1010            out_fee: Some(U256::from(1u64)),
1011            fee_gamma: Some(U256::from(1u64)),
1012            d: Some(U256::from(1u64)),
1013            gamma: Some(U256::from(1u64)),
1014            price_scale: Some(vec![U256::from(1u64)]), // needs 2, got 1
1015            ..Default::default()
1016        };
1017
1018        let err = match build_pool(&state) {
1019            Err(e) => e,
1020            Ok(_) => panic!("expected error"),
1021        };
1022        assert!(matches!(err, BuildError::PriceScaleWrongLen { expected: 2, actual: 1 }));
1023    }
1024
1025    #[test]
1026    fn build_ng_without_offpeg_defaults_to_fee_denominator() {
1027        // v5+ crvUSD factory pools lack offpeg_fee_multiplier
1028        let state = RawPoolState {
1029            variant: CurveVariant::StableSwapNG,
1030            balances: vec![U256::from(1u64), U256::from(1u64)],
1031            token_decimals: vec![18, 18],
1032            amp: U256::from(40_000u64),
1033            fee: Some(U256::from(4_000_000u64)),
1034            // offpeg_fee_multiplier intentionally missing — defaults to FEE_DENOMINATOR
1035            ..Default::default()
1036        };
1037
1038        let pool = build_pool(&state).expect("should succeed with defaulted offpeg");
1039        // FEE_DENOMINATOR = 10_000_000_000
1040        assert_eq!(pool.offpeg_fee_multiplier(), Some(U256::from(10_000_000_000u64)));
1041    }
1042
1043    #[test]
1044    fn build_ng_crvusd_sdai_matches_on_chain() {
1045        // Real on-chain state for pool 0x1539c2461d7432cc114b0903f1824079BfCA2C92
1046        // (crvUSD/sDAI, v5.0.0 from crvUSD StableSwap Factory, no offpeg_fee_multiplier)
1047        let state = RawPoolState {
1048            variant: CurveVariant::StableSwapNG,
1049            balances: vec![
1050                "3219009600398261994"
1051                    .parse::<U256>()
1052                    .expect("balance 0"),
1053                "311156701443769568"
1054                    .parse::<U256>()
1055                    .expect("balance 1"),
1056            ],
1057            token_decimals: vec![18, 18],
1058            amp: U256::from(150_000u64),
1059            fee: Some(U256::from(1_000_000u64)),
1060            // offpeg_fee_multiplier absent (v5+ pool) — defaults to FEE_DENOMINATOR
1061            dynamic_rates: Some(vec![
1062                Some(
1063                    "1000000000000000000"
1064                        .parse::<U256>()
1065                        .expect("rate 0"),
1066                ),
1067                Some(
1068                    "1173627645818786870"
1069                        .parse::<U256>()
1070                        .expect("rate 1"),
1071                ),
1072            ]),
1073            ..Default::default()
1074        };
1075
1076        let pool = build_pool(&state).expect("should build with defaulted offpeg");
1077        let dy = pool
1078            .get_amount_out(0, 1, U256::from(3_219_009_600_398_261u64))
1079            .expect("swap should succeed");
1080
1081        // On-chain get_dy(0, 1, 3219009600398261) = 2720818166217034
1082        let expected = U256::from(2_720_818_166_217_034u64);
1083        let diff = if dy > expected { dy - expected } else { expected - dy };
1084        assert!(diff <= U256::from(1u64), "mismatch: got {dy}, expected {expected}, diff {diff}");
1085    }
1086
1087    #[test]
1088    fn build_meta_without_virtual_price_fails() {
1089        // Meta pool without dynamic_rates → must fail because rates[1]
1090        // would default to 10^(36-18)=10^18 instead of virtual_price.
1091        let state = RawPoolState {
1092            variant: CurveVariant::StableSwapMeta,
1093            balances: vec![
1094                U256::from(1_000_000_000u128),
1095                U256::from(1_000_000_000_000_000_000_000u128),
1096            ],
1097            token_decimals: vec![2, 18],
1098            amp: U256::from(150_000u64),
1099            fee: Some(U256::from(4_000_000u64)),
1100            // dynamic_rates NOT provided → should error
1101            ..Default::default()
1102        };
1103
1104        let err = match build_pool(&state) {
1105            Err(e) => e,
1106            Ok(_) => panic!("expected MetaMissingVirtualPrice error"),
1107        };
1108        assert!(matches!(err, BuildError::MetaMissingVirtualPrice));
1109    }
1110
1111    #[test]
1112    fn build_meta_with_partial_dynamic_rates_missing_vp_fails() {
1113        // dynamic_rates provided but last coin has None (no virtual_price)
1114        let state = RawPoolState {
1115            variant: CurveVariant::StableSwapMeta,
1116            balances: vec![
1117                U256::from(1_000_000_000u128),
1118                U256::from(1_000_000_000_000_000_000_000u128),
1119            ],
1120            token_decimals: vec![2, 18],
1121            amp: U256::from(150_000u64),
1122            fee: Some(U256::from(4_000_000u64)),
1123            dynamic_rates: Some(vec![None, None]), // vp not set for last coin
1124            ..Default::default()
1125        };
1126
1127        let err = match build_pool(&state) {
1128            Err(e) => e,
1129            Ok(_) => panic!("expected MetaMissingVirtualPrice error"),
1130        };
1131        assert!(matches!(err, BuildError::MetaMissingVirtualPrice));
1132    }
1133
1134    #[test]
1135    fn rates_match_fuzz_registry_18_dec() {
1136        // 18-decimal token → rate = 10^(36-18) = 10^18
1137        let rates = super::compute_stableswap_rates(&[18], &None);
1138        assert_eq!(rates[0], U256::from(10u64).pow(U256::from(18u64)));
1139    }
1140
1141    #[test]
1142    fn rates_match_fuzz_registry_6_dec() {
1143        // 6-decimal token → rate = 10^(36-6) = 10^30
1144        let rates = super::compute_stableswap_rates(&[6], &None);
1145        assert_eq!(rates[0], U256::from(10u64).pow(U256::from(30u64)));
1146    }
1147
1148    #[test]
1149    fn rates_match_fuzz_registry_8_dec() {
1150        // 8-decimal token → rate = 10^(36-8) = 10^28
1151        let rates = super::compute_stableswap_rates(&[8], &None);
1152        assert_eq!(rates[0], U256::from(10u64).pow(U256::from(28u64)));
1153    }
1154
1155    #[test]
1156    fn rates_match_fuzz_registry_2_dec() {
1157        // 2-decimal token (GUSD) → rate = 10^(36-2) = 10^34
1158        let rates = super::compute_stableswap_rates(&[2], &None);
1159        assert_eq!(rates[0], U256::from(10u64).pow(U256::from(34u64)));
1160    }
1161
1162    #[test]
1163    fn precisions_match_fuzz_registry() {
1164        // CryptoSwap: precision = 10^(18 - decimals)
1165        let precs = super::compute_crypto_precisions(&[6, 8, 18]);
1166        assert_eq!(precs[0], U256::from(10u64).pow(U256::from(12u64))); // USDC
1167        assert_eq!(precs[1], U256::from(10u64).pow(U256::from(10u64))); // WBTC
1168        assert_eq!(precs[2], U256::from(1u64)); // WETH
1169    }
1170
1171    #[test]
1172    fn precision_mul_matches_fuzz_registry() {
1173        // ALend: precision_mul = 10^(18 - decimals)
1174        let pm = super::compute_crypto_precisions(&[18, 6]);
1175        assert_eq!(pm[0], U256::from(1u64));
1176        assert_eq!(pm[1], U256::from(10u64).pow(U256::from(12u64)));
1177    }
1178
1179    #[test]
1180    fn build_all_11_variants_succeed() {
1181        // Smoke test: every variant can be built with minimal valid state.
1182        let stableswap_base = |variant: CurveVariant| -> RawPoolState {
1183            RawPoolState {
1184                variant,
1185                balances: vec![U256::from(1_000_000_000_000_000_000u128); 2],
1186                token_decimals: vec![18, 18],
1187                amp: U256::from(40_000u64),
1188                fee: Some(U256::from(4_000_000u64)),
1189                ..Default::default()
1190            }
1191        };
1192
1193        // V0, V1, V2
1194        for v in
1195            [CurveVariant::StableSwapV0, CurveVariant::StableSwapV1, CurveVariant::StableSwapV2]
1196        {
1197            assert!(build_pool(&stableswap_base(v)).is_ok(), "failed for {v}");
1198        }
1199
1200        // Meta (needs virtual_price)
1201        let mut meta = stableswap_base(CurveVariant::StableSwapMeta);
1202        meta.dynamic_rates = Some(vec![None, Some(U256::from(10u64).pow(U256::from(18u64)))]);
1203        assert!(build_pool(&meta).is_ok(), "failed for StableSwapMeta");
1204
1205        // NG
1206        let mut ng = stableswap_base(CurveVariant::StableSwapNG);
1207        ng.offpeg_fee_multiplier = Some(U256::from(20_000_000_000u128));
1208        assert!(build_pool(&ng).is_ok(), "failed for StableSwapNG");
1209
1210        // ALend
1211        let mut alend = stableswap_base(CurveVariant::StableSwapALend);
1212        alend.offpeg_fee_multiplier = Some(U256::from(20_000_000_000u128));
1213        assert!(build_pool(&alend).is_ok(), "failed for StableSwapALend");
1214
1215        // CryptoSwap common fields
1216        let crypto_base = |variant: CurveVariant, n: usize| -> RawPoolState {
1217            RawPoolState {
1218                variant,
1219                balances: vec![U256::from(1_000_000_000_000_000_000u128); n],
1220                token_decimals: vec![18; n],
1221                amp: U256::from(540_000u64 * 10_000u64),
1222                mid_fee: Some(U256::from(3_000_000u64)),
1223                out_fee: Some(U256::from(30_000_000u64)),
1224                fee_gamma: Some(U256::from(500_000_000_000_000u128)),
1225                d: Some(U256::from(2_000_000_000_000_000_000_000u128)),
1226                gamma: Some(U256::from(10_000_000_000_000u128)),
1227                price_scale: Some(if n == 2 {
1228                    vec![U256::from(10u64).pow(U256::from(18u64))]
1229                } else {
1230                    vec![U256::from(10u64).pow(U256::from(18u64)); n - 1]
1231                }),
1232                // Required for TwoCryptoV1; ignored by every other crypto variant.
1233                eth_variant: Some(true),
1234                ..Default::default()
1235            }
1236        };
1237
1238        // TwoCryptoV1, TwoCryptoNG
1239        for v in [CurveVariant::TwoCryptoV1, CurveVariant::TwoCryptoNG] {
1240            assert!(build_pool(&crypto_base(v, 2)).is_ok(), "failed for {v}");
1241        }
1242
1243        // TwoCryptoStable (no gamma needed)
1244        let mut tcs = crypto_base(CurveVariant::TwoCryptoStable, 2);
1245        tcs.gamma = None;
1246        assert!(build_pool(&tcs).is_ok(), "failed for TwoCryptoStable");
1247
1248        // TriCryptoV1, TriCryptoNG
1249        for v in [CurveVariant::TriCryptoV1, CurveVariant::TriCryptoNG] {
1250            assert!(build_pool(&crypto_base(v, 3)).is_ok(), "failed for {v}");
1251        }
1252    }
1253
1254    //
1255    // These tests use hardcoded state from Ethereum mainnet at block 24722544.
1256    // For each pool variant:
1257    //   1. RawPoolState is populated with real on-chain values
1258    //   2. build_pool() constructs the Pool
1259    //   3. get_amount_out() is compared against on-chain get_dy()
1260    //
1261    // If any test fails, it means build_pool() constructs a Pool that doesn't
1262    // match the on-chain contract's behavior — either rates, amp, or fees are wrong.
1263
1264    fn u(s: &str) -> U256 {
1265        U256::from_str_radix(s, 10).unwrap()
1266    }
1267
1268    #[test]
1269    fn integration_stableswap_v0_susd() {
1270        // sUSD pool: DAI(18)/USDC(6)/USDT(6)/sUSD(18), A=256, A_PRECISION=1
1271        let state = RawPoolState {
1272            variant: CurveVariant::StableSwapV0,
1273            balances: vec![
1274                u("1919848022082255699479"),
1275                u("1920322445"),
1276                u("1920171938"),
1277                u("21038816168255729764832232005"),
1278            ],
1279            token_decimals: vec![18, 6, 6, 18],
1280            amp: U256::from(256u64),
1281            fee: Some(U256::from(2_000_000u64)),
1282            ..Default::default()
1283        };
1284        let pool = build_pool(&state).unwrap();
1285        let dy = pool
1286            .get_amount_out(0, 1, u("19198480220822556994"))
1287            .unwrap();
1288        assert_eq!(dy, U256::from(19_009_291u64));
1289    }
1290
1291    #[test]
1292    fn integration_stableswap_v1_3pool() {
1293        // 3pool: DAI(18)/USDC(6)/USDT(6), A=4000, A_PRECISION=1
1294        let state = RawPoolState {
1295            variant: CurveVariant::StableSwapV1,
1296            balances: vec![
1297                u("45102835177280382580138407"),
1298                u("45853975278310"),
1299                u("72989152672276"),
1300            ],
1301            token_decimals: vec![18, 6, 6],
1302            amp: U256::from(4000u64),
1303            fee: Some(U256::from(1_500_000u64)),
1304            ..Default::default()
1305        };
1306        let pool = build_pool(&state).unwrap();
1307        let dy = pool
1308            .get_amount_out(0, 1, u("451028351772803825801384"))
1309            .unwrap();
1310        assert_eq!(dy, u("450961663745"));
1311    }
1312
1313    #[test]
1314    fn integration_stableswap_v2_frax_usdc() {
1315        // FRAX/USDC: FRAX(18)/USDC(6), A=1500*100=150000
1316        let state = RawPoolState {
1317            variant: CurveVariant::StableSwapV2,
1318            balances: vec![u("6722234569994793202271485"), u("714493991383")],
1319            token_decimals: vec![18, 6],
1320            amp: U256::from(150_000u64),
1321            fee: Some(U256::from(1_000_000u64)),
1322            ..Default::default()
1323        };
1324        let pool = build_pool(&state).unwrap();
1325        let dy = pool
1326            .get_amount_out(0, 1, u("67222345699947932022714"))
1327            .unwrap();
1328        assert_eq!(dy, u("66561674655"));
1329    }
1330
1331    #[test]
1332    fn integration_stableswap_alend_aave() {
1333        // Aave: aDAI(18)/aUSDC(6)/aUSDT(6), A=2000*100=200000
1334        let state = RawPoolState {
1335            variant: CurveVariant::StableSwapALend,
1336            balances: vec![u("968991099162993551077367"), u("1012448901351"), u("414282246850")],
1337            token_decimals: vec![18, 6, 6],
1338            amp: U256::from(200_000u64),
1339            fee: Some(U256::from(4_000_000u64)),
1340            offpeg_fee_multiplier: Some(u("20000000000")),
1341            ..Default::default()
1342        };
1343        let pool = build_pool(&state).unwrap();
1344        let dy = pool
1345            .get_amount_out(0, 1, u("9689910991629935510773"))
1346            .unwrap();
1347        assert_eq!(dy, u("9686201099"));
1348    }
1349
1350    #[test]
1351    fn integration_stableswap_ng_usde_dai() {
1352        // USDe/DAI NG: USDe(18)/DAI(18), A=400*100=40000
1353        let state = RawPoolState {
1354            variant: CurveVariant::StableSwapNG,
1355            balances: vec![u("124403796536542495997070"), u("95031311223261676260348")],
1356            token_decimals: vec![18, 18],
1357            amp: U256::from(40_000u64),
1358            fee: Some(U256::from(4_000_000u64)),
1359            offpeg_fee_multiplier: Some(u("20000000000")),
1360            dynamic_rates: Some(vec![
1361                Some(u("1000000000000000000")),
1362                Some(u("1000000000000000000")),
1363            ]),
1364            ..Default::default()
1365        };
1366        let pool = build_pool(&state).unwrap();
1367        let dy = pool
1368            .get_amount_out(0, 1, u("1244037965365424959970"))
1369            .unwrap();
1370        assert_eq!(dy, u("1242635841481792448583"));
1371    }
1372
1373    #[test]
1374    fn integration_stableswap_meta_gusd_3crv() {
1375        // GUSD/3CRV: GUSD(2)/3CRV(18), A=1000*100=100000, virtual_price from 3pool
1376        let state = RawPoolState {
1377            variant: CurveVariant::StableSwapMeta,
1378            balances: vec![u("59814423"), u("1210422553896217308280639")],
1379            token_decimals: vec![2, 18],
1380            amp: U256::from(100_000u64),
1381            fee: Some(U256::from(4_000_000u64)),
1382            dynamic_rates: Some(vec![
1383                None,                           // coin 0: 10^(36-2) = 10^34
1384                Some(u("1039823717145796146")), // virtual_price
1385            ]),
1386            ..Default::default()
1387        };
1388        let pool = build_pool(&state).unwrap();
1389        let dy = pool
1390            .get_amount_out(0, 1, u("598144"))
1391            .unwrap();
1392        assert_eq!(dy, u("5755338887370979902172"));
1393    }
1394
1395    #[test]
1396    fn integration_twocrypto_v1_crv_eth() {
1397        // CRV/ETH: CRV(18)/WETH(18)
1398        let state = RawPoolState {
1399            variant: CurveVariant::TwoCryptoV1,
1400            balances: vec![u("33389428640766852909"), u("1538654846121127403001612563")],
1401            token_decimals: vec![18, 18],
1402            amp: U256::from(400_000u64),
1403            d: Some(u("3338917956478824050009")),
1404            gamma: Some(u("145000000000000")),
1405            price_scale: Some(vec![u("52805053500476")]),
1406            mid_fee: Some(U256::from(26_000_000u64)),
1407            out_fee: Some(U256::from(45_000_000u64)),
1408            fee_gamma: Some(u("230000000000000")),
1409            eth_variant: Some(true), // CRV/ETH is WETH-paired → ETH solver
1410            ..Default::default()
1411        };
1412        let pool = build_pool(&state).unwrap();
1413        let dy = pool
1414            .get_amount_out(0, 1, u("333894286407668529"))
1415            .unwrap();
1416        assert_eq!(dy, u("15024547954512515366680912"));
1417    }
1418
1419    #[test]
1420    fn integration_twocrypto_ng_crvusd_fxn() {
1421        // crvUSD/FXN: crvUSD(18)/FXN(18)
1422        let state = RawPoolState {
1423            variant: CurveVariant::TwoCryptoNG,
1424            balances: vec![u("575304877931995002539"), u("1286854862507061937737")],
1425            token_decimals: vec![18, 18],
1426            amp: U256::from(400_000u64),
1427            d: Some(u("1309807915207365083258")),
1428            gamma: Some(u("145000000000000")),
1429            price_scale: Some(vec![u("578321621819309618")]),
1430            mid_fee: Some(U256::from(26_000_000u64)),
1431            out_fee: Some(U256::from(45_000_000u64)),
1432            fee_gamma: Some(u("230000000000000")),
1433            ..Default::default()
1434        };
1435        let pool = build_pool(&state).unwrap();
1436        let dy = pool
1437            .get_amount_out(0, 1, u("5753048779319950025"))
1438            .unwrap();
1439        assert_eq!(dy, u("12553693226638615366"));
1440    }
1441
1442    #[test]
1443    fn integration_twocrypto_stable_crvusd_weth() {
1444        // crvUSD/WETH TwoCryptoStable: crvUSD(18)/WETH(18)
1445        let state = RawPoolState {
1446            variant: CurveVariant::TwoCryptoStable,
1447            balances: vec![u("17087755783041929282185464"), u("13675635632110845893058")],
1448            token_decimals: vec![18, 18],
1449            amp: U256::from(25_000u64),
1450            d: Some(u("53892663239303863640675237")),
1451            price_scale: Some(vec![u("2783064941591876143844")]),
1452            mid_fee: Some(U256::from(60_000_000u64)),
1453            out_fee: Some(U256::from(220_000_000u64)),
1454            fee_gamma: Some(u("1395000000000000")),
1455            ..Default::default()
1456        };
1457        let pool = build_pool(&state).unwrap();
1458        let dy = pool
1459            .get_amount_out(0, 1, u("170877557830419292821854"))
1460            .unwrap();
1461        assert_eq!(dy, u("77522288630419592645"));
1462    }
1463
1464    #[test]
1465    fn integration_tricrypto_v1_usdt_wbtc_weth() {
1466        // tricrypto2: USDT(6)/WBTC(8)/WETH(18)
1467        let state = RawPoolState {
1468            variant: CurveVariant::TriCryptoV1,
1469            balances: vec![u("3687737692530"), u("5185841754"), u("1696614171366863858308")],
1470            token_decimals: vec![6, 8, 18],
1471            amp: U256::from(1_707_629u64),
1472            d: Some(u("11006845200255249518958282")),
1473            gamma: Some(u("11809167828997")),
1474            price_scale: Some(vec![u("70578404679338064954709"), u("2156666095129214805267")]),
1475            mid_fee: Some(U256::from(3_000_000u64)),
1476            out_fee: Some(U256::from(30_000_000u64)),
1477            fee_gamma: Some(u("500000000000000")),
1478            ..Default::default()
1479        };
1480        let pool = build_pool(&state).unwrap();
1481        let dy = pool
1482            .get_amount_out(0, 1, u("36877376925"))
1483            .unwrap();
1484        assert_eq!(dy, U256::from(51_646_866u64));
1485    }
1486
1487    #[test]
1488    fn integration_tricrypto_ng_usdc_wbtc_weth() {
1489        // tricrypto-ng: USDC(6)/WBTC(8)/WETH(18)
1490        let state = RawPoolState {
1491            variant: CurveVariant::TriCryptoNG,
1492            balances: vec![u("3323859056394"), u("4735137544"), u("1544027711277257449902")],
1493            token_decimals: vec![6, 8, 18],
1494            amp: U256::from(1_707_629u64),
1495            d: Some(u("10010654847128420517547506")),
1496            gamma: Some(u("11809167828997")),
1497            price_scale: Some(vec![u("70750968814053384159761"), u("2161000205852311064272")]),
1498            mid_fee: Some(U256::from(3_000_000u64)),
1499            out_fee: Some(U256::from(30_000_000u64)),
1500            fee_gamma: Some(u("500000000000000")),
1501            ..Default::default()
1502        };
1503        let pool = build_pool(&state).unwrap();
1504        let dy = pool
1505            .get_amount_out(0, 1, u("33238590563"))
1506            .unwrap();
1507        assert_eq!(dy, U256::from(46_932_317u64));
1508    }
1509}