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::MissingAttribute(format!(
75                    "Couldn't create LiquoriceClient: {e}"
76                ))
77            })?;
78
79        Ok(LiquoriceState::new(base_token, quote_token, prices_by_mm, client))
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use std::env;
86
87    use tycho_common::models::{
88        protocol::{ProtocolComponent, ProtocolComponentState},
89        Chain, ChangeType,
90    };
91
92    use super::*;
93
94    fn wbtc() -> Token {
95        Token::new(
96            &hex::decode("2260fac5e5542a773aa44fbcfedf7c193bc2c599")
97                .unwrap()
98                .into(),
99            "WBTC",
100            8,
101            0,
102            &[Some(10_000)],
103            Chain::Ethereum,
104            100,
105        )
106    }
107
108    fn usdc() -> Token {
109        Token::new(
110            &hex::decode("a0b86991c6218a76c1d19d4a2e9eb0ce3606eb48")
111                .unwrap()
112                .into(),
113            "USDC",
114            6,
115            0,
116            &[Some(10_000)],
117            Chain::Ethereum,
118            100,
119        )
120    }
121
122    fn create_test_price_levels() -> serde_json::Value {
123        serde_json::json!({
124            "test_market_maker": {
125                "baseToken": "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599",
126                "quoteToken": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
127                "levels": [
128                    ["65000.0", "1.5"],
129                    ["64950.0", "2.0"],
130                    ["65100.0", "0.5"]
131                ],
132                "updatedAt": null
133            }
134        })
135    }
136
137    fn create_test_snapshot() -> (ComponentWithState, HashMap<Bytes, Token>) {
138        let wbtc_token = wbtc();
139        let usdc_token = usdc();
140        let price_levels = create_test_price_levels();
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
148        let prices_json = serde_json::to_vec(&price_levels).expect("Failed to serialize prices");
149        state_attributes.insert("prices".to_string(), prices_json.into());
150
151        let snapshot = ComponentWithState {
152            state: ProtocolComponentState {
153                attributes: state_attributes,
154                component_id: "liquorice_wbtc_usdc".to_string(),
155                balances: HashMap::new(),
156            },
157            component: ProtocolComponent {
158                id: "liquorice_wbtc_usdc".to_string(),
159                protocol_system: "liquorice".to_string(),
160                protocol_type_name: "liquorice".to_string(),
161                chain: Chain::Ethereum,
162                tokens: vec![wbtc_token.address.clone(), usdc_token.address.clone()],
163                contract_addresses: Vec::new(),
164                static_attributes: HashMap::new(),
165                change: ChangeType::Creation,
166                creation_tx: Bytes::default(),
167                created_at: chrono::NaiveDateTime::default(),
168            },
169            component_tvl: None,
170            entrypoints: Vec::new(),
171        };
172
173        (snapshot, tokens)
174    }
175
176    #[tokio::test]
177    async fn test_try_from_with_header() {
178        env::set_var("LIQUORICE_USER", "test_solver");
179        env::set_var("LIQUORICE_KEY", "test_key");
180
181        let (snapshot, tokens) = create_test_snapshot();
182
183        let result = LiquoriceState::try_from_with_header(
184            snapshot,
185            TimestampHeader { timestamp: 1703097600u64 },
186            &HashMap::new(),
187            &tokens,
188            &DecoderContext::new(),
189        )
190        .await
191        .expect("create state from snapshot");
192
193        assert_eq!(result.base_token.symbol, "WBTC");
194        assert_eq!(result.quote_token.symbol, "USDC");
195        assert!(result
196            .prices_by_mm
197            .contains_key("test_market_maker"));
198        let mm_price = &result.prices_by_mm["test_market_maker"];
199        assert_eq!(mm_price.levels.len(), 3);
200        assert_eq!(mm_price.levels[0].quantity, 1.5);
201        assert_eq!(mm_price.levels[0].price, 65000.0);
202        assert_eq!(mm_price.levels[1].quantity, 2.0);
203        assert_eq!(mm_price.levels[1].price, 64950.0);
204        assert_eq!(mm_price.levels[2].quantity, 0.5);
205        assert_eq!(mm_price.levels[2].price, 65100.0);
206    }
207
208    #[tokio::test]
209    async fn test_try_from_missing_prices() {
210        env::set_var("LIQUORICE_USER", "test_solver");
211        env::set_var("LIQUORICE_KEY", "test_key");
212
213        let (mut snapshot, tokens) = create_test_snapshot();
214        snapshot
215            .state
216            .attributes
217            .remove("prices");
218
219        let result = LiquoriceState::try_from_with_header(
220            snapshot,
221            TimestampHeader::default(),
222            &HashMap::new(),
223            &tokens,
224            &DecoderContext::new(),
225        )
226        .await
227        .expect("create state with missing prices should default to empty prices");
228
229        assert_eq!(result.base_token.symbol, "WBTC");
230        assert_eq!(result.quote_token.symbol, "USDC");
231        assert!(result.prices_by_mm.is_empty());
232    }
233
234    #[tokio::test]
235    async fn test_try_from_missing_token() {
236        env::set_var("LIQUORICE_USER", "test_solver");
237        env::set_var("LIQUORICE_KEY", "test_key");
238
239        let (mut snapshot, tokens) = create_test_snapshot();
240        snapshot.component.tokens.pop();
241
242        let result = LiquoriceState::try_from_with_header(
243            snapshot,
244            TimestampHeader::default(),
245            &HashMap::new(),
246            &tokens,
247            &DecoderContext::new(),
248        )
249        .await;
250
251        assert!(result.is_err());
252        assert!(matches!(result.unwrap_err(), InvalidSnapshotError::ValueError(_)));
253    }
254
255    #[tokio::test]
256    async fn test_try_from_too_many_tokens() {
257        env::set_var("LIQUORICE_USER", "test_solver");
258        env::set_var("LIQUORICE_KEY", "test_key");
259
260        let (mut snapshot, mut tokens) = create_test_snapshot();
261
262        let dai_token = Token::new(
263            &hex::decode("6b175474e89094c44da98b954eedeac495271d0f")
264                .unwrap()
265                .into(),
266            "DAI",
267            18,
268            0,
269            &[Some(10_000)],
270            Chain::Ethereum,
271            100,
272        );
273
274        tokens.insert(dai_token.address.clone(), dai_token.clone());
275        snapshot
276            .component
277            .tokens
278            .push(dai_token.address);
279
280        let result = LiquoriceState::try_from_with_header(
281            snapshot,
282            TimestampHeader::default(),
283            &HashMap::new(),
284            &tokens,
285            &DecoderContext::new(),
286        )
287        .await;
288
289        assert!(result.is_err());
290        assert!(matches!(result.unwrap_err(), InvalidSnapshotError::ValueError(_)));
291    }
292
293    #[tokio::test]
294    async fn test_try_from_invalid_prices_json() {
295        env::set_var("LIQUORICE_USER", "test_solver");
296        env::set_var("LIQUORICE_KEY", "test_key");
297
298        let (mut snapshot, tokens) = create_test_snapshot();
299
300        snapshot.state.attributes.insert(
301            "prices".to_string(),
302            "invalid json"
303                .as_bytes()
304                .to_vec()
305                .into(),
306        );
307
308        let result = LiquoriceState::try_from_with_header(
309            snapshot,
310            TimestampHeader::default(),
311            &HashMap::new(),
312            &tokens,
313            &DecoderContext::new(),
314        )
315        .await;
316
317        assert!(result.is_err());
318        assert!(matches!(result.unwrap_err(), InvalidSnapshotError::ValueError(_)));
319    }
320}