Skip to main content

tycho_simulation/rfq/protocols/metric/
decoder.rs

1use std::collections::{HashMap, HashSet};
2
3use tycho_client::feed::synchronizer::ComponentWithState;
4use tycho_common::{models::token::Token, Bytes};
5
6use super::{
7    client_builder::MetricClientBuilder,
8    models::{MetricBidAskResponse, MetricDepth, MetricMetadata},
9    state::MetricState,
10};
11use crate::{
12    protocol::{
13        errors::InvalidSnapshotError,
14        models::{DecoderContext, TryFromWithBlock},
15    },
16    rfq::models::TimestampHeader,
17};
18
19impl TryFromWithBlock<ComponentWithState, TimestampHeader> for MetricState {
20    type Error = InvalidSnapshotError;
21
22    async fn try_from_with_header(
23        snapshot: ComponentWithState,
24        _timestamp_header: TimestampHeader,
25        _account_balances: &HashMap<Bytes, HashMap<Bytes, Bytes>>,
26        all_tokens: &HashMap<Bytes, Token>,
27        _decoder_context: &DecoderContext,
28    ) -> Result<Self, Self::Error> {
29        if snapshot.component.tokens.len() != 2 {
30            return Err(InvalidSnapshotError::ValueError(
31                "Metric component must have token0 and token1".to_string(),
32            ));
33        }
34
35        let token0_address = &snapshot.component.tokens[0];
36        let token1_address = &snapshot.component.tokens[1];
37        let token0 = all_tokens
38            .get(token0_address)
39            .ok_or_else(|| {
40                InvalidSnapshotError::ValueError(format!(
41                    "Metric token0 not found: {token0_address}"
42                ))
43            })?
44            .clone();
45        let token1 = all_tokens
46            .get(token1_address)
47            .ok_or_else(|| {
48                InvalidSnapshotError::ValueError(format!(
49                    "Metric token1 not found: {token1_address}"
50                ))
51            })?
52            .clone();
53        let pool_address = snapshot
54            .component
55            .id
56            .parse::<Bytes>()
57            .map_err(|_| {
58                InvalidSnapshotError::ValueError(format!(
59                    "Metric component id is not a pool address: {}",
60                    snapshot.component.id
61                ))
62            })?;
63
64        // RFQ snapshots do not carry balances; all Metric pricing data is stored as attributes.
65        let attrs = snapshot.state.attributes;
66        let metadata = MetricMetadata {
67            pool_address,
68            token0: token0_address.clone(),
69            token1: token1_address.clone(),
70            tvl_fiat: None,
71        };
72        let bid_ask = MetricBidAskResponse {
73            bid_adj: read_biguint_attr(&attrs, "bid_adj")?,
74            ask_adj: read_biguint_attr(&attrs, "ask_adj")?,
75            total_token0_available: Some(read_biguint_attr(&attrs, "total_token0_available")?),
76            total_token1_available: Some(read_biguint_attr(&attrs, "total_token1_available")?),
77            server_ts: read_u64_attr(&attrs, "server_ts")?,
78            // Component attributes do not carry the provider status: only healthy pools are
79            // emitted, and is_quotable treats a missing status as "decide structurally".
80            price_provider_status: None,
81            depth: read_optional_depth_attr(&attrs, "depth")?,
82        };
83
84        let client = MetricClientBuilder::new(snapshot.component.chain)
85            .tokens(HashSet::from([token0_address.clone(), token1_address.clone()]))
86            .build()
87            .map_err(|e| {
88                InvalidSnapshotError::ValueError(format!("Couldn't create MetricClient: {e}"))
89            })?;
90
91        Ok(MetricState::new(token0, token1, metadata, bid_ask, client))
92    }
93}
94
95fn read_string_attr(
96    attrs: &HashMap<String, Bytes>,
97    name: &str,
98) -> Result<String, InvalidSnapshotError> {
99    let bytes = attrs.get(name).ok_or_else(|| {
100        InvalidSnapshotError::MissingAttribute(format!("{name} attribute not found"))
101    })?;
102    String::from_utf8(bytes.to_vec())
103        .map_err(|_| InvalidSnapshotError::ValueError(format!("Invalid {name} encoding")))
104}
105
106fn read_biguint_attr(
107    attrs: &HashMap<String, Bytes>,
108    name: &str,
109) -> Result<num_bigint::BigUint, InvalidSnapshotError> {
110    read_string_attr(attrs, name)?
111        .parse()
112        .map_err(|_| InvalidSnapshotError::ValueError(format!("Invalid {name} integer")))
113}
114
115fn read_u64_attr(attrs: &HashMap<String, Bytes>, name: &str) -> Result<u64, InvalidSnapshotError> {
116    read_string_attr(attrs, name)?
117        .parse()
118        .map_err(|_| InvalidSnapshotError::ValueError(format!("Invalid {name} integer")))
119}
120
121fn read_optional_depth_attr(
122    attrs: &HashMap<String, Bytes>,
123    name: &str,
124) -> Result<MetricDepth, InvalidSnapshotError> {
125    match attrs.get(name) {
126        Some(bytes) => serde_json::from_slice(bytes)
127            .map_err(|e| InvalidSnapshotError::ValueError(format!("Invalid {name} JSON: {e}"))),
128        None => Ok(MetricDepth::default()),
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use std::str::FromStr;
135
136    use tycho_common::models::{
137        protocol::{ProtocolComponent, ProtocolComponentState},
138        Chain as ModelChain, ChangeType,
139    };
140
141    use super::*;
142
143    fn weth() -> Token {
144        Token::new(
145            &Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(),
146            "WETH",
147            18,
148            0,
149            &[Some(2300)],
150            ModelChain::Ethereum,
151            100,
152        )
153    }
154
155    fn usdc() -> Token {
156        Token::new(
157            &Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(),
158            "USDC",
159            6,
160            0,
161            &[Some(1)],
162            ModelChain::Ethereum,
163            100,
164        )
165    }
166
167    fn create_snapshot() -> (ComponentWithState, HashMap<Bytes, Token>) {
168        let weth = weth();
169        let usdc = usdc();
170        let mut tokens = HashMap::new();
171        tokens.insert(weth.address.clone(), weth.clone());
172        tokens.insert(usdc.address.clone(), usdc.clone());
173
174        let mut attrs = HashMap::new();
175        attrs.insert(
176            "bid_adj".to_string(),
177            "55340232221128654848000"
178                .as_bytes()
179                .to_vec()
180                .into(),
181        );
182        attrs.insert(
183            "ask_adj".to_string(),
184            "55524699661865750400000"
185                .as_bytes()
186                .to_vec()
187                .into(),
188        );
189        attrs.insert(
190            "total_token0_available".to_string(),
191            "10000000000000000000"
192                .as_bytes()
193                .to_vec()
194                .into(),
195        );
196        attrs
197            .insert("total_token1_available".to_string(), "30000000000".as_bytes().to_vec().into());
198        attrs.insert("server_ts".to_string(), "100".as_bytes().to_vec().into());
199        attrs.insert("depth".to_string(), r#"{"asks":[],"bids":[]}"#.as_bytes().to_vec().into());
200
201        let pool_address = Bytes::from_str("0xbF48bCf474d57fF82A3215319229e0DE1476A557").unwrap();
202        let snapshot = ComponentWithState {
203            state: ProtocolComponentState {
204                attributes: attrs,
205                component_id: pool_address.to_string(),
206                balances: HashMap::new(),
207            },
208            component: ProtocolComponent {
209                id: pool_address.to_string(),
210                protocol_system: "rfq:metric".to_string(),
211                protocol_type_name: "metric_pool".to_string(),
212                chain: ModelChain::Ethereum,
213                tokens: vec![weth.address.clone(), usdc.address.clone()],
214                contract_addresses: Vec::new(),
215                static_attributes: HashMap::new(),
216                change: ChangeType::Creation,
217                creation_tx: Bytes::default(),
218                created_at: chrono::NaiveDateTime::default(),
219            },
220            component_tvl: None,
221            entrypoints: Vec::new(),
222        };
223
224        (snapshot, tokens)
225    }
226
227    #[tokio::test]
228    async fn test_try_from_with_header() {
229        let (snapshot, tokens) = create_snapshot();
230        let state = MetricState::try_from_with_header(
231            snapshot,
232            TimestampHeader { timestamp: 1_700_000_000 },
233            &HashMap::new(),
234            &tokens,
235            &DecoderContext::new(),
236        )
237        .await
238        .expect("decode metric state");
239
240        assert_eq!(state.base_token.symbol, "WETH");
241        assert_eq!(state.quote_token.symbol, "USDC");
242        assert_eq!(state.bid_ask.server_ts, 100);
243    }
244
245    #[tokio::test]
246    async fn test_try_from_missing_attribute() {
247        let (mut snapshot, tokens) = create_snapshot();
248        snapshot
249            .state
250            .attributes
251            .remove("bid_adj");
252
253        let result = MetricState::try_from_with_header(
254            snapshot,
255            TimestampHeader::default(),
256            &HashMap::new(),
257            &tokens,
258            &DecoderContext::new(),
259        )
260        .await;
261
262        assert!(result.is_err());
263    }
264}