1use std::{
2 collections::{HashMap, HashSet},
3 str::FromStr,
4 time::SystemTime,
5};
6
7use alloy::primitives::utils::keccak256;
8use async_trait::async_trait;
9use futures::stream::BoxStream;
10use num_bigint::BigUint;
11use reqwest::Client;
12use tokio::time::{interval, timeout, Duration};
13use tracing::{debug, error, info, warn};
14use tycho_common::{
15 models::{protocol::GetAmountOutParams, Chain},
16 simulation::indicatively_priced::SignedQuote,
17 Bytes,
18};
19
20use crate::{
21 evm::protocol::u256_num::biguint_to_u256,
22 rfq::{
23 client::RFQClient,
24 errors::RFQError,
25 models::TimestampHeader,
26 protocols::liquorice::models::{
27 LiquoricePriceLevelsResponse, LiquoriceQuoteRequest, LiquoriceQuoteResponse,
28 LiquoriceTokenPairPrice,
29 },
30 },
31 tycho_client::feed::synchronizer::{ComponentWithState, Snapshot, StateSyncMessage},
32 tycho_common::models::protocol::{ProtocolComponent, ProtocolComponentState},
33};
34
35#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
36pub struct LiquoriceClient {
37 chain: Chain,
38 price_levels_endpoint: String,
39 quote_endpoint: String,
40 tokens: HashSet<Bytes>,
42 tvl: f64,
44 #[serde(skip_serializing, default)]
46 auth_solver: String,
47 #[serde(skip_serializing, default)]
49 auth_key: String,
50 quote_tokens: HashSet<Bytes>,
51 poll_time: Duration,
52 quote_timeout: Duration,
53 quote_expiry_secs: u64,
54}
55
56impl LiquoriceClient {
57 pub const PROTOCOL_SYSTEM: &'static str = "rfq:liquorice";
58
59 #[allow(clippy::too_many_arguments)]
60 pub fn new(
61 chain: Chain,
62 tokens: HashSet<Bytes>,
63 tvl: f64,
64 quote_tokens: HashSet<Bytes>,
65 auth_solver: String,
66 auth_key: String,
67 poll_time: Duration,
68 quote_timeout: Duration,
69 quote_expiry_secs: u64,
70 ) -> Result<Self, RFQError> {
71 Ok(Self {
72 chain,
73 price_levels_endpoint: "https://api.liquorice.tech/v1/solver/price-levels".to_string(),
74 quote_endpoint: "https://api.liquorice.tech/v1/solver/rfq".to_string(),
75 tokens,
76 tvl,
77 auth_solver,
78 auth_key,
79 quote_tokens,
80 poll_time,
81 quote_timeout,
82 quote_expiry_secs,
83 })
84 }
85
86 fn normalize_tvl(
87 &self,
88 raw_tvl: f64,
89 quote_token: Bytes,
90 prices_by_mm: &HashMap<String, Vec<LiquoriceTokenPairPrice>>,
91 ) -> Result<f64, RFQError> {
92 if self.quote_tokens.contains("e_token) {
93 return Ok(raw_tvl);
94 }
95
96 for approved_quote_token in &self.quote_tokens {
97 for token_prices in prices_by_mm.values() {
98 for token_price in token_prices {
99 if token_price.base_token == quote_token &&
100 token_price.quote_token == *approved_quote_token
101 {
102 if let Some(price) = token_price.get_price_for_amount(1.0) {
103 return Ok(raw_tvl * price);
104 }
105 }
106 }
107 }
108 }
109
110 Ok(0.0)
111 }
112
113 fn create_component_with_state(
114 &self,
115 component_id: String,
116 tokens: Vec<Bytes>,
117 prices_by_mm: &HashMap<String, LiquoriceTokenPairPrice>,
118 tvl: f64,
119 ) -> ComponentWithState {
120 let protocol_component = ProtocolComponent {
121 id: component_id.clone(),
122 protocol_system: Self::PROTOCOL_SYSTEM.to_string(),
123 protocol_type_name: "liquorice_pool".to_string(),
124 chain: self.chain,
125 tokens,
126 contract_addresses: vec![],
127 ..Default::default()
128 };
129
130 let mut attributes = HashMap::new();
131
132 let prices_json = serde_json::to_string(&prices_by_mm).unwrap_or_default();
133 attributes.insert("prices".to_string(), prices_json.as_bytes().to_vec().into());
134
135 ComponentWithState {
136 state: ProtocolComponentState::new(&component_id, attributes, HashMap::new()),
137 component: protocol_component,
138 component_tvl: Some(tvl),
139 entrypoints: vec![],
140 }
141 }
142
143 fn process_quote_response(
144 quote_response: LiquoriceQuoteResponse,
145 params: &GetAmountOutParams,
146 ) -> Result<SignedQuote, RFQError> {
147 if !quote_response.liquidity_available {
148 debug!(quote_response = ?quote_response, "Liquorice quote response indicates no liquidity");
149 return Err(RFQError::QuoteNotFound(format!(
150 "Liquorice quote not found for {} {} ->{}",
151 params.amount_in, params.token_in, params.token_out,
152 )));
153 }
154
155 info!("Received Liquorice quote response with {} levels", quote_response.levels.len());
156
157 let best_level = quote_response
159 .levels
160 .iter()
161 .filter(|level| level.validate(params).is_ok())
162 .filter_map(|level| {
163 BigUint::from_str(&level.quote_token_amount)
164 .ok()
165 .map(|amount| (level, amount))
166 })
167 .max_by(|(_, a), (_, b)| a.cmp(b));
168
169 let (quote_level, _) = best_level.ok_or_else(|| {
170 RFQError::QuoteNotFound(format!(
171 "No valid Liquorice quote levels for {} {} ->{}",
172 params.amount_in, params.token_in, params.token_out,
173 ))
174 })?;
175
176 let mut quote_attributes: HashMap<String, Bytes> = HashMap::new();
177
178 quote_attributes.insert(
180 "calldata".to_string(),
181 Bytes::from(
182 hex::decode(
183 quote_level
184 .tx
185 .data
186 .trim_start_matches("0x"),
187 )
188 .map_err(|e| RFQError::ParsingError(format!("Failed to parse calldata: {e}")))?,
189 ),
190 );
191
192 quote_attributes.insert(
194 "base_token_amount".to_string(),
195 Bytes::from(
196 biguint_to_u256(&BigUint::from_str("e_level.base_token_amount).map_err(
197 |_| {
198 RFQError::ParsingError(format!(
199 "Failed to parse base token amount: {}",
200 quote_level.base_token_amount
201 ))
202 },
203 )?)
204 .to_be_bytes::<32>()
205 .to_vec(),
206 ),
207 );
208
209 if let Some(pf) = "e_level.partial_fill {
211 quote_attributes.insert(
212 "partial_fill_offset".to_string(),
213 Bytes::from(pf.offset.to_be_bytes().to_vec()),
214 );
215 quote_attributes.insert(
216 "min_base_token_amount".to_string(),
217 Bytes::from(
218 biguint_to_u256(&BigUint::from_str(&pf.min_base_token_amount).map_err(
219 |_| {
220 RFQError::ParsingError(format!(
221 "Failed to parse min_base_token_amount: {}",
222 pf.min_base_token_amount
223 ))
224 },
225 )?)
226 .to_be_bytes::<32>()
227 .to_vec(),
228 ),
229 );
230 }
231
232 Ok(SignedQuote {
233 base_token: params.token_in.clone(),
234 quote_token: params.token_out.clone(),
235 amount_in: BigUint::from_str("e_level.base_token_amount).map_err(|_| {
236 RFQError::ParsingError(format!(
237 "Failed to parse amount in string: {}",
238 quote_level.base_token_amount
239 ))
240 })?,
241 amount_out: BigUint::from_str("e_level.quote_token_amount).map_err(|_| {
242 RFQError::ParsingError(format!(
243 "Failed to parse amount out string: {}",
244 quote_level.quote_token_amount
245 ))
246 })?,
247 quote_attributes,
248 })
249 }
250
251 async fn fetch_price_levels(
252 &self,
253 ) -> Result<HashMap<String, Vec<LiquoriceTokenPairPrice>>, RFQError> {
254 let query_params = vec![("chainId", self.chain.id().to_string())];
255
256 let http_client = Client::new();
257 let request = http_client
258 .get(&self.price_levels_endpoint)
259 .query(&query_params)
260 .header("accept", "application/json")
261 .header("solver", &self.auth_solver)
262 .header("authorization", &self.auth_key);
263
264 let response = request
265 .send()
266 .await
267 .map_err(|e| RFQError::ConnectionError(format!("Failed to fetch price levels: {e}")))?;
268
269 if !response.status().is_success() {
270 return Err(RFQError::ConnectionError(format!(
271 "HTTP error {}: {}",
272 response.status(),
273 response
274 .text()
275 .await
276 .unwrap_or_default()
277 )));
278 }
279
280 let price_response: LiquoricePriceLevelsResponse = response.json().await.map_err(|e| {
281 RFQError::ParsingError(format!("Failed to parse price levels response: {e}"))
282 })?;
283
284 Ok(price_response.prices)
285 }
286}
287
288#[async_trait]
289impl RFQClient for LiquoriceClient {
290 fn stream(
291 &self,
292 ) -> BoxStream<'static, Result<(String, StateSyncMessage<TimestampHeader>), RFQError>> {
293 let client = self.clone();
294
295 Box::pin(async_stream::stream! {
296 let mut current_components: HashMap<String, ComponentWithState> = HashMap::new();
297 let mut ticker = interval(client.poll_time);
298
299 info!("Starting Liquorice price levels polling every {} seconds", client.poll_time.as_secs());
300 info!("TVL threshold: {:.2}", client.tvl);
301
302 loop {
303 ticker.tick().await;
304
305 match client.fetch_price_levels().await {
306 Ok(prices_by_mm) => {
307 let mut new_components = HashMap::new();
308
309 struct PricesWithTvl {
311 mm_prices: HashMap<String, LiquoriceTokenPairPrice>,
313 tvl: f64,
315 }
316 let mut pair_mm_prices: HashMap<(Bytes, Bytes), PricesWithTvl> = HashMap::new();
317
318 info!("Fetched price levels from {} market makers", prices_by_mm.len());
319 for (mm_name, token_pair_prices) in prices_by_mm.iter() {
320 for token_pair_price in token_pair_prices {
321 let base_token = &token_pair_price.base_token;
322 let quote_token = &token_pair_price.quote_token;
323
324 if !client.tokens.contains(base_token) || !client.tokens.contains(quote_token) {
325 continue;
326 }
327
328 let tvl = token_pair_price.calculate_tvl();
329 let normalized_tvl = client.normalize_tvl(
330 tvl,
331 token_pair_price.quote_token.clone(),
332 &prices_by_mm,
333 )?;
334
335 if normalized_tvl < client.tvl {
336 info!("Filtering out MM {} for pair {}/{} due to low TVL: {:.2} < {:.2}",
337 mm_name, hex::encode(base_token), hex::encode(quote_token),
338 normalized_tvl, client.tvl);
339 continue;
340 }
341
342 let entry = pair_mm_prices
343 .entry((base_token.clone(), quote_token.clone()))
344 .or_insert_with(|| PricesWithTvl { mm_prices: HashMap::new(), tvl: f64::NEG_INFINITY });
345 entry.tvl = entry.tvl.max(normalized_tvl);
346 entry.mm_prices.insert(mm_name.clone(), token_pair_price.clone());
347 }
348 }
349
350 for ((base_token, quote_token), PricesWithTvl { mm_prices, tvl: component_tvl }) in pair_mm_prices {
351 let pair_str = format!("liquorice_{}/{}", hex::encode(&base_token), hex::encode("e_token));
352 let component_id = format!("{}", keccak256(pair_str.as_bytes()));
353
354 let tokens = vec![base_token, quote_token];
355
356 let component_with_state = client.create_component_with_state(
357 component_id.clone(),
358 tokens,
359 &mm_prices,
360 component_tvl,
361 );
362 new_components.insert(component_id, component_with_state);
363 }
364
365 let removed_components: HashMap<String, ProtocolComponent> = current_components
366 .iter()
367 .filter(|&(id, _)| !new_components.contains_key(id))
368 .map(|(k, v)| (k.clone(), v.component.clone()))
369 .collect();
370
371 current_components = new_components.clone();
372
373 let snapshot = Snapshot {
374 states: new_components,
375 vm_storage: HashMap::new(),
376 };
377 let timestamp = SystemTime::now().duration_since(
378 SystemTime::UNIX_EPOCH
379 ).map_err(
380 |_| RFQError::ParsingError("SystemTime before UNIX EPOCH!".into())
381 )?.as_secs();
382
383 let msg = StateSyncMessage::<TimestampHeader> {
384 header: TimestampHeader { timestamp },
385 snapshots: snapshot,
386 deltas: None,
387 removed_components,
388 };
389
390 yield Ok(("liquorice".to_string(), msg));
391 },
392 Err(e) => {
393 error!("Failed to fetch price levels from Liquorice API: {}", e);
394 continue;
395 }
396 }
397 }
398 })
399 }
400
401 async fn request_binding_quote(
402 &self,
403 params: &GetAmountOutParams,
404 ) -> Result<SignedQuote, RFQError> {
405 let expiry = SystemTime::now()
406 .duration_since(SystemTime::UNIX_EPOCH)
407 .map_err(|_| RFQError::ParsingError("SystemTime before UNIX EPOCH!".into()))?
408 .as_secs() +
409 self.quote_expiry_secs;
410
411 let rfq_id = uuid::Uuid::new_v4().to_string();
412
413 let quote_request = LiquoriceQuoteRequest {
414 chain_id: self.chain.id(),
415 rfq_id: rfq_id.clone(),
416 expiry,
417 base_token: params.token_in.to_string(),
418 quote_token: params.token_out.to_string(),
419 trader: params.receiver.to_string(),
420 effective_trader: Some(params.sender.to_string()),
421 base_token_amount: Some(params.amount_in.to_string()),
422 quote_token_amount: None,
423 };
424
425 debug!(quote_request = ?quote_request, "Sending Liquorice quote request");
426
427 let url = self.quote_endpoint.clone();
428
429 let start_time = std::time::Instant::now();
430 const MAX_RETRIES: u32 = 3;
431 let mut last_error = None;
432
433 for attempt in 0..MAX_RETRIES {
434 let elapsed = start_time.elapsed();
435 if elapsed >= self.quote_timeout {
436 return Err(last_error.unwrap_or_else(|| {
437 RFQError::ConnectionError(format!(
438 "Liquorice quote request timed out after {} seconds",
439 self.quote_timeout.as_secs()
440 ))
441 }));
442 }
443
444 let remaining_time = self.quote_timeout - elapsed;
445
446 let http_client = Client::new();
447 let request = http_client
448 .post(&url)
449 .json("e_request)
450 .header("accept", "application/json")
451 .header("solver", &self.auth_solver)
452 .header("authorization", &self.auth_key);
453
454 let response = match timeout(remaining_time, request.send()).await {
455 Ok(Ok(resp)) => resp,
456 Ok(Err(e)) => {
457 warn!(
458 "Liquorice quote request failed (attempt {}/{}): {}",
459 attempt + 1,
460 MAX_RETRIES,
461 e
462 );
463 last_error = Some(RFQError::ConnectionError(format!(
464 "Failed to send Liquorice quote request: {e}"
465 )));
466 if attempt < MAX_RETRIES - 1 {
467 tokio::time::sleep(Duration::from_millis(100)).await;
468 continue;
469 } else {
470 return Err(last_error.unwrap());
471 }
472 }
473 Err(_) => {
474 return Err(RFQError::ConnectionError(format!(
475 "Liquorice quote request timed out after {} seconds",
476 self.quote_timeout.as_secs()
477 )));
478 }
479 };
480
481 if response.status() != 200 {
482 let err_msg = match response.text().await {
483 Ok(text) => text,
484 Err(e) => {
485 warn!(
486 "Liquorice error response parsing failed (attempt {}/{}): {}",
487 attempt + 1,
488 MAX_RETRIES,
489 e
490 );
491 last_error = Some(RFQError::ParsingError(format!(
492 "Failed to read response text from Liquorice failed request: {e}"
493 )));
494 if attempt < MAX_RETRIES - 1 {
495 tokio::time::sleep(Duration::from_millis(100)).await;
496 continue;
497 } else {
498 return Err(last_error.unwrap());
499 }
500 }
501 };
502 last_error = Some(RFQError::FatalError(format!(
503 "Failed to send Liquorice quote request: {err_msg}",
504 )));
505 if attempt < MAX_RETRIES - 1 {
506 warn!(
507 "Liquorice returned non-200 status (attempt {}/{}): {}",
508 attempt + 1,
509 MAX_RETRIES,
510 err_msg
511 );
512 tokio::time::sleep(Duration::from_millis(100)).await;
513 continue;
514 } else {
515 return Err(last_error.unwrap());
516 }
517 }
518
519 let quote_response = match response
520 .json::<LiquoriceQuoteResponse>()
521 .await
522 {
523 Ok(resp) => resp,
524 Err(e) => {
525 warn!(
526 "Liquorice quote response parsing failed (attempt {}/{}): {}",
527 attempt + 1,
528 MAX_RETRIES,
529 e
530 );
531 last_error = Some(RFQError::ParsingError(format!(
532 "Failed to parse Liquorice quote response: {e}"
533 )));
534 if attempt < MAX_RETRIES - 1 {
535 tokio::time::sleep(Duration::from_millis(100)).await;
536 continue;
537 } else {
538 return Err(last_error.unwrap());
539 }
540 }
541 };
542
543 return Self::process_quote_response(quote_response, params);
544 }
545
546 Err(last_error.unwrap_or_else(|| {
547 RFQError::ConnectionError("Liquorice quote request failed after retries".to_string())
548 }))
549 }
550}
551
552#[cfg(test)]
553mod tests {
554 use std::{str::FromStr, time::Duration};
555
556 use super::*;
557 use crate::rfq::protocols::liquorice::models::{LiquoricePriceLevel, LiquoriceTokenPairPrice};
558
559 #[test]
560 fn test_normalize_tvl_same_quote_token() {
561 let client = create_test_client();
562 let prices = HashMap::new();
563
564 let result = client.normalize_tvl(
565 1000.0,
566 Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(),
567 &prices,
568 );
569 assert!(result.is_ok());
570 assert_eq!(result.unwrap(), 1000.0);
571 }
572
573 #[test]
574 fn test_normalize_tvl_different_quote_token() {
575 let client = create_test_client();
576 let mut prices = HashMap::new();
577 let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
578 let usdc = Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap();
579
580 let eth_usdc_price = LiquoriceTokenPairPrice {
581 base_token: weth.clone(),
582 quote_token: usdc,
583 levels: vec![LiquoricePriceLevel { quantity: 1.0, price: 3000.0 }],
584 updated_at: None,
585 };
586
587 prices.insert("test_mm".to_string(), vec![eth_usdc_price]);
588
589 let result = client.normalize_tvl(2.0, weth, &prices);
590 assert!(result.is_ok());
591 assert_eq!(result.unwrap(), 6000.0);
592 }
593
594 #[test]
595 fn test_normalize_tvl_no_conversion_available() {
596 let client = create_test_client();
597 let prices = HashMap::new();
598 let result = client.normalize_tvl(
599 1000.0,
600 Bytes::from_str("0x1234567890123456789012345678901234567890").unwrap(),
601 &prices,
602 );
603 assert!(result.is_ok());
604 assert_eq!(result.unwrap(), 0.0);
605 }
606
607 fn create_test_client() -> LiquoriceClient {
608 let quote_tokens = HashSet::from([
609 Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(), Bytes::from_str("0xdAC17F958D2ee523a2206206994597C13D831ec7").unwrap(), ]);
612
613 LiquoriceClient::new(
614 Chain::Ethereum,
615 HashSet::new(),
616 1.0,
617 quote_tokens,
618 "test_solver".to_string(),
619 "test_key".to_string(),
620 Duration::from_secs(5),
621 Duration::from_secs(5),
622 300,
623 )
624 .unwrap()
625 }
626
627 async fn create_delayed_response_server(delay_ms: u64) -> std::net::SocketAddr {
628 use tokio::{io::AsyncWriteExt, net::TcpListener};
629
630 let listener = TcpListener::bind("127.0.0.1:0")
631 .await
632 .unwrap();
633 let addr = listener.local_addr().unwrap();
634
635 let json_response = r#"{"rfqId":"test-rfq-id","liquidityAvailable":true,"levels":[{"makerRfqId":"maker-rfq-1","maker":"test-maker","nonce":"0x0000000000000000000000000000000000000000000000000000000000000001","expiry":1707847360,"tx":{"to":"0x71D9750ECF0c5081FAE4E3EDC4253E52024b0B59","data":"0xdeadbeef"},"baseToken":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2","quoteToken":"0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599","baseTokenAmount":"1000000000000000000","quoteTokenAmount":"3329502","partialFill":null,"allowances":[]}]}"#;
636
637 tokio::spawn(async move {
638 while let Ok((mut stream, _)) = listener.accept().await {
639 let json_response_clone = json_response.to_owned();
640 tokio::spawn(async move {
641 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
642 let response = format!(
643 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
644 json_response_clone.len(),
645 json_response_clone
646 );
647 let _ = stream
648 .write_all(response.as_bytes())
649 .await;
650 let _ = stream.flush().await;
651 let _ = stream.shutdown().await;
652 });
653 }
654 });
655
656 tokio::time::sleep(Duration::from_millis(50)).await;
657 addr
658 }
659
660 fn create_test_liquorice_client(
661 quote_endpoint: String,
662 quote_timeout: Duration,
663 ) -> LiquoriceClient {
664 let token_in = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
665 let token_out = Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap();
666
667 LiquoriceClient {
668 chain: Chain::Ethereum,
669 price_levels_endpoint: "http://unused/price-levels".to_string(),
670 quote_endpoint,
671 tokens: HashSet::from([token_in, token_out]),
672 tvl: 10.0,
673 auth_solver: "test_solver".to_string(),
674 auth_key: "test_key".to_string(),
675 quote_tokens: HashSet::new(),
676 poll_time: Duration::from_secs(0),
677 quote_timeout,
678 quote_expiry_secs: 300,
679 }
680 }
681
682 fn make_quote_level(
683 base_token: &str,
684 quote_token: &str,
685 base_token_amount: &str,
686 quote_token_amount: &str,
687 partial_fill: Option<crate::rfq::protocols::liquorice::models::LiquoricePartialFill>,
688 ) -> crate::rfq::protocols::liquorice::models::LiquoriceQuoteLevel {
689 use crate::rfq::protocols::liquorice::models::{LiquoriceQuoteLevel, LiquoriceTx};
690 LiquoriceQuoteLevel {
691 maker_rfq_id: "maker-1".to_string(),
692 maker: "test-maker".to_string(),
693 expiry: 9999999999,
694 tx: LiquoriceTx {
695 to: "0x1111111111111111111111111111111111111111".to_string(),
696 data: "0xdeadbeef".to_string(),
697 },
698 base_token: base_token.to_string(),
699 quote_token: quote_token.to_string(),
700 base_token_amount: base_token_amount.to_string(),
701 quote_token_amount: quote_token_amount.to_string(),
702 partial_fill,
703 }
704 }
705
706 fn make_params(token_in: &str, token_out: &str, amount_in: u64) -> GetAmountOutParams {
707 GetAmountOutParams {
708 amount_in: BigUint::from(amount_in),
709 token_in: Bytes::from_str(token_in).unwrap(),
710 token_out: Bytes::from_str(token_out).unwrap(),
711 sender: Bytes::from_str("0x3333333333333333333333333333333333333333").unwrap(),
712 receiver: Bytes::from_str("0x4444444444444444444444444444444444444444").unwrap(),
713 }
714 }
715
716 #[test]
717 fn test_process_quote_response_no_liquidity() {
718 use crate::rfq::protocols::liquorice::models::LiquoriceQuoteResponse;
719
720 let response = LiquoriceQuoteResponse {
721 rfq_id: "r1".to_string(),
722 liquidity_available: false,
723 levels: vec![],
724 };
725 let params = make_params(
726 "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
727 "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599",
728 1_000_000_000_000_000_000,
729 );
730 let result = LiquoriceClient::process_quote_response(response, ¶ms);
731 assert!(
732 matches!(result, Err(RFQError::QuoteNotFound(_))),
733 "expected QuoteNotFound, got {:?}",
734 result
735 );
736 }
737
738 #[test]
739 fn test_process_quote_response_partial_fill_attributes() {
740 use crate::rfq::protocols::liquorice::models::{
741 LiquoricePartialFill, LiquoriceQuoteResponse,
742 };
743
744 let token_in = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
745 let token_out = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
746 let amount_in = 1_000_000_000_000_000_000u64;
747
748 let level = make_quote_level(
749 token_in,
750 token_out,
751 &amount_in.to_string(),
752 "3329502",
753 Some(LiquoricePartialFill {
754 offset: 68,
755 min_base_token_amount: "500000000000000000".to_string(),
756 }),
757 );
758 let response = LiquoriceQuoteResponse {
759 rfq_id: "r1".to_string(),
760 liquidity_available: true,
761 levels: vec![level],
762 };
763 let params = make_params(token_in, token_out, amount_in);
764
765 let quote = LiquoriceClient::process_quote_response(response, ¶ms).unwrap();
766
767 let offset_bytes = quote.quote_attributes["partial_fill_offset"].clone();
769 assert_eq!(offset_bytes.as_ref(), &68u32.to_be_bytes());
770
771 let min_amount_bytes = quote.quote_attributes["min_base_token_amount"].clone();
773 assert_eq!(min_amount_bytes.len(), 32);
774 let expected_min = BigUint::from(500_000_000_000_000_000u64);
775 let actual_min = BigUint::from_bytes_be(min_amount_bytes.as_ref());
776 assert_eq!(actual_min, expected_min);
777 }
778
779 #[test]
780 fn test_process_quote_response_selects_best_valid_level() {
781 use crate::rfq::protocols::liquorice::models::LiquoriceQuoteResponse;
782
783 let token_in = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
784 let token_out = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
785 let amount_in = 1_000_000_000_000_000_000u64;
786
787 let invalid_level = make_quote_level(token_in, token_out, "999", "9999999", None);
791 let lower_level =
792 make_quote_level(token_in, token_out, &amount_in.to_string(), "3000000", None);
793 let best_level =
794 make_quote_level(token_in, token_out, &amount_in.to_string(), "3500000", None);
795
796 let response = LiquoriceQuoteResponse {
797 rfq_id: "r1".to_string(),
798 liquidity_available: true,
799 levels: vec![invalid_level, lower_level, best_level],
800 };
801 let params = make_params(token_in, token_out, amount_in);
802
803 let quote = LiquoriceClient::process_quote_response(response, ¶ms).unwrap();
804 assert_eq!(quote.amount_out, BigUint::from(3_500_000u64));
805 }
806
807 fn create_test_quote_params() -> GetAmountOutParams {
808 let token_in = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
809 let token_out = Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap();
810 let router = Bytes::from_str("0xfD0b31d2E955fA55e3fa641Fe90e08b677188d35").unwrap();
811
812 GetAmountOutParams {
813 amount_in: BigUint::from(1_000000000000000000u64),
814 token_in,
815 token_out,
816 sender: router.clone(),
817 receiver: router,
818 }
819 }
820
821 #[tokio::test]
822 async fn test_liquorice_quote_timeout() {
823 let addr = create_delayed_response_server(500).await;
824
825 let client_short_timeout = create_test_liquorice_client(
826 format!("http://127.0.0.1:{}/rfq", addr.port()),
827 Duration::from_millis(200),
828 );
829 let params = create_test_quote_params();
830
831 let start = std::time::Instant::now();
832 let result = client_short_timeout
833 .request_binding_quote(¶ms)
834 .await;
835 let elapsed = start.elapsed();
836
837 assert!(result.is_err());
838 let err = result.unwrap_err();
839 match err {
840 RFQError::ConnectionError(msg) => {
841 assert!(msg.contains("timed out"), "Expected timeout error, got: {}", msg);
842 }
843 _ => panic!("Expected ConnectionError, got: {:?}", err),
844 }
845 assert!(
846 elapsed.as_millis() >= 200 && elapsed.as_millis() < 400,
847 "Expected timeout around 200ms, got: {:?}",
848 elapsed
849 );
850
851 let client_long_timeout = create_test_liquorice_client(
852 format!("http://127.0.0.1:{}/rfq", addr.port()),
853 Duration::from_secs(1),
854 );
855
856 let result = client_long_timeout
857 .request_binding_quote(¶ms)
858 .await;
859 assert!(result.is_ok(), "Expected success, got: {:?}", result);
860 }
861
862 async fn create_retry_server() -> (std::net::SocketAddr, std::sync::Arc<std::sync::Mutex<u32>>)
863 {
864 use std::sync::{Arc, Mutex};
865
866 use tokio::{io::AsyncWriteExt, net::TcpListener};
867
868 let request_count = Arc::new(Mutex::new(0u32));
869 let request_count_clone = request_count.clone();
870
871 let listener = TcpListener::bind("127.0.0.1:0")
872 .await
873 .unwrap();
874 let addr = listener.local_addr().unwrap();
875
876 let json_response = r#"{"rfqId":"test-rfq-id","liquidityAvailable":true,"levels":[{"makerRfqId":"maker-rfq-1","maker":"test-maker","nonce":"0x0000000000000000000000000000000000000000000000000000000000000001","expiry":1707847360,"tx":{"to":"0x71D9750ECF0c5081FAE4E3EDC4253E52024b0B59","data":"0xdeadbeef"},"baseToken":"0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2","quoteToken":"0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599","baseTokenAmount":"1000000000000000000","quoteTokenAmount":"3329502","partialFill":null,"allowances":[]}]}"#;
877
878 tokio::spawn(async move {
879 while let Ok((mut stream, _)) = listener.accept().await {
880 let count_clone = request_count_clone.clone();
881 let json_response_clone = json_response.to_owned();
882 tokio::spawn(async move {
883 *count_clone.lock().unwrap() += 1;
884 let count = *count_clone.lock().unwrap();
885 println!("Mock server: Received request #{count}");
886
887 if count <= 2 {
888 let response = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 21\r\n\r\nInternal Server Error";
889 let _ = stream
890 .write_all(response.as_bytes())
891 .await;
892 } else {
893 let response = format!(
894 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
895 json_response_clone.len(),
896 json_response_clone
897 );
898 let _ = stream
899 .write_all(response.as_bytes())
900 .await;
901 }
902 let _ = stream.flush().await;
903 let _ = stream.shutdown().await;
904 });
905 }
906 });
907
908 tokio::time::sleep(Duration::from_millis(50)).await;
909 (addr, request_count)
910 }
911
912 #[tokio::test]
913 async fn test_liquorice_quote_retry_on_bad_response() {
914 let (addr, request_count) = create_retry_server().await;
915
916 let client = create_test_liquorice_client(
917 format!("http://127.0.0.1:{}/rfq", addr.port()),
918 Duration::from_secs(5),
919 );
920 let params = create_test_quote_params();
921 let result = client
922 .request_binding_quote(¶ms)
923 .await;
924
925 assert!(result.is_ok(), "Expected success after retries, got: {:?}", result);
926 let quote = result.unwrap();
927
928 assert_eq!(quote.amount_in, BigUint::from(1_000000000000000000u64));
929 assert_eq!(quote.amount_out, BigUint::from(3329502u64));
930
931 let final_count = *request_count.lock().unwrap();
932 assert_eq!(final_count, 3, "Expected 3 requests, got {}", final_count);
933 }
934}