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