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