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