tycho_simulation/rfq/protocols/liquorice/
decoder.rs1use 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}