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