Skip to main content

tycho_simulation/rfq/protocols/liquorice/
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::LiquoriceClientBuilder, models::LiquoriceTokenPairPrice, state::LiquoriceState,
8};
9use crate::{
10    protocol::{
11        errors::InvalidSnapshotError,
12        models::{DecoderContext, TryFromWithBlock},
13    },
14    rfq::{constants::get_liquorice_auth, models::TimestampHeader},
15};
16
17impl TryFromWithBlock<ComponentWithState, TimestampHeader> for LiquoriceState {
18    type Error = InvalidSnapshotError;
19
20    async fn try_from_with_header(
21        snapshot: ComponentWithState,
22        _timestamp_header: TimestampHeader,
23        _account_balances: &HashMap<Bytes, HashMap<Bytes, Bytes>>,
24        all_tokens: &HashMap<Bytes, Token>,
25        _decoder_context: &DecoderContext,
26    ) -> Result<Self, Self::Error> {
27        let state_attrs = snapshot.state.attributes;
28
29        if snapshot.component.tokens.len() != 2 {
30            return Err(InvalidSnapshotError::ValueError(
31                "Component must have 2 tokens (base and quote)".to_string(),
32            ));
33        }
34
35        let base_token_address = &snapshot.component.tokens[0];
36        let quote_token_address = &snapshot.component.tokens[1];
37
38        let base_token = all_tokens
39            .get(base_token_address)
40            .ok_or_else(|| {
41                InvalidSnapshotError::ValueError(format!(
42                    "Base token not found: {base_token_address}"
43                ))
44            })?
45            .clone();
46
47        let quote_token = all_tokens
48            .get(quote_token_address)
49            .ok_or_else(|| {
50                InvalidSnapshotError::ValueError(format!(
51                    "Quote token not found: {quote_token_address}"
52                ))
53            })?
54            .clone();
55
56        let empty_prices_map: Bytes = "{}".as_bytes().to_vec().into();
57        let prices_data = state_attrs
58            .get("prices")
59            .unwrap_or(&empty_prices_map);
60
61        let prices_by_mm: HashMap<String, LiquoriceTokenPairPrice> =
62            serde_json::from_slice(prices_data).map_err(|e| {
63                InvalidSnapshotError::ValueError(format!("Invalid prices JSON: {e}"))
64            })?;
65
66        let auth = get_liquorice_auth().map_err(|e| {
67            InvalidSnapshotError::ValueError(format!("Failed to get Liquorice authentication: {e}"))
68        })?;
69
70        let client = LiquoriceClientBuilder::new(snapshot.component.chain, auth.solver, auth.key)
71            .tokens(HashSet::from([base_token_address.clone(), quote_token_address.clone()]))
72            .build()
73            .map_err(|e| {
74                InvalidSnapshotError::ValueError(format!("Couldn't create LiquoriceClient: {e}"))
75            })?;
76
77        Ok(LiquoriceState::new(base_token, quote_token, prices_by_mm, client))
78    }
79}
80
81#[cfg(test)]
82mod tests {
83    use std::env;
84
85    use tycho_common::models::{
86        protocol::{ProtocolComponent, ProtocolComponentState},
87        Chain, ChangeType,
88    };
89
90    use super::*;
91
92    fn wbtc() -> Token {
93        Token::new(
94            &hex::decode("2260fac5e5542a773aa44fbcfedf7c193bc2c599")
95                .unwrap()
96                .into(),
97            "WBTC",
98            8,
99            0,
100            &[Some(10_000)],
101            Chain::Ethereum,
102            100,
103        )
104    }
105
106    fn usdc() -> Token {
107        Token::new(
108            &hex::decode("a0b86991c6218a76c1d19d4a2e9eb0ce3606eb48")
109                .unwrap()
110                .into(),
111            "USDC",
112            6,
113            0,
114            &[Some(10_000)],
115            Chain::Ethereum,
116            100,
117        )
118    }
119
120    fn create_test_price_levels() -> serde_json::Value {
121        serde_json::json!({
122            "test_market_maker": {
123                "baseToken": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599",
124                "quoteToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
125                "levels": [
126                    ["65000.0", "1.5"],
127                    ["64950.0", "2.0"],
128                    ["65100.0", "0.5"]
129                ],
130                "updatedAt": null
131            }
132        })
133    }
134
135    fn create_test_snapshot() -> (ComponentWithState, HashMap<Bytes, Token>) {
136        let wbtc_token = wbtc();
137        let usdc_token = usdc();
138        let price_levels = create_test_price_levels();
139
140        let mut tokens = HashMap::new();
141        tokens.insert(wbtc_token.address.clone(), wbtc_token.clone());
142        tokens.insert(usdc_token.address.clone(), usdc_token.clone());
143
144        let mut state_attributes = HashMap::new();
145
146        let prices_json = serde_json::to_vec(&price_levels).expect("Failed to serialize prices");
147        state_attributes.insert("prices".to_string(), prices_json.into());
148
149        let snapshot = ComponentWithState {
150            state: ProtocolComponentState {
151                attributes: state_attributes,
152                component_id: "liquorice_wbtc_usdc".to_string(),
153                balances: HashMap::new(),
154            },
155            component: ProtocolComponent {
156                id: "liquorice_wbtc_usdc".to_string(),
157                protocol_system: "liquorice".to_string(),
158                protocol_type_name: "liquorice".to_string(),
159                chain: Chain::Ethereum,
160                tokens: vec![wbtc_token.address.clone(), usdc_token.address.clone()],
161                contract_addresses: Vec::new(),
162                static_attributes: HashMap::new(),
163                change: ChangeType::Creation,
164                creation_tx: Bytes::default(),
165                created_at: chrono::NaiveDateTime::default(),
166            },
167            component_tvl: None,
168            entrypoints: Vec::new(),
169        };
170
171        (snapshot, tokens)
172    }
173
174    #[tokio::test]
175    async fn test_try_from_with_header() {
176        env::set_var("LIQUORICE_USER", "test_solver");
177        env::set_var("LIQUORICE_KEY", "test_key");
178
179        let (snapshot, tokens) = create_test_snapshot();
180
181        let result = LiquoriceState::try_from_with_header(
182            snapshot,
183            TimestampHeader { timestamp: 1703097600u64 },
184            &HashMap::new(),
185            &tokens,
186            &DecoderContext::new(),
187        )
188        .await
189        .expect("create state from snapshot");
190
191        assert_eq!(result.base_token.symbol, "WBTC");
192        assert_eq!(result.quote_token.symbol, "USDC");
193        assert!(result
194            .prices_by_mm
195            .contains_key("test_market_maker"));
196        let mm_price = &result.prices_by_mm["test_market_maker"];
197        assert_eq!(mm_price.levels.len(), 3);
198        assert_eq!(mm_price.levels[0].quantity, 1.5);
199        assert_eq!(mm_price.levels[0].price, 65000.0);
200        assert_eq!(mm_price.levels[1].quantity, 2.0);
201        assert_eq!(mm_price.levels[1].price, 64950.0);
202        assert_eq!(mm_price.levels[2].quantity, 0.5);
203        assert_eq!(mm_price.levels[2].price, 65100.0);
204    }
205
206    #[tokio::test]
207    async fn test_try_from_missing_prices() {
208        env::set_var("LIQUORICE_USER", "test_solver");
209        env::set_var("LIQUORICE_KEY", "test_key");
210
211        let (mut snapshot, tokens) = create_test_snapshot();
212        snapshot
213            .state
214            .attributes
215            .remove("prices");
216
217        let result = LiquoriceState::try_from_with_header(
218            snapshot,
219            TimestampHeader::default(),
220            &HashMap::new(),
221            &tokens,
222            &DecoderContext::new(),
223        )
224        .await
225        .expect("create state with missing prices should default to empty prices");
226
227        assert_eq!(result.base_token.symbol, "WBTC");
228        assert_eq!(result.quote_token.symbol, "USDC");
229        assert!(result.prices_by_mm.is_empty());
230    }
231
232    #[tokio::test]
233    async fn test_try_from_missing_token() {
234        env::set_var("LIQUORICE_USER", "test_solver");
235        env::set_var("LIQUORICE_KEY", "test_key");
236
237        let (mut snapshot, tokens) = create_test_snapshot();
238        snapshot.component.tokens.pop();
239
240        let result = LiquoriceState::try_from_with_header(
241            snapshot,
242            TimestampHeader::default(),
243            &HashMap::new(),
244            &tokens,
245            &DecoderContext::new(),
246        )
247        .await;
248
249        assert!(result.is_err());
250        assert!(matches!(result.unwrap_err(), InvalidSnapshotError::ValueError(_)));
251    }
252
253    #[tokio::test]
254    async fn test_try_from_too_many_tokens() {
255        env::set_var("LIQUORICE_USER", "test_solver");
256        env::set_var("LIQUORICE_KEY", "test_key");
257
258        let (mut snapshot, mut tokens) = create_test_snapshot();
259
260        let dai_token = Token::new(
261            &hex::decode("6b175474e89094c44da98b954eedeac495271d0f")
262                .unwrap()
263                .into(),
264            "DAI",
265            18,
266            0,
267            &[Some(10_000)],
268            Chain::Ethereum,
269            100,
270        );
271
272        tokens.insert(dai_token.address.clone(), dai_token.clone());
273        snapshot
274            .component
275            .tokens
276            .push(dai_token.address);
277
278        let result = LiquoriceState::try_from_with_header(
279            snapshot,
280            TimestampHeader::default(),
281            &HashMap::new(),
282            &tokens,
283            &DecoderContext::new(),
284        )
285        .await;
286
287        assert!(result.is_err());
288        assert!(matches!(result.unwrap_err(), InvalidSnapshotError::ValueError(_)));
289    }
290
291    #[tokio::test]
292    async fn test_try_from_invalid_prices_json() {
293        env::set_var("LIQUORICE_USER", "test_solver");
294        env::set_var("LIQUORICE_KEY", "test_key");
295
296        let (mut snapshot, tokens) = create_test_snapshot();
297
298        snapshot.state.attributes.insert(
299            "prices".to_string(),
300            "invalid json"
301                .as_bytes()
302                .to_vec()
303                .into(),
304        );
305
306        let result = LiquoriceState::try_from_with_header(
307            snapshot,
308            TimestampHeader::default(),
309            &HashMap::new(),
310            &tokens,
311            &DecoderContext::new(),
312        )
313        .await;
314
315        assert!(result.is_err());
316        assert!(matches!(result.unwrap_err(), InvalidSnapshotError::ValueError(_)));
317    }
318}