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 =
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}