Skip to main content

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

1//! Detect Curve pool variant from on-chain probing results.
2//!
3//! This module ports the logic from `detect_variant.py` into Rust, but
4//! **without any RPC calls**. The consumer probes the pool contract and
5//! reports results via [`ProbingResults`], then [`detect_variant`] returns
6//! the appropriate [`CurveVariant`].
7//!
8//! # When to use this
9//!
10//! Use this module when you have a pool address and need to identify
11//! its variant via RPC probing (e.g., a standalone indexer discovering
12//! unknown pools). If you already know the variant from another source
13//! (deploy events, protocol metadata, etc.), skip this and pass the
14//! variant directly to [`RawPoolState`](crate::evm::protocol::curve::adapter::RawPoolState).
15//!
16//! # Detection order (StableSwap)
17//!
18//! 1. `stored_rates()` → **StableSwapNG** (all NG pools, including v5+ crvUSD factory pools that
19//!    lack `offpeg_fee_multiplier()`)
20//! 2. `offpeg_fee_multiplier()` without `stored_rates()` → **StableSwapALend**
21//! 3. `version()` → **StableSwapNG** (v6+ crvUSD factory pools that lack both `stored_rates()` and
22//!    `offpeg_fee_multiplier()`)
23//! 4. `base_pool()` → **StableSwapMeta**
24//! 5. `balances(int128)` → **StableSwapV0**
25//! 6. Known address → **StableSwapV0** / **StableSwapV1**
26//! 7. Fallback → **StableSwapV2**
27//!
28//! # Limitations
29//!
30//! MetaPool Factory proxy pools lack `base_pool()`. Without factory context,
31//! they are misclassified as `StableSwapV2`. Factory-aware detection (via
32//! deploy events or `factory.is_meta()`) is more reliable; factory→variant mapping lives in the
33//! consumer's variant resolver.
34
35use alloy_primitives::{address, Address};
36
37use crate::evm::protocol::curve::adapter::CurveVariant;
38
39/// Results of on-chain function probing.
40///
41/// The consumer calls these getters on the pool contract and reports
42/// whether each call succeeded. No actual values are needed (except
43/// `math_version`) — only success/failure matters.
44///
45/// # How to populate
46///
47/// For each field, try calling the corresponding on-chain function.
48/// If the call reverts, set the field to `false` / `None`.
49///
50/// ```text
51/// has_gamma             ← call gamma()
52/// n_coins               ← count how many coins(i) calls succeed (i = 0, 1, 2, ...)
53/// has_math              ← call MATH() → returns address
54/// math_version          ← call version() on the MATH address
55/// has_offpeg_fee_multiplier ← call offpeg_fee_multiplier()
56/// has_stored_rates       ← call stored_rates()
57/// has_version            ← call version() on the pool itself
58/// has_base_pool          ← call base_pool()
59/// has_int128_balances    ← call balances(int128(0))
60/// ```
61pub struct ProbingResults {
62    /// Pool has `gamma()` getter → CryptoSwap variant.
63    pub has_gamma: bool,
64
65    /// Number of coins in the pool (count `coins(i)` calls that succeed).
66    pub n_coins: usize,
67
68    /// Pool has `MATH()` getter → TwoCrypto-NG with external math contract.
69    pub has_math: bool,
70
71    /// Version string from `MATH().version()`. E.g. `"v2.0.0"`, `"v2.1.0"`, `"v0.1.0"`.
72    pub math_version: Option<String>,
73
74    /// Pool has `offpeg_fee_multiplier()` → StableSwapNG or StableSwapALend.
75    /// Note: v5+ crvUSD StableSwap Factory pools may lack this while still
76    /// being NG (detected via `stored_rates()` instead).
77    pub has_offpeg_fee_multiplier: bool,
78
79    /// Pool has `stored_rates()` → StableSwapNG. Present on all NG pools
80    /// including v5+ crvUSD factory pools that lack `offpeg_fee_multiplier()`.
81    /// ALend does not have this.
82    pub has_stored_rates: bool,
83
84    /// Pool has `version()` getter → NG-era pool (v5+, v6+, v7+).
85    /// All NG pools have this. Legacy (V0/V1/V2/Meta/ALend) do not.
86    /// Catches v6+ crvUSD factory pools that lack both `stored_rates()`
87    /// and `offpeg_fee_multiplier()`.
88    pub has_version: bool,
89
90    /// Pool has `base_pool()` → StableSwapMeta.
91    /// Note: MetaPool Factory proxy pools lack this getter.
92    pub has_base_pool: bool,
93
94    /// `balances(int128(0))` call succeeds → V0-era pool (oldest interface).
95    pub has_int128_balances: bool,
96
97    /// Pool contract address (used for known-address fallback for V0/V1/TriCryptoV1).
98    pub pool_address: Address,
99}
100
101/// Error returned when variant cannot be determined.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct DetectError {
104    pub message: String,
105}
106
107impl std::fmt::Display for DetectError {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        write!(f, "cannot detect variant: {}", self.message)
110    }
111}
112
113impl std::error::Error for DetectError {}
114
115/// Detect pool variant from probing results.
116///
117/// This is a pure function — no RPC calls. The consumer probes the pool
118/// and passes results.
119///
120/// # Errors
121///
122/// Returns `DetectError` if the pool has `gamma()` but an unsupported
123/// coin count (not 2 or 3).
124pub fn detect_variant(probing: &ProbingResults) -> Result<CurveVariant, DetectError> {
125    if probing.has_gamma {
126        detect_cryptoswap(probing)
127    } else {
128        Ok(detect_stableswap(probing))
129    }
130}
131
132fn detect_cryptoswap(probing: &ProbingResults) -> Result<CurveVariant, DetectError> {
133    match probing.n_coins {
134        3 => {
135            if KNOWN_TRICRYPTO_V1.contains(&probing.pool_address) {
136                Ok(CurveVariant::TriCryptoV1)
137            } else {
138                Ok(CurveVariant::TriCryptoNG)
139            }
140        }
141        2 => {
142            if probing.has_math {
143                match probing.math_version.as_deref() {
144                    Some("v2.0.0" | "v2.1.0") => Ok(CurveVariant::TwoCryptoNG),
145                    Some("v0.1.0") => Ok(CurveVariant::TwoCryptoStable),
146                    // Unknown MATH version — default to TwoCryptoNG
147                    _ => Ok(CurveVariant::TwoCryptoNG),
148                }
149            } else {
150                // No MATH() function → legacy TwoCrypto with inline math
151                Ok(CurveVariant::TwoCryptoV1)
152            }
153        }
154        n => Err(DetectError {
155            message: format!("CryptoSwap pool with {n} coins — expected 2 or 3"),
156        }),
157    }
158}
159
160fn detect_stableswap(probing: &ProbingResults) -> CurveVariant {
161    // stored_rates → NG (covers standard NG pools with offpeg_fee_multiplier
162    // AND v5+ crvUSD factory pools that have stored_rates without offpeg)
163    if probing.has_stored_rates {
164        return CurveVariant::StableSwapNG;
165    }
166
167    // offpeg_fee_multiplier without stored_rates → ALend
168    if probing.has_offpeg_fee_multiplier {
169        return CurveVariant::StableSwapALend;
170    }
171
172    // version() → NG (covers v6+ crvUSD factory pools that lack both
173    // stored_rates and offpeg_fee_multiplier)
174    if probing.has_version {
175        return CurveVariant::StableSwapNG;
176    }
177
178    // base_pool() → Meta
179    if probing.has_base_pool {
180        return CurveVariant::StableSwapMeta;
181    }
182
183    // balances(int128) → V0
184    if probing.has_int128_balances {
185        return CurveVariant::StableSwapV0;
186    }
187
188    // Fallback: known addresses for V0/V1/STETH, else V2
189    let addr = probing.pool_address;
190    if KNOWN_V0.contains(&addr) {
191        CurveVariant::StableSwapV0
192    } else if KNOWN_V1.contains(&addr) {
193        CurveVariant::StableSwapV1
194    } else if KNOWN_STETH.contains(&addr) {
195        CurveVariant::StableSwapSTETH
196    } else {
197        CurveVariant::StableSwapV2
198    }
199}
200
201//
202// These pools cannot be reliably distinguished on-chain and require
203// address-based lookup. These are COMPLETE lists — no new pools of these
204// types can be created because no factory exists for them. All pre-factory
205// pools are deployed manually and the set is fixed.
206
207const KNOWN_TRICRYPTO_V1: [Address; 2] = [
208    address!("D51a44d3FaE010294C616388b506AcdA1bfAAE46"), // tricrypto2 (USDT/WBTC/WETH)
209    address!("80466c64868E1ab14a1Ddf27A676C3fcBE638Fe5"), // tricrypto (original)
210];
211
212const KNOWN_V0: [Address; 8] = [
213    address!("A5407eAE9Ba41422680e2e00537571bcC53efBfD"), // sUSD
214    address!("A2B47E3D5c44877cca798226B7B8118F9BFb7A56"), // compound
215    address!("79a8C46DeA5aDa233ABaFFD40F3A0A2B1e5A4F27"), // busd
216    address!("45F783CCE6B7FF23B2ab2D70e416cdb7D6055f51"), // y
217    address!("52EA46506B9CC5Ef470C5bf89f17Dc28bB35D85C"), // usdt
218    address!("06364f10B501e868329afBc005b3492902d6C763"), // pax
219    address!("93054188d876f558f4a66B2EF1d97d16eDf0895B"), // ren
220    address!("7fC77b5c7614E1533320Ea6DDc2Eb61fa00A9714"), // sbtc
221];
222
223const KNOWN_V1: [Address; 2] = [
224    address!("bEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7"), // 3pool
225    address!("4CA9b3063Ec5866A4B82E437059D2C43d1be596F"), // hbtc
226];
227
228// The Lido stETH/ETH pool is a one-off custom deployment whose `get_D` differs from the
229// base/plain template by a `+1` divisor guard. It probes identically to a plain V2 pool
230// (balances(uint256), no gamma/stored_rates/offpeg/version/base_pool), so it can only be told
231// apart by address.
232const KNOWN_STETH: [Address; 1] = [
233    address!("DC24316b9AE028F1497c275EB9192a3Ea0f67022"), // Lido stETH/ETH
234];
235
236/// Legacy TwoCryptoV1 pools that need the **non-ETH** `get_y` solver flavour.
237///
238/// # Mechanism
239///
240/// Legacy 2-coin CryptoSwap V1 ships in two deployed Vyper flavours whose Newton `get_y` solver
241/// differs only in the `mul2` integer-division grouping (see
242/// [`crate::evm::protocol::curve::math::core::twocrypto_v1`]):
243/// - `CurveCryptoSwap2ETH.vy` (ETH flavour): `10**18 + (2 * 10**18) * K0 / _g1k0`.
244/// - `CurveCryptoSwap2.vy` (non-ETH flavour): `(10**18 + 2*10**18*K0) / _g1k0`.
245///
246/// The TwoCryptoV1 factory deploys (EIP-1167 minimal proxies) and the WETH-paired legacy pools all
247/// use the ETH flavour, so [`detect_eth_variant`] returns `true` by default. Only a small set of
248/// legacy direct-deploy pools that are not WETH-paired use the non-ETH flavour. Those are listed
249/// here, identified by testing both flavours against the pool's on-chain `get_dy`.
250///
251/// This list is empty: every legacy and factory TwoCryptoV1 verified against on-chain `get_dy`
252/// (CRV/ETH and a factory EIP-1167 proxy) matched the ETH flavour wei-for-wei, and no non-ETH
253/// direct-deploy pool was confidently identified during verification. The brief is to add entries
254/// empirically (lowercased `address!(...)` with a verification comment) only when a real quote
255/// mismatch surfaces a pool that the non-ETH flavour reproduces; guessing addresses is not allowed.
256/// Correctness holds for ~99% of pools (all factory + WETH-paired pools) regardless.
257const NON_ETH_TWOCRYPTO_V1: [Address; 0] = [];
258
259/// Whether a TwoCryptoV1 pool uses the ETH `get_y` solver flavour.
260///
261/// Returns `true` for all pools except the hardcoded [`NON_ETH_TWOCRYPTO_V1`] deny-list, which
262/// holds legacy direct-deploy pools verified to need the non-ETH flavour. See that constant for
263/// the mechanism and how the list is maintained.
264pub fn detect_eth_variant(pool: Address) -> bool {
265    !NON_ETH_TWOCRYPTO_V1.contains(&pool)
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    fn addr(s: &str) -> Address {
273        s.parse().unwrap()
274    }
275
276    #[test]
277    fn detect_tricrypto_v1_by_address() {
278        let probing = ProbingResults {
279            has_gamma: true,
280            n_coins: 3,
281            has_math: false,
282            math_version: None,
283            has_offpeg_fee_multiplier: false,
284            has_stored_rates: false,
285            has_version: false,
286            has_base_pool: false,
287            has_int128_balances: false,
288            pool_address: addr("0xD51a44d3FaE010294C616388b506AcdA1bfAAE46"),
289        };
290        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::TriCryptoV1);
291    }
292
293    #[test]
294    fn detect_tricrypto_ng_unknown_3coin() {
295        let probing = ProbingResults {
296            has_gamma: true,
297            n_coins: 3,
298            has_math: false,
299            math_version: None,
300            has_offpeg_fee_multiplier: false,
301            has_stored_rates: false,
302            has_version: false,
303            has_base_pool: false,
304            has_int128_balances: false,
305            pool_address: addr("0x7F86Bf177Dd4F3494b841a37e810A34dD56c829B"),
306        };
307        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::TriCryptoNG);
308    }
309
310    #[test]
311    fn detect_twocrypto_ng_v200() {
312        let probing = ProbingResults {
313            has_gamma: true,
314            n_coins: 2,
315            has_math: true,
316            math_version: Some("v2.0.0".to_string()),
317            has_offpeg_fee_multiplier: false,
318            has_stored_rates: false,
319            has_version: false,
320            has_base_pool: false,
321            has_int128_balances: false,
322            pool_address: addr("0xfb8b95Fb2296a0Ad4b6b1419fdAA5AA5F13e4009"),
323        };
324        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::TwoCryptoNG);
325    }
326
327    #[test]
328    fn detect_twocrypto_ng_v210() {
329        let probing = ProbingResults {
330            has_gamma: true,
331            n_coins: 2,
332            has_math: true,
333            math_version: Some("v2.1.0".to_string()),
334            has_offpeg_fee_multiplier: false,
335            has_stored_rates: false,
336            has_version: false,
337            has_base_pool: false,
338            has_int128_balances: false,
339            pool_address: Address::ZERO,
340        };
341        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::TwoCryptoNG);
342    }
343
344    #[test]
345    fn detect_twocrypto_stable_v010() {
346        let probing = ProbingResults {
347            has_gamma: true,
348            n_coins: 2,
349            has_math: true,
350            math_version: Some("v0.1.0".to_string()),
351            has_offpeg_fee_multiplier: false,
352            has_stored_rates: false,
353            has_version: false,
354            has_base_pool: false,
355            has_int128_balances: false,
356            pool_address: addr("0x6e5492F8ea2370844EE098A56DD88e1717e4A9C2"),
357        };
358        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::TwoCryptoStable);
359    }
360
361    #[test]
362    fn detect_twocrypto_v1_no_math() {
363        let probing = ProbingResults {
364            has_gamma: true,
365            n_coins: 2,
366            has_math: false,
367            math_version: None,
368            has_offpeg_fee_multiplier: false,
369            has_stored_rates: false,
370            has_version: false,
371            has_base_pool: false,
372            has_int128_balances: false,
373            pool_address: addr("0x8301AE4fc9c624d1D396cbDAa1ed877821D7C511"),
374        };
375        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::TwoCryptoV1);
376    }
377
378    #[test]
379    fn detect_twocrypto_ng_unknown_math_version() {
380        let probing = ProbingResults {
381            has_gamma: true,
382            n_coins: 2,
383            has_math: true,
384            math_version: Some("v3.0.0".to_string()),
385            has_offpeg_fee_multiplier: false,
386            has_stored_rates: false,
387            has_version: false,
388            has_base_pool: false,
389            has_int128_balances: false,
390            pool_address: Address::ZERO,
391        };
392        // Unknown version defaults to TwoCryptoNG
393        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::TwoCryptoNG);
394    }
395
396    #[test]
397    fn detect_crypto_unsupported_coin_count() {
398        let probing = ProbingResults {
399            has_gamma: true,
400            n_coins: 4,
401            has_math: false,
402            math_version: None,
403            has_offpeg_fee_multiplier: false,
404            has_stored_rates: false,
405            has_version: false,
406            has_base_pool: false,
407            has_int128_balances: false,
408            pool_address: Address::ZERO,
409        };
410        assert!(detect_variant(&probing).is_err());
411    }
412
413    #[test]
414    fn detect_stableswap_ng() {
415        let probing = ProbingResults {
416            has_gamma: false,
417            n_coins: 2,
418            has_math: false,
419            math_version: None,
420            has_offpeg_fee_multiplier: true,
421            has_stored_rates: true,
422            has_version: true,
423            has_base_pool: false,
424            has_int128_balances: false,
425            pool_address: addr("0xF36a4BA50C603204c3FC6d2dA8b78A7b69CBC67d"),
426        };
427        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::StableSwapNG);
428    }
429
430    #[test]
431    fn detect_stableswap_ng_without_offpeg() {
432        // v5+ crvUSD factory pool: has stored_rates but no offpeg_fee_multiplier
433        let probing = ProbingResults {
434            has_gamma: false,
435            n_coins: 2,
436            has_math: false,
437            math_version: None,
438            has_offpeg_fee_multiplier: false,
439            has_stored_rates: true,
440            has_version: true,
441            has_base_pool: false,
442            has_int128_balances: false,
443            pool_address: addr("0x1539c2461d7432cc114b0903f1824079BfCA2C92"),
444        };
445        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::StableSwapNG);
446    }
447
448    #[test]
449    fn detect_stableswap_ng_via_version() {
450        // v6.0.1 crvUSD factory pool: has version() but no stored_rates, no offpeg
451        let probing = ProbingResults {
452            has_gamma: false,
453            n_coins: 3,
454            has_math: false,
455            math_version: None,
456            has_offpeg_fee_multiplier: false,
457            has_stored_rates: false,
458            has_version: true,
459            has_base_pool: false,
460            has_int128_balances: false,
461            pool_address: addr("0x4DEcE678ceceb27446b35C672dC7d61F30bAD69E"),
462        };
463        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::StableSwapNG);
464    }
465
466    #[test]
467    fn detect_stableswap_alend() {
468        let probing = ProbingResults {
469            has_gamma: false,
470            n_coins: 3,
471            has_math: false,
472            math_version: None,
473            has_offpeg_fee_multiplier: true,
474            has_stored_rates: false,
475            has_version: false,
476            has_base_pool: false,
477            has_int128_balances: false,
478            pool_address: addr("0xDeBF20617708857ebe4F679508E7b7863a8A8EeE"),
479        };
480        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::StableSwapALend);
481    }
482
483    #[test]
484    fn detect_stableswap_meta() {
485        let probing = ProbingResults {
486            has_gamma: false,
487            n_coins: 2,
488            has_math: false,
489            math_version: None,
490            has_offpeg_fee_multiplier: false,
491            has_stored_rates: false,
492            has_version: false,
493            has_base_pool: true,
494            has_int128_balances: false,
495            pool_address: addr("0x4f062658EaAF2C1ccf8C8e36D6824CDf41167956"),
496        };
497        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::StableSwapMeta);
498    }
499
500    #[test]
501    fn detect_stableswap_v0_by_int128() {
502        let probing = ProbingResults {
503            has_gamma: false,
504            n_coins: 4,
505            has_math: false,
506            math_version: None,
507            has_offpeg_fee_multiplier: false,
508            has_stored_rates: false,
509            has_version: false,
510            has_base_pool: false,
511            has_int128_balances: true,
512            pool_address: addr("0xA5407eAE9Ba41422680e2e00537571bcC53efBfD"),
513        };
514        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::StableSwapV0);
515    }
516
517    #[test]
518    fn detect_stableswap_v0_by_known_address() {
519        // Even without int128 probe, known address identifies V0
520        let probing = ProbingResults {
521            has_gamma: false,
522            n_coins: 4,
523            has_math: false,
524            math_version: None,
525            has_offpeg_fee_multiplier: false,
526            has_stored_rates: false,
527            has_version: false,
528            has_base_pool: false,
529            has_int128_balances: false,
530            pool_address: addr("0xA5407eAE9Ba41422680e2e00537571bcC53efBfD"),
531        };
532        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::StableSwapV0);
533    }
534
535    #[test]
536    fn detect_stableswap_v1_3pool() {
537        let probing = ProbingResults {
538            has_gamma: false,
539            n_coins: 3,
540            has_math: false,
541            math_version: None,
542            has_offpeg_fee_multiplier: false,
543            has_stored_rates: false,
544            has_version: false,
545            has_base_pool: false,
546            has_int128_balances: false,
547            pool_address: addr("0xbEbc44782C7dB0a1A60Cb6fe97d0b483032FF1C7"),
548        };
549        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::StableSwapV1);
550    }
551
552    #[test]
553    fn detect_stableswap_steth_by_known_address() {
554        // The Lido stETH/ETH pool probes like a plain V2 pool; only its known address
555        // distinguishes it.
556        let probing = ProbingResults {
557            has_gamma: false,
558            n_coins: 2,
559            has_math: false,
560            math_version: None,
561            has_offpeg_fee_multiplier: false,
562            has_stored_rates: false,
563            has_version: false,
564            has_base_pool: false,
565            has_int128_balances: false,
566            pool_address: addr("0xDC24316b9AE028F1497c275EB9192a3Ea0f67022"),
567        };
568        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::StableSwapSTETH);
569    }
570
571    #[test]
572    fn detect_eth_variant_defaults_true() {
573        // CRV/ETH (WETH-paired) and an arbitrary address both default to the ETH solver flavour;
574        // only the (currently empty) NON_ETH_TWOCRYPTO_V1 deny-list returns false.
575        assert!(detect_eth_variant(addr("0x8301AE4fc9c624d1D396cbDAa1ed877821D7C511")));
576        assert!(detect_eth_variant(Address::ZERO));
577    }
578
579    #[test]
580    fn detect_stableswap_v2_default() {
581        let probing = ProbingResults {
582            has_gamma: false,
583            n_coins: 2,
584            has_math: false,
585            math_version: None,
586            has_offpeg_fee_multiplier: false,
587            has_stored_rates: false,
588            has_version: false,
589            has_base_pool: false,
590            has_int128_balances: false,
591            pool_address: addr("0xDcEF968d416a41Cdac0ED8702fAC8128A64241A2"),
592        };
593        assert_eq!(detect_variant(&probing).unwrap(), CurveVariant::StableSwapV2);
594    }
595}