Skip to main content

tycho_simulation/evm/protocol/curve/
decoder.rs

1use std::{collections::HashMap, str::FromStr};
2
3use alloy::primitives::Address as AlloyAddress;
4use tycho_client::feed::{synchronizer::ComponentWithState, BlockHeader};
5use tycho_common::{models::token::Token, Bytes};
6
7use crate::{
8    evm::{
9        engine_db::{create_engine, SHARED_TYCHO_DB},
10        protocol::{
11            curve::{state::CurveState, variant, vm},
12            vm::utils::load_stateless_contracts,
13        },
14    },
15    protocol::{
16        errors::InvalidSnapshotError,
17        models::{DecoderContext, TryFromWithBlock},
18    },
19};
20
21/// Curve's substreams encodes native ETH as `0xEee…EeE`; Tycho's token map and component tokens
22/// use the zero address. Both are normalized to the zero address so coin addresses align with the
23/// token map and with swap inputs.
24const ETH_SENTINEL: [u8; 20] = [0xEE; 20];
25
26impl TryFromWithBlock<ComponentWithState, BlockHeader> for CurveState {
27    type Error = InvalidSnapshotError;
28
29    /// Decodes a `vm:curve` snapshot into a `CurveState`.
30    ///
31    /// Coin order is taken from the `coins` static attribute (the pool's on-chain coin order),
32    /// not `component.tokens` (which Tycho sorts by address) — the two differ in general, and the
33    /// VM getters (`balances(i)`, `price_scale(i)`, …) are indexed in on-chain order. Decimals and
34    /// the swap-token→index mapping therefore also follow the `coins` order.
35    async fn try_from_with_header(
36        value: ComponentWithState,
37        _block: BlockHeader,
38        _account_balances: &HashMap<Bytes, HashMap<Bytes, Bytes>>,
39        all_tokens: &HashMap<Bytes, Token>,
40        decoder_context: &DecoderContext,
41    ) -> Result<Self, Self::Error> {
42        let pool_address = Bytes::from_str(value.component.id.as_str()).map_err(|e| {
43            InvalidSnapshotError::ValueError(format!(
44                "Expected curve component id to be the pool address: {e}"
45            ))
46        })?;
47
48        let coins = parse_coins(&value.component.static_attributes)?;
49        if coins.len() < 2 {
50            return Err(InvalidSnapshotError::ValueError(format!(
51                "Curve pool {pool_address} has fewer than 2 coins"
52            )));
53        }
54        let decimals = coins
55            .iter()
56            .map(|coin| coin_decimals(coin, all_tokens, &pool_address))
57            .collect::<Result<Vec<u8>, _>>()?;
58
59        let engine = create_engine(
60            SHARED_TYCHO_DB.clone(),
61            decoder_context
62                .vm_traces
63                .unwrap_or_default(),
64        )
65        .expect("Infallible");
66
67        // Load proxy/implementation contracts so getter delegatecalls resolve (persists into the
68        // shared DB for later delta_transition rebuilds).
69        load_stateless_contracts(&engine, &value.state.attributes).await?;
70
71        let pool_alloy = AlloyAddress::from_slice(pool_address.as_ref());
72        // Ensure the pool's actual MATH() contract is loaded — the indexed math address can be
73        // stale, which otherwise breaks TwoCrypto NG-vs-Stable detection. Fail decoding if a pool
74        // that exposes MATH() cannot load it, rather than build a pool with unresolved math.
75        vm::load_math_contract(&engine, &pool_alloy).await?;
76
77        let resolved = variant::resolve_variant(
78            &value.component.static_attributes,
79            &pool_alloy,
80            coins.len(),
81            &engine,
82        )?;
83        let pool = vm::decode_from_vm(&engine, &pool_alloy, resolved, &decimals)?;
84
85        Ok(CurveState::new(pool_address, coins, decimals, resolved, pool))
86    }
87}
88
89/// Parse the `coins` static attribute (a JSON array of `"0x…"` addresses in on-chain order) into
90/// normalized coin addresses. The ETH sentinel is rewritten to the zero address.
91fn parse_coins(
92    static_attributes: &HashMap<String, Bytes>,
93) -> Result<Vec<Bytes>, InvalidSnapshotError> {
94    let raw = static_attributes
95        .get("coins")
96        .ok_or_else(|| {
97            InvalidSnapshotError::ValueError("Missing `coins` static attribute".to_string())
98        })?;
99    let text = std::str::from_utf8(raw.as_ref()).map_err(|e| {
100        InvalidSnapshotError::ValueError(format!("`coins` attribute is not valid UTF-8: {e}"))
101    })?;
102    let addresses: Vec<String> = serde_json::from_str(text).map_err(|e| {
103        InvalidSnapshotError::ValueError(format!("Failed to parse `coins` attribute: {e}"))
104    })?;
105    addresses
106        .iter()
107        .map(|address| {
108            Bytes::from_str(address)
109                .map(normalize_eth)
110                .map_err(|e| {
111                    InvalidSnapshotError::ValueError(format!("Invalid coin address {address}: {e}"))
112                })
113        })
114        .collect()
115}
116
117/// Rewrite the Curve ETH sentinel to the zero address; leave other addresses unchanged.
118fn normalize_eth(address: Bytes) -> Bytes {
119    if address.as_ref() == ETH_SENTINEL {
120        Bytes::from(vec![0u8; 20])
121    } else {
122        address
123    }
124}
125
126/// Look up a coin's decimals from the token map. Native ETH (zero address) defaults to 18 when
127/// absent; any other unknown coin is an error (the pool is dropped).
128fn coin_decimals(
129    coin: &Bytes,
130    all_tokens: &HashMap<Bytes, Token>,
131    pool_address: &Bytes,
132) -> Result<u8, InvalidSnapshotError> {
133    if let Some(token) = all_tokens.get(coin) {
134        return Ok(token.decimals as u8);
135    }
136    if coin.iter().all(|b| *b == 0) {
137        return Ok(18);
138    }
139    Err(InvalidSnapshotError::ValueError(format!(
140        "Missing token {coin} in state for curve pool {pool_address}"
141    )))
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    fn attrs_with_coins(json: &str) -> HashMap<String, Bytes> {
149        let mut m = HashMap::new();
150        m.insert("coins".to_string(), Bytes::from(json.as_bytes().to_vec()));
151        m
152    }
153
154    #[test]
155    fn parse_coins_preserves_on_chain_order_and_normalizes_eth() {
156        // On-chain order [USDC, WBTC, ETH-sentinel]; ETH sentinel must map to the zero address.
157        let json = r#"["0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48","0x2260fac5e5542a773aa44fbcfedf7c193bc2c599","0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"]"#;
158        let coins = parse_coins(&attrs_with_coins(json)).unwrap();
159        assert_eq!(coins.len(), 3);
160        assert_eq!(
161            coins[0],
162            Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap()
163        );
164        assert_eq!(
165            coins[1],
166            Bytes::from_str("0x2260fac5e5542a773aa44fbcfedf7c193bc2c599").unwrap()
167        );
168        assert_eq!(coins[2], Bytes::from(vec![0u8; 20]), "ETH sentinel -> zero address");
169    }
170
171    #[test]
172    fn parse_coins_missing_attribute_errors() {
173        let err = parse_coins(&HashMap::new()).unwrap_err();
174        assert!(matches!(err, InvalidSnapshotError::ValueError(_)));
175    }
176}