Skip to main content

tycho_simulation/evm/protocol/sky/
decoder.rs

1use std::collections::HashMap;
2
3use alloy::primitives::U256;
4use tycho_client::feed::{synchronizer::ComponentWithState, BlockHeader};
5use tycho_common::{models::token::Token, Bytes};
6
7use super::state::{JoinEscrows, SkyComponentKind, SkyState};
8use crate::protocol::{
9    errors::InvalidSnapshotError,
10    models::{DecoderContext, TryFromWithBlock},
11};
12
13impl TryFromWithBlock<ComponentWithState, BlockHeader> for SkyState {
14    type Error = InvalidSnapshotError;
15
16    /// Decodes a `ComponentWithState` into a `SkyState`, dispatching on the
17    /// `component_type` static attribute (`psm`, `psm_wrapper` or `converter`).
18    async fn try_from_with_header(
19        snapshot: ComponentWithState,
20        _block: BlockHeader,
21        _account_balances: &HashMap<Bytes, HashMap<Bytes, Bytes>>,
22        all_tokens: &HashMap<Bytes, Token>,
23        _decoder_context: &DecoderContext,
24    ) -> Result<Self, Self::Error> {
25        let kind = match snapshot
26            .component
27            .static_attributes
28            .get("component_type")
29            .ok_or_else(|| InvalidSnapshotError::MissingAttribute("component_type".to_string()))?
30            .as_ref()
31        {
32            b"psm" => SkyComponentKind::Psm,
33            b"psm_wrapper" => SkyComponentKind::PsmWrapper,
34            b"converter" => SkyComponentKind::Converter,
35            other => {
36                return Err(InvalidSnapshotError::ValueError(format!(
37                    "unknown sky component_type: {}",
38                    String::from_utf8_lossy(other)
39                )))
40            }
41        };
42
43        let tokens = snapshot
44            .component
45            .tokens
46            .iter()
47            .map(|address| {
48                all_tokens
49                    .get(address)
50                    .cloned()
51                    .ok_or_else(|| {
52                        InvalidSnapshotError::ValueError(format!("unknown token {address}"))
53                    })
54            })
55            .collect::<Result<Vec<_>, _>>()?;
56        let [a, b] = tokens.try_into().map_err(|_| {
57            InvalidSnapshotError::ValueError("sky components have exactly two tokens".to_string())
58        })?;
59
60        // Token roles come from the `gem` static attribute (also the swap encoder's
61        // direction source), so the token list order does not matter.
62        let gem_address = snapshot
63            .component
64            .static_attributes
65            .get("gem")
66            .ok_or_else(|| InvalidSnapshotError::MissingAttribute("gem".to_string()))?;
67        let (stable, gem) = if b.address == *gem_address {
68            (a, b)
69        } else if a.address == *gem_address {
70            (b, a)
71        } else {
72            return Err(InvalidSnapshotError::ValueError(format!(
73                "gem attribute {gem_address} is not among the component tokens"
74            )));
75        };
76        // The state's conversion factor is 10^(stable.decimals - gem.decimals);
77        // reject the inverted case up front so that unsigned subtraction cannot
78        // underflow (and, wrapping, turn the factor into a zero divisor).
79        if gem.decimals > stable.decimals {
80            return Err(InvalidSnapshotError::ValueError(format!(
81                "gem decimals ({}) exceed stable decimals ({})",
82                gem.decimals, stable.decimals
83            )));
84        }
85
86        let get_fee = |name: &str| -> Result<U256, InvalidSnapshotError> {
87            match kind {
88                // The converter is immutable and feeless; no attributes are indexed.
89                SkyComponentKind::Converter => Ok(U256::ZERO),
90                SkyComponentKind::Psm | SkyComponentKind::PsmWrapper => snapshot
91                    .state
92                    .attributes
93                    .get(name)
94                    .map(|value| U256::from_be_slice(value))
95                    .ok_or_else(|| InvalidSnapshotError::MissingAttribute(name.to_string())),
96            }
97        };
98        let get_balance = |token: &Token| -> Result<U256, InvalidSnapshotError> {
99            snapshot
100                .state
101                .balances
102                .get(&token.address)
103                .map(|balance| U256::from_be_slice(balance))
104                .ok_or_else(|| {
105                    // Every sky component carries both balances from creation (the
106                    // substreams package seeds them explicitly), so absence means a
107                    // corrupt or stale snapshot, not a zero balance.
108                    InvalidSnapshotError::ValueError(format!(
109                        "missing balance for token {} on component {}",
110                        token.address, snapshot.component.id
111                    ))
112                })
113        };
114
115        let (stable_balance, gem_balance) = (get_balance(&stable)?, get_balance(&gem)?);
116
117        let get_escrow = |name: &str| -> Result<U256, InvalidSnapshotError> {
118            snapshot
119                .state
120                .attributes
121                .get(name)
122                .map(|value| U256::from_be_slice(value))
123                .ok_or_else(|| InvalidSnapshotError::MissingAttribute(name.to_string()))
124        };
125        // The wrapper's in-flight DAI <-> USDS conversion is bounded by the join
126        // escrows, seeded at creation like the fees.
127        let escrows = match kind {
128            SkyComponentKind::PsmWrapper => Some(JoinEscrows {
129                dai: get_escrow("dai_escrow")?,
130                usds: get_escrow("usds_escrow")?,
131            }),
132            SkyComponentKind::Psm | SkyComponentKind::Converter => None,
133        };
134
135        Ok(SkyState::new(
136            snapshot.component.id.to_string(),
137            kind,
138            stable,
139            gem,
140            get_fee("tin")?,
141            get_fee("tout")?,
142            stable_balance,
143            gem_balance,
144            escrows,
145        ))
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use std::str::FromStr;
152
153    use rstest::rstest;
154    use tycho_common::{
155        models::{
156            protocol::{ProtocolComponent, ProtocolComponentState},
157            Chain,
158        },
159        Bytes,
160    };
161
162    use super::*;
163
164    async fn decode(
165        snapshot: ComponentWithState,
166        all_tokens: &HashMap<Bytes, Token>,
167    ) -> Result<SkyState, InvalidSnapshotError> {
168        SkyState::try_from_with_header(
169            snapshot,
170            Default::default(),
171            &HashMap::default(),
172            all_tokens,
173            &Default::default(),
174        )
175        .await
176    }
177
178    const PSM_ID: &str = "0xf6e72db5454dd049d0788e411b06cfaf16853042";
179    const DAI: &str = "0x6b175474e89094c44da98b954eedeac495271d0f";
180    const USDC: &str = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
181    const USDS: &str = "0xdc035d45d973e3ec169d2276ddab16f1e407384f";
182
183    fn token(address: &str, symbol: &str, decimals: u32) -> Token {
184        Token::new(
185            &Bytes::from_str(address).unwrap(),
186            symbol,
187            decimals,
188            0,
189            &[Some(50_000)],
190            Chain::Ethereum,
191            100,
192        )
193    }
194
195    fn all_tokens() -> HashMap<Bytes, Token> {
196        [token(DAI, "DAI", 18), token(USDC, "USDC", 6), token(USDS, "USDS", 18)]
197            .into_iter()
198            .map(|t| (t.address.clone(), t))
199            .collect()
200    }
201
202    fn snapshot(
203        id: &str,
204        component_type: &str,
205        gem: &str,
206        tokens: Vec<&str>,
207        with_fees: bool,
208    ) -> ComponentWithState {
209        let attributes = if with_fees {
210            HashMap::from([
211                ("tin".to_string(), Bytes::from(U256::ZERO.to_be_bytes_vec())),
212                ("tout".to_string(), Bytes::from(U256::from(2u8).to_be_bytes_vec())),
213            ])
214        } else {
215            HashMap::new()
216        };
217        ComponentWithState {
218            state: ProtocolComponentState {
219                component_id: id.to_string(),
220                attributes,
221                balances: HashMap::from([
222                    (
223                        Bytes::from_str(tokens[0]).unwrap(),
224                        Bytes::from(U256::from(1000u64).to_be_bytes_vec()),
225                    ),
226                    (
227                        Bytes::from_str(tokens[1]).unwrap(),
228                        Bytes::from(U256::from(2000u64).to_be_bytes_vec()),
229                    ),
230                ]),
231            },
232            component: ProtocolComponent {
233                id: id.to_string(),
234                tokens: tokens
235                    .iter()
236                    .map(|t| Bytes::from_str(t).unwrap())
237                    .collect(),
238                static_attributes: HashMap::from([
239                    ("component_type".to_string(), Bytes::from(component_type.as_bytes().to_vec())),
240                    ("gem".to_string(), Bytes::from_str(gem).unwrap()),
241                ]),
242                ..Default::default()
243            },
244            component_tvl: None,
245            entrypoints: Vec::new(),
246        }
247    }
248
249    #[rstest]
250    #[case::stable_first(vec![DAI, USDC])]
251    #[case::gem_first(vec![USDC, DAI])]
252    #[tokio::test]
253    async fn decodes_psm_regardless_of_token_order(#[case] tokens: Vec<&str>) {
254        let snap = snapshot(PSM_ID, "psm", USDC, tokens, true);
255        let state = decode(snap, &all_tokens())
256            .await
257            .unwrap();
258        assert_eq!(state.kind, SkyComponentKind::Psm);
259    }
260
261    #[tokio::test]
262    async fn decodes_converter_without_fee_attributes() {
263        let snap = snapshot(
264            "0x3225737a9bbb6473cb4a45b7244aca2befdb276a",
265            "converter",
266            USDS,
267            vec![DAI, USDS],
268            false,
269        );
270        let state = decode(snap, &all_tokens())
271            .await
272            .unwrap();
273        assert_eq!(state.kind, SkyComponentKind::Converter);
274    }
275
276    fn wrapper_snapshot() -> ComponentWithState {
277        let mut snap = snapshot(
278            "0xa188eec8f81263234da3622a406892f3d630f98c",
279            "psm_wrapper",
280            USDC,
281            vec![USDS, USDC],
282            true,
283        );
284        snap.state.attributes.extend([
285            ("dai_escrow".to_string(), Bytes::from(U256::from(7u8).to_be_bytes_vec())),
286            ("usds_escrow".to_string(), Bytes::from(U256::from(9u8).to_be_bytes_vec())),
287        ]);
288        snap
289    }
290
291    #[tokio::test]
292    async fn decodes_wrapper_with_join_escrows() {
293        let state = decode(wrapper_snapshot(), &all_tokens())
294            .await
295            .unwrap();
296        assert_eq!(state.kind, SkyComponentKind::PsmWrapper);
297    }
298
299    #[rstest]
300    #[case::dai_escrow("dai_escrow")]
301    #[case::usds_escrow("usds_escrow")]
302    #[tokio::test]
303    async fn missing_escrow_attribute_errors_for_wrapper(#[case] name: &str) {
304        let mut snap = wrapper_snapshot();
305        snap.state.attributes.remove(name);
306        let result = decode(snap, &all_tokens()).await;
307        assert!(matches!(result, Err(InvalidSnapshotError::MissingAttribute(_))));
308    }
309
310    #[tokio::test]
311    async fn missing_fee_attribute_errors_for_psm() {
312        let snap = snapshot(PSM_ID, "psm", USDC, vec![DAI, USDC], false);
313        let result = decode(snap, &all_tokens()).await;
314        assert!(matches!(result, Err(InvalidSnapshotError::MissingAttribute(_))));
315    }
316
317    #[tokio::test]
318    async fn unknown_component_type_errors() {
319        let snap = snapshot(PSM_ID, "mystery", USDC, vec![DAI, USDC], true);
320        let result = decode(snap, &all_tokens()).await;
321        assert!(matches!(result, Err(InvalidSnapshotError::ValueError(_))));
322    }
323
324    #[tokio::test]
325    async fn gem_decimals_above_stable_decimals_errors() {
326        // Same PSM snapshot, but with the tokens' decimals inverted (6-dec
327        // stable, 18-dec gem): the conversion factor would underflow.
328        let tokens = [token(DAI, "DAI", 6), token(USDC, "USDC", 18)]
329            .into_iter()
330            .map(|t| (t.address.clone(), t))
331            .collect();
332        let snap = snapshot(PSM_ID, "psm", USDC, vec![DAI, USDC], true);
333        let result = decode(snap, &tokens).await;
334        assert!(matches!(result, Err(InvalidSnapshotError::ValueError(_))));
335    }
336
337    #[tokio::test]
338    async fn missing_gem_attribute_errors() {
339        let mut snap = snapshot(PSM_ID, "psm", USDC, vec![DAI, USDC], true);
340        snap.component
341            .static_attributes
342            .remove("gem");
343        let result = decode(snap, &all_tokens()).await;
344        assert!(matches!(result, Err(InvalidSnapshotError::MissingAttribute(_))));
345    }
346
347    #[tokio::test]
348    async fn missing_balance_errors() {
349        let mut snap = snapshot(PSM_ID, "psm", USDC, vec![DAI, USDC], true);
350        snap.state
351            .balances
352            .remove(&Bytes::from_str(DAI).unwrap());
353        let result = decode(snap, &all_tokens()).await;
354        assert!(matches!(result, Err(InvalidSnapshotError::ValueError(_))));
355    }
356}