tycho_simulation/rfq/protocols/bebop/
decoder.rs

1use std::collections::HashMap;
2
3use tycho_client::feed::synchronizer::ComponentWithState;
4use tycho_common::{models::token::Token, Bytes};
5
6use super::{models::BebopPriceData, state::BebopState};
7use crate::{
8    protocol::{
9        errors::InvalidSnapshotError,
10        models::{DecoderContext, TryFromWithBlock},
11    },
12    rfq::{
13        constants::get_bebop_auth, models::TimestampHeader,
14        protocols::bebop::client_builder::BebopClientBuilder,
15    },
16};
17
18impl TryFromWithBlock<ComponentWithState, TimestampHeader> for BebopState {
19    type Error = InvalidSnapshotError;
20
21    async fn try_from_with_header(
22        snapshot: ComponentWithState,
23        timestamp_header: TimestampHeader,
24        _account_balances: &HashMap<Bytes, HashMap<Bytes, Bytes>>,
25        all_tokens: &HashMap<Bytes, Token>,
26        _decoder_context: &DecoderContext,
27    ) -> Result<Self, Self::Error> {
28        let state_attrs = snapshot.state.attributes;
29
30        if snapshot.component.tokens.len() != 2 {
31            return Err(InvalidSnapshotError::ValueError(
32                "Component must have 2 tokens (base and quote)".to_string(),
33            ));
34        }
35
36        let base_token_address = &snapshot.component.tokens[0];
37        let quote_token_address = &snapshot.component.tokens[1];
38
39        let base_token = all_tokens
40            .get(base_token_address)
41            .ok_or_else(|| {
42                InvalidSnapshotError::ValueError(format!(
43                    "Base token not found: {base_token_address}"
44                ))
45            })?
46            .clone();
47
48        let quote_token = all_tokens
49            .get(quote_token_address)
50            .ok_or_else(|| {
51                InvalidSnapshotError::ValueError(format!(
52                    "Quote token not found: {quote_token_address}"
53                ))
54            })?
55            .clone();
56
57        let empty_array_bytes: Bytes = "[]".as_bytes().to_vec().into();
58        let bids_json = state_attrs
59            .get("bids")
60            .unwrap_or(&empty_array_bytes);
61        let asks_json = state_attrs
62            .get("asks")
63            .unwrap_or(&empty_array_bytes);
64
65        // Parse bids and asks from JSON
66        let bids: Vec<(f32, f32)> = serde_json::from_slice(bids_json)
67            .map_err(|e| InvalidSnapshotError::ValueError(format!("Invalid bids JSON: {e}")))?;
68        let asks: Vec<(f32, f32)> = serde_json::from_slice(asks_json)
69            .map_err(|e| InvalidSnapshotError::ValueError(format!("Invalid asks JSON: {e}")))?;
70
71        let price_data = BebopPriceData {
72            base: base_token.address.to_vec(),
73            quote: quote_token.address.to_vec(),
74            last_update_ts: timestamp_header.timestamp,
75            bids: bids
76                .iter()
77                .flat_map(|(price, size)| [*price, *size])
78                .collect(),
79            asks: asks
80                .iter()
81                .flat_map(|(price, size)| [*price, *size])
82                .collect(),
83        };
84
85        let auth = get_bebop_auth().map_err(|e| {
86            InvalidSnapshotError::ValueError(format!("Failed to get Bebop authentication: {e}"))
87        })?;
88
89        let client = BebopClientBuilder::new(snapshot.component.chain.into(), auth.user, auth.key)
90            .build()
91            .map_err(|e| {
92                InvalidSnapshotError::MissingAttribute(format!("Couldn't create BebopClient: {e}"))
93            })?;
94
95        Ok(BebopState { base_token, quote_token, price_data, client })
96    }
97}
98
99#[cfg(test)]
100mod tests {
101    use std::env;
102
103    use tycho_common::{
104        dto::{Chain, ChangeType, ProtocolComponent, ResponseProtocolState},
105        models::Chain as ModelChain,
106    };
107
108    use super::*;
109
110    fn wbtc() -> Token {
111        Token::new(
112            &hex::decode("2260fac5e5542a773aa44fbcfedf7c193bc2c599")
113                .unwrap()
114                .into(),
115            "WBTC",
116            8,
117            0,
118            &[Some(10_000)],
119            ModelChain::Ethereum,
120            100,
121        )
122    }
123
124    fn usdc() -> Token {
125        Token::new(
126            &hex::decode("a0b86991c6218a76c1d19d4a2e9eb0ce3606eb48")
127                .unwrap()
128                .into(),
129            "USDC",
130            6,
131            0,
132            &[Some(10_000)],
133            ModelChain::Ethereum,
134            100,
135        )
136    }
137
138    fn create_test_snapshot() -> (ComponentWithState, HashMap<Bytes, Token>) {
139        let wbtc_token = wbtc();
140        let usdc_token = usdc();
141
142        let mut tokens = HashMap::new();
143        tokens.insert(wbtc_token.address.clone(), wbtc_token.clone());
144        tokens.insert(usdc_token.address.clone(), usdc_token.clone());
145
146        let mut state_attributes = HashMap::new();
147        state_attributes.insert(
148            "bids".to_string(),
149            "[[65000.0, 1.5], [64950.0, 2.0], [64900.0, 0.5]]"
150                .as_bytes()
151                .to_vec()
152                .into(),
153        );
154        state_attributes.insert(
155            "asks".to_string(),
156            "[[65100.0, 1.0], [65150.0, 2.5], [65200.0, 1.5]]"
157                .as_bytes()
158                .to_vec()
159                .into(),
160        );
161
162        let snapshot = ComponentWithState {
163            state: ResponseProtocolState {
164                attributes: state_attributes,
165                component_id: "bebop_wbtc_usdc".to_string(),
166                balances: HashMap::new(),
167            },
168            component: ProtocolComponent {
169                id: "bebop_wbtc_usdc".to_string(),
170                protocol_system: "bebop".to_string(),
171                protocol_type_name: "bebop".to_string(),
172                chain: Chain::Ethereum,
173                tokens: vec![wbtc_token.address.clone(), usdc_token.address.clone()],
174                contract_ids: Vec::new(),
175                static_attributes: HashMap::new(),
176                change: ChangeType::Creation,
177                creation_tx: Bytes::default(),
178                created_at: chrono::NaiveDateTime::default(),
179            },
180            component_tvl: None,
181            entrypoints: Vec::new(),
182        };
183
184        (snapshot, tokens)
185    }
186
187    #[tokio::test]
188    async fn test_try_from_with_header() {
189        env::set_var("BEBOP_USER", "test_user");
190        env::set_var("BEBOP_KEY", "test_key");
191
192        let (snapshot, tokens) = create_test_snapshot();
193
194        let result = BebopState::try_from_with_header(
195            snapshot,
196            TimestampHeader { timestamp: 1703097600u64 },
197            &HashMap::new(),
198            &tokens,
199            &DecoderContext::new(),
200        )
201        .await
202        .expect("create state from snapshot");
203
204        assert_eq!(result.base_token.symbol, "WBTC");
205        assert_eq!(result.quote_token.symbol, "USDC");
206        assert_eq!(result.price_data.last_update_ts, 1703097600);
207        assert_eq!(result.price_data.get_bids().len(), 3);
208        assert_eq!(result.price_data.get_asks().len(), 3);
209        assert_eq!(result.price_data.get_bids()[0], (65000.0, 1.5));
210        assert_eq!(result.price_data.get_asks()[0], (65100.0, 1.0));
211    }
212
213    #[tokio::test]
214    async fn test_try_from_missing_token() {
215        env::set_var("BEBOP_USER", "test_user");
216        env::set_var("BEBOP_KEY", "test_key");
217
218        // Test missing second token (only one token in array)
219        let (mut snapshot, tokens) = create_test_snapshot();
220        snapshot.component.tokens.pop(); // Remove the second token
221        let result = BebopState::try_from_with_header(
222            snapshot,
223            TimestampHeader::default(),
224            &HashMap::new(),
225            &tokens,
226            &DecoderContext::new(),
227        )
228        .await;
229        assert!(result.is_err());
230    }
231
232    #[tokio::test]
233    async fn test_try_from_missing_bids() {
234        env::set_var("BEBOP_USER", "test_user");
235        env::set_var("BEBOP_KEY", "test_key");
236
237        // Should decode an empty array of bids
238        let (mut snapshot, tokens) = create_test_snapshot();
239        snapshot.state.attributes.remove("bids");
240        let result = BebopState::try_from_with_header(
241            snapshot,
242            TimestampHeader::default(),
243            &HashMap::new(),
244            &tokens,
245            &DecoderContext::new(),
246        )
247        .await
248        .expect("create state from snapshot");
249        assert_eq!(result.price_data.bids.len(), 0);
250    }
251
252    #[tokio::test]
253    async fn test_try_from_invalid_json() {
254        env::set_var("BEBOP_USER", "test_user");
255        env::set_var("BEBOP_KEY", "test_key");
256
257        let (mut snapshot, tokens) = create_test_snapshot();
258
259        // Test invalid bids JSON
260        snapshot.state.attributes.insert(
261            "bids".to_string(),
262            "invalid json"
263                .as_bytes()
264                .to_vec()
265                .into(),
266        );
267        let result = BebopState::try_from_with_header(
268            snapshot,
269            TimestampHeader::default(),
270            &HashMap::new(),
271            &tokens,
272            &DecoderContext::new(),
273        )
274        .await;
275        assert!(result.is_err());
276    }
277}