1use std::{
2 collections::{HashMap, HashSet},
3 str::FromStr,
4 sync::{
5 atomic::{AtomicBool, Ordering},
6 Arc,
7 },
8 time::SystemTime,
9};
10
11use alloy::primitives::utils::keccak256;
12use async_trait::async_trait;
13use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine};
14use futures::stream::BoxStream;
15use num_bigint::BigUint;
16use reqwest::{Client, RequestBuilder, Response, StatusCode};
17use tokio::time::{interval, timeout, Duration};
18use tracing::{debug, error, info, warn};
19use tycho_common::{
20 models::{protocol::GetAmountOutParams, Chain},
21 simulation::indicatively_priced::SignedQuote,
22 Bytes,
23};
24
25use crate::{
26 evm::protocol::u256_num::biguint_to_u256,
27 rfq::{
28 client::RFQClient,
29 errors::RFQError,
30 models::{ComponentLayout, QuoteRule, TimestampHeader},
31 protocols::{
32 component,
33 liquorice::models::{
34 LiquoricePriceLevelsResponse, LiquoriceQuoteRequest, LiquoriceQuoteResponse,
35 LiquoriceTokenPairPrice,
36 },
37 maker_price_levels::{self, MakerLevelsWithTvl, MakerPriceLevels},
38 },
39 },
40 tycho_client::feed::synchronizer::{ComponentWithState, StateSyncMessage},
41 tycho_common::models::protocol::{ProtocolComponent, ProtocolComponentState},
42};
43
44#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
45pub struct LiquoriceClient {
46 chain: Chain,
47 price_levels_endpoint: String,
48 quote_endpoint: String,
49 tokens: HashSet<Bytes>,
51 tvl: f64,
53 #[serde(skip_serializing, default)]
55 auth_solver: String,
56 #[serde(skip_serializing, default)]
58 auth_key: String,
59 quote_tokens: HashSet<Bytes>,
60 poll_time: Duration,
61 quote_timeout: Duration,
62 quote_expiry_secs: u64,
63 #[serde(default)]
64 component_layout: ComponentLayout,
65 #[serde(default = "LiquoriceClient::default_quote_rule")]
67 quote_rule: QuoteRule,
68 #[serde(skip)]
73 use_legacy_auth: Arc<AtomicBool>,
74}
75
76impl LiquoriceClient {
77 pub const PROTOCOL_SYSTEM: &'static str = "rfq:liquorice";
78 pub const DEFAULT_QUOTE_RULE: QuoteRule = QuoteRule::OncePerMaker;
79
80 #[allow(clippy::too_many_arguments)]
81 pub fn new(
82 chain: Chain,
83 tokens: HashSet<Bytes>,
84 tvl: f64,
85 quote_tokens: HashSet<Bytes>,
86 auth_solver: String,
87 auth_key: String,
88 poll_time: Duration,
89 quote_timeout: Duration,
90 quote_expiry_secs: u64,
91 ) -> Result<Self, RFQError> {
92 Ok(Self {
93 chain,
94 price_levels_endpoint: "https://api.liquorice.tech/v1/solver/price-levels".to_string(),
95 quote_endpoint: "https://api.liquorice.tech/v1/solver/rfq".to_string(),
96 tokens,
97 tvl,
98 auth_solver,
99 auth_key,
100 quote_tokens,
101 poll_time,
102 quote_timeout,
103 quote_expiry_secs,
104 component_layout: ComponentLayout::PerPair,
105 quote_rule: Self::DEFAULT_QUOTE_RULE,
106 use_legacy_auth: Arc::new(AtomicBool::new(false)),
107 })
108 }
109
110 fn default_quote_rule() -> QuoteRule {
111 Self::DEFAULT_QUOTE_RULE
112 }
113
114 pub(crate) fn with_component_layout(mut self, component_layout: ComponentLayout) -> Self {
115 self.component_layout = component_layout;
116 self
117 }
118
119 pub(crate) fn with_quote_rule(mut self, quote_rule: QuoteRule) -> Self {
120 self.quote_rule = quote_rule;
121 self
122 }
123
124 fn stream_name(&self) -> &'static str {
127 match self.component_layout {
128 ComponentLayout::PerPair => "liquorice",
129 ComponentLayout::AllPairs => "liquorice_all_pairs",
130 }
131 }
132
133 pub fn quote_rule(&self) -> QuoteRule {
134 self.quote_rule
135 }
136
137 #[cfg(test)]
138 pub(crate) fn with_quote_endpoint(mut self, quote_endpoint: String) -> Self {
139 self.quote_endpoint = quote_endpoint;
140 self
141 }
142
143 fn basic_auth_header(&self) -> String {
146 let credentials = format!("{}:{}", self.auth_solver, self.auth_key);
147 format!("Basic {}", BASE64_STANDARD.encode(credentials.as_bytes()))
148 }
149
150 fn apply_auth(&self, request: RequestBuilder) -> RequestBuilder {
152 if self
153 .use_legacy_auth
154 .load(Ordering::Relaxed)
155 {
156 request
157 .header("solver", &self.auth_solver)
158 .header("authorization", &self.auth_key)
159 } else {
160 request.header("authorization", self.basic_auth_header())
161 }
162 }
163
164 async fn send_authed(
168 &self,
169 build: impl Fn() -> RequestBuilder,
170 ) -> Result<Response, reqwest::Error> {
171 let response = self.apply_auth(build()).send().await?;
172 if response.status() == StatusCode::UNAUTHORIZED &&
173 !self
174 .use_legacy_auth
175 .swap(true, Ordering::Relaxed)
176 {
177 warn!("Liquorice Basic auth rejected (401); retrying with legacy solver/authorization headers");
178 return self.apply_auth(build()).send().await;
179 }
180 Ok(response)
181 }
182
183 fn normalize_tvl(
184 &self,
185 raw_tvl: f64,
186 quote_token: Bytes,
187 prices_by_mm: &HashMap<String, Vec<LiquoriceTokenPairPrice>>,
188 ) -> Result<f64, RFQError> {
189 if self.quote_tokens.contains("e_token) {
190 return Ok(raw_tvl);
191 }
192
193 for approved_quote_token in &self.quote_tokens {
194 for token_prices in prices_by_mm.values() {
195 for token_price in token_prices {
196 if token_price.base_token == quote_token &&
197 token_price.quote_token == *approved_quote_token
198 {
199 if let Some(price) = token_price.get_price_for_amount(1.0) {
200 return Ok(raw_tvl * price);
201 }
202 }
203 }
204 }
205 }
206
207 Ok(0.0)
208 }
209
210 fn create_component_with_state(
211 &self,
212 component_id: String,
213 tokens: Vec<Bytes>,
214 prices_by_mm: &HashMap<String, LiquoriceTokenPairPrice>,
215 tvl: f64,
216 ) -> ComponentWithState {
217 let protocol_component = ProtocolComponent {
218 id: component_id.clone(),
219 protocol_system: Self::PROTOCOL_SYSTEM.to_string(),
220 protocol_type_name: "liquorice_pool".to_string(),
221 chain: self.chain,
222 tokens,
223 contract_addresses: vec![],
224 ..Default::default()
225 };
226
227 let mut attributes = HashMap::new();
228
229 let prices_json = serde_json::to_string(&prices_by_mm).unwrap_or_default();
230 attributes.insert("prices".to_string(), prices_json.as_bytes().to_vec().into());
231
232 ComponentWithState {
233 state: ProtocolComponentState::new(&component_id, attributes, HashMap::new()),
234 component: protocol_component,
235 component_tvl: Some(tvl),
236 entrypoints: vec![],
237 }
238 }
239
240 fn levels_above_tvl_threshold<'a>(
243 &self,
244 prices_by_mm: &'a HashMap<String, Vec<LiquoriceTokenPairPrice>>,
245 ) -> Result<Vec<MakerLevelsWithTvl<'a, LiquoriceTokenPairPrice>>, RFQError> {
246 let mut kept = Vec::new();
247 for (mm_name, token_pair_prices) in prices_by_mm.iter() {
248 for token_pair_price in token_pair_prices {
249 let base_token = &token_pair_price.base_token;
250 let quote_token = &token_pair_price.quote_token;
251
252 if !self.tokens.contains(base_token) || !self.tokens.contains(quote_token) {
253 continue;
254 }
255
256 let normalized_tvl = self.normalize_tvl(
257 token_pair_price.calculate_tvl(),
258 token_pair_price.quote_token.clone(),
259 prices_by_mm,
260 )?;
261
262 if normalized_tvl < self.tvl {
263 info!(
264 "Filtering out MM {} for pair {}/{} due to low TVL: {:.2} < {:.2}",
265 mm_name,
266 hex::encode(base_token),
267 hex::encode(quote_token),
268 normalized_tvl,
269 self.tvl
270 );
271 continue;
272 }
273 kept.push(MakerLevelsWithTvl {
274 market_maker: mm_name,
275 levels: token_pair_price,
276 tvl: normalized_tvl,
277 });
278 }
279 }
280 Ok(kept)
281 }
282
283 fn pair_components(
286 &self,
287 prices_by_mm: &HashMap<String, Vec<LiquoriceTokenPairPrice>>,
288 ) -> Result<HashMap<String, ComponentWithState>, RFQError> {
289 struct PricesWithTvl {
290 mm_prices: HashMap<String, LiquoriceTokenPairPrice>,
292 tvl: f64,
294 }
295 let mut pair_mm_prices: HashMap<(Bytes, Bytes), PricesWithTvl> = HashMap::new();
296 for MakerLevelsWithTvl {
297 market_maker: mm_name,
298 levels: token_pair_price,
299 tvl: normalized_tvl,
300 } in self.levels_above_tvl_threshold(prices_by_mm)?
301 {
302 let entry = pair_mm_prices
303 .entry((token_pair_price.base_token.clone(), token_pair_price.quote_token.clone()))
304 .or_insert_with(|| PricesWithTvl {
305 mm_prices: HashMap::new(),
306 tvl: f64::NEG_INFINITY,
307 });
308 entry.tvl = entry.tvl.max(normalized_tvl);
309 entry
310 .mm_prices
311 .insert(mm_name.to_string(), token_pair_price.clone());
312 }
313
314 let mut new_components = HashMap::new();
315 for ((base_token, quote_token), PricesWithTvl { mm_prices, tvl }) in pair_mm_prices {
316 let pair_str =
317 format!("liquorice_{}/{}", hex::encode(&base_token), hex::encode("e_token));
318 let component_id = format!("{}", keccak256(pair_str.as_bytes()));
319 let component_with_state = self.create_component_with_state(
320 component_id.clone(),
321 vec![base_token, quote_token],
322 &mm_prices,
323 tvl,
324 );
325 new_components.insert(component_id, component_with_state);
326 }
327 Ok(new_components)
328 }
329
330 fn all_pairs_component(
333 &self,
334 prices_by_mm: &HashMap<String, Vec<LiquoriceTokenPairPrice>>,
335 ) -> Result<Option<ComponentWithState>, RFQError> {
336 let mut price_levels = Vec::new();
337 for MakerLevelsWithTvl { market_maker: mm_name, levels: token_pair_price, tvl } in
338 self.levels_above_tvl_threshold(prices_by_mm)?
339 {
340 let maker_levels = MakerPriceLevels {
341 market_maker: mm_name.to_string(),
342 base_token: token_pair_price.base_token.clone(),
343 quote_token: token_pair_price.quote_token.clone(),
344 levels: token_pair_price.levels.clone(),
345 };
346 price_levels.push((maker_levels, tvl));
347 }
348 maker_price_levels::all_pairs_component(
349 Self::PROTOCOL_SYSTEM,
350 "liquorice_pool",
351 self.chain,
352 price_levels,
353 self.quote_rule,
354 )
355 }
356
357 fn process_quote_response(
359 quote_response: LiquoriceQuoteResponse,
360 params: &GetAmountOutParams,
361 market_maker: Option<&str>,
362 ) -> Result<SignedQuote, RFQError> {
363 if !quote_response.liquidity_available {
364 debug!(quote_response = ?quote_response, "Liquorice quote response indicates no liquidity");
365 return Err(RFQError::QuoteNotFound(format!(
366 "Liquorice quote not found for {} {} ->{}",
367 params.amount_in, params.token_in, params.token_out,
368 )));
369 }
370
371 info!("Received Liquorice quote response with {} levels", quote_response.levels.len());
372
373 let best_level = quote_response
375 .levels
376 .iter()
377 .filter(|level| level.validate(params).is_ok())
378 .filter(|level| market_maker.is_none_or(|maker| level.maker == maker))
379 .filter_map(|level| {
380 BigUint::from_str(&level.quote_token_amount)
381 .ok()
382 .map(|amount| (level, amount))
383 })
384 .max_by(|(_, a), (_, b)| a.cmp(b));
385
386 let (quote_level, _) = best_level.ok_or_else(|| {
387 RFQError::QuoteNotFound(format!(
388 "No valid Liquorice quote levels for {} {} ->{}",
389 params.amount_in, params.token_in, params.token_out,
390 ))
391 })?;
392
393 let mut quote_attributes: HashMap<String, Bytes> = HashMap::new();
394
395 quote_attributes.insert(
397 "calldata".to_string(),
398 Bytes::from(
399 hex::decode(
400 quote_level
401 .tx
402 .data
403 .trim_start_matches("0x"),
404 )
405 .map_err(|e| RFQError::ParsingError(format!("Failed to parse calldata: {e}")))?,
406 ),
407 );
408
409 quote_attributes.insert(
411 "base_token_amount".to_string(),
412 Bytes::from(
413 biguint_to_u256(&BigUint::from_str("e_level.base_token_amount).map_err(
414 |_| {
415 RFQError::ParsingError(format!(
416 "Failed to parse base token amount: {}",
417 quote_level.base_token_amount
418 ))
419 },
420 )?)
421 .to_be_bytes::<32>()
422 .to_vec(),
423 ),
424 );
425
426 if let Some(pf) = "e_level.partial_fill {
428 quote_attributes.insert(
429 "partial_fill_offset".to_string(),
430 Bytes::from(pf.offset.to_be_bytes().to_vec()),
431 );
432 quote_attributes.insert(
433 "min_base_token_amount".to_string(),
434 Bytes::from(
435 biguint_to_u256(&BigUint::from_str(&pf.min_base_token_amount).map_err(
436 |_| {
437 RFQError::ParsingError(format!(
438 "Failed to parse min_base_token_amount: {}",
439 pf.min_base_token_amount
440 ))
441 },
442 )?)
443 .to_be_bytes::<32>()
444 .to_vec(),
445 ),
446 );
447 }
448
449 Ok(SignedQuote {
450 base_token: params.token_in.clone(),
451 quote_token: params.token_out.clone(),
452 amount_in: BigUint::from_str("e_level.base_token_amount).map_err(|_| {
453 RFQError::ParsingError(format!(
454 "Failed to parse amount in string: {}",
455 quote_level.base_token_amount
456 ))
457 })?,
458 amount_out: BigUint::from_str("e_level.quote_token_amount).map_err(|_| {
459 RFQError::ParsingError(format!(
460 "Failed to parse amount out string: {}",
461 quote_level.quote_token_amount
462 ))
463 })?,
464 quote_attributes,
465 })
466 }
467
468 async fn fetch_price_levels(
469 &self,
470 ) -> Result<HashMap<String, Vec<LiquoriceTokenPairPrice>>, RFQError> {
471 let query_params = vec![("chainId", self.chain.id().to_string())];
472
473 let http_client = Client::new();
474 let response = self
475 .send_authed(|| {
476 http_client
477 .get(&self.price_levels_endpoint)
478 .query(&query_params)
479 .header("accept", "application/json")
480 })
481 .await
482 .map_err(|e| RFQError::ConnectionError(format!("Failed to fetch price levels: {e}")))?;
483
484 if !response.status().is_success() {
485 return Err(RFQError::ConnectionError(format!(
486 "HTTP error {}: {}",
487 response.status(),
488 response
489 .text()
490 .await
491 .unwrap_or_default()
492 )));
493 }
494
495 let price_response: LiquoricePriceLevelsResponse = response.json().await.map_err(|e| {
496 RFQError::ParsingError(format!("Failed to parse price levels response: {e}"))
497 })?;
498
499 Ok(price_response.prices)
500 }
501
502 pub(crate) async fn request_quote(
504 &self,
505 params: &GetAmountOutParams,
506 market_maker: Option<&str>,
507 ) -> Result<SignedQuote, RFQError> {
508 let expiry = SystemTime::now()
509 .duration_since(SystemTime::UNIX_EPOCH)
510 .map_err(|_| RFQError::ParsingError("SystemTime before UNIX EPOCH!".into()))?
511 .as_secs() +
512 self.quote_expiry_secs;
513
514 let rfq_id = uuid::Uuid::new_v4().to_string();
515
516 let quote_request = LiquoriceQuoteRequest {
517 chain_id: self.chain.id(),
518 rfq_id: rfq_id.clone(),
519 expiry,
520 base_token: params.token_in.to_string(),
521 quote_token: params.token_out.to_string(),
522 trader: params.receiver.to_string(),
523 effective_trader: Some(params.sender.to_string()),
524 base_token_amount: Some(params.amount_in.to_string()),
525 quote_token_amount: None,
526 };
527
528 debug!(quote_request = ?quote_request, "Sending Liquorice quote request");
529
530 let url = self.quote_endpoint.clone();
531
532 let start_time = std::time::Instant::now();
533 const MAX_RETRIES: u32 = 3;
534 let mut last_error = None;
535
536 for attempt in 0..MAX_RETRIES {
537 let elapsed = start_time.elapsed();
538 if elapsed >= self.quote_timeout {
539 return Err(last_error.unwrap_or_else(|| {
540 RFQError::ConnectionError(format!(
541 "Liquorice quote request timed out after {} seconds",
542 self.quote_timeout.as_secs()
543 ))
544 }));
545 }
546
547 let remaining_time = self.quote_timeout - elapsed;
548
549 let http_client = Client::new();
550 let response = match timeout(
551 remaining_time,
552 self.send_authed(|| {
553 http_client
554 .post(&url)
555 .json("e_request)
556 .header("accept", "application/json")
557 }),
558 )
559 .await
560 {
561 Ok(Ok(resp)) => resp,
562 Ok(Err(e)) => {
563 warn!(
564 "Liquorice quote request failed (attempt {}/{}): {}",
565 attempt + 1,
566 MAX_RETRIES,
567 e
568 );
569 last_error = Some(RFQError::ConnectionError(format!(
570 "Failed to send Liquorice quote request: {e}"
571 )));
572 if attempt < MAX_RETRIES - 1 {
573 tokio::time::sleep(Duration::from_millis(100)).await;
574 continue;
575 } else {
576 return Err(last_error.unwrap());
577 }
578 }
579 Err(_) => {
580 return Err(RFQError::ConnectionError(format!(
581 "Liquorice quote request timed out after {} seconds",
582 self.quote_timeout.as_secs()
583 )));
584 }
585 };
586
587 if response.status() != 200 {
588 let err_msg = match response.text().await {
589 Ok(text) => text,
590 Err(e) => {
591 warn!(
592 "Liquorice error response parsing failed (attempt {}/{}): {}",
593 attempt + 1,
594 MAX_RETRIES,
595 e
596 );
597 last_error = Some(RFQError::ParsingError(format!(
598 "Failed to read response text from Liquorice failed request: {e}"
599 )));
600 if attempt < MAX_RETRIES - 1 {
601 tokio::time::sleep(Duration::from_millis(100)).await;
602 continue;
603 } else {
604 return Err(last_error.unwrap());
605 }
606 }
607 };
608 last_error = Some(RFQError::FatalError(format!(
609 "Failed to send Liquorice quote request: {err_msg}",
610 )));
611 if attempt < MAX_RETRIES - 1 {
612 warn!(
613 "Liquorice returned non-200 status (attempt {}/{}): {}",
614 attempt + 1,
615 MAX_RETRIES,
616 err_msg
617 );
618 tokio::time::sleep(Duration::from_millis(100)).await;
619 continue;
620 } else {
621 return Err(last_error.unwrap());
622 }
623 }
624
625 let quote_response = match response
626 .json::<LiquoriceQuoteResponse>()
627 .await
628 {
629 Ok(resp) => resp,
630 Err(e) => {
631 warn!(
632 "Liquorice quote response parsing failed (attempt {}/{}): {}",
633 attempt + 1,
634 MAX_RETRIES,
635 e
636 );
637 last_error = Some(RFQError::ParsingError(format!(
638 "Failed to parse Liquorice quote response: {e}"
639 )));
640 if attempt < MAX_RETRIES - 1 {
641 tokio::time::sleep(Duration::from_millis(100)).await;
642 continue;
643 } else {
644 return Err(last_error.unwrap());
645 }
646 }
647 };
648
649 return Self::process_quote_response(quote_response, params, market_maker);
650 }
651
652 Err(last_error.unwrap_or_else(|| {
653 RFQError::ConnectionError("Liquorice quote request failed after retries".to_string())
654 }))
655 }
656}
657
658#[async_trait]
659impl RFQClient for LiquoriceClient {
660 fn stream(
661 &self,
662 ) -> BoxStream<'static, Result<(String, StateSyncMessage<TimestampHeader>), RFQError>> {
663 let client = self.clone();
664
665 Box::pin(async_stream::stream! {
666 let mut current_components: HashMap<String, ComponentWithState> = HashMap::new();
667 let mut ticker = interval(client.poll_time);
668
669 info!("Starting Liquorice price levels polling every {} seconds", client.poll_time.as_secs());
670 info!("TVL threshold: {:.2}", client.tvl);
671
672 loop {
673 ticker.tick().await;
674
675 let prices_by_mm = match client.fetch_price_levels().await {
676 Ok(prices_by_mm) => prices_by_mm,
677 Err(e) => {
678 error!("Failed to fetch price levels from Liquorice API: {}", e);
679 continue;
680 }
681 };
682 info!("Fetched price levels from {} market makers", prices_by_mm.len());
683 let components = match client.component_layout {
684 ComponentLayout::PerPair => client.pair_components(&prices_by_mm)?,
685 ComponentLayout::AllPairs => match client.all_pairs_component(&prices_by_mm) {
686 Ok(component) => component
687 .into_iter()
688 .map(|component| (component.component.id.clone(), component))
689 .collect(),
690 Err(e) => {
691 error!("Failed to build the Liquorice component: {}", e);
692 continue;
693 }
694 },
695 };
696 let timestamp = component::unix_timestamp()?;
697 let msg = component::poll_message(&mut current_components, components, timestamp);
698 yield Ok((client.stream_name().to_string(), msg));
699 }
700 })
701 }
702
703 async fn request_binding_quote(
704 &self,
705 params: &GetAmountOutParams,
706 ) -> Result<SignedQuote, RFQError> {
707 self.request_quote(params, None).await
708 }
709}
710
711#[cfg(test)]
712mod tests {
713 use std::{str::FromStr, time::Duration};
714
715 use super::*;
716 use crate::rfq::{
717 models::PriceLevel,
718 protocols::{
719 component::{BOOKS_ATTRIBUTE, SWAP_DIRECTIONS_ATTRIBUTE},
720 liquorice::models::{
721 LiquoricePartialFill, LiquoriceQuoteLevel, LiquoriceQuoteResponse,
722 LiquoriceTokenPairPrice, LiquoriceTx,
723 },
724 maker_price_levels::MakerPriceLevels,
725 test_utils::{mock_quote_server, quote_params, LIQUORICE_QUOTE_RESPONSE},
726 },
727 };
728
729 #[test]
730 fn test_normalize_tvl_same_quote_token() {
731 let client = create_test_client();
732 let prices = HashMap::new();
733
734 let result = client.normalize_tvl(
735 1000.0,
736 Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(),
737 &prices,
738 );
739 assert!(result.is_ok());
740 assert_eq!(result.unwrap(), 1000.0);
741 }
742
743 #[test]
744 fn test_normalize_tvl_different_quote_token() {
745 let client = create_test_client();
746 let mut prices = HashMap::new();
747 let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
748 let usdc = Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap();
749
750 let eth_usdc_price = LiquoriceTokenPairPrice {
751 base_token: weth.clone(),
752 quote_token: usdc,
753 levels: vec![PriceLevel { quantity: 1.0, price: 3000.0 }],
754 updated_at: None,
755 };
756
757 prices.insert("test_mm".to_string(), vec![eth_usdc_price]);
758
759 let result = client.normalize_tvl(2.0, weth, &prices);
760 assert!(result.is_ok());
761 assert_eq!(result.unwrap(), 6000.0);
762 }
763
764 #[test]
765 fn test_normalize_tvl_no_conversion_available() {
766 let client = create_test_client();
767 let prices = HashMap::new();
768 let result = client.normalize_tvl(
769 1000.0,
770 Bytes::from_str("0x1234567890123456789012345678901234567890").unwrap(),
771 &prices,
772 );
773 assert!(result.is_ok());
774 assert_eq!(result.unwrap(), 0.0);
775 }
776
777 fn create_test_client() -> LiquoriceClient {
778 let quote_tokens = HashSet::from([
779 Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(), Bytes::from_str("0xdAC17F958D2ee523a2206206994597C13D831ec7").unwrap(), ]);
782
783 LiquoriceClient::new(
784 Chain::Ethereum,
785 HashSet::new(),
786 1.0,
787 quote_tokens,
788 "test_solver".to_string(),
789 "test_key".to_string(),
790 Duration::from_secs(5),
791 Duration::from_secs(5),
792 300,
793 )
794 .unwrap()
795 }
796
797 fn create_test_liquorice_client(
798 quote_endpoint: String,
799 quote_timeout: Duration,
800 ) -> LiquoriceClient {
801 let token_in = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
802 let token_out = Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap();
803
804 LiquoriceClient {
805 chain: Chain::Ethereum,
806 price_levels_endpoint: "http://unused/price-levels".to_string(),
807 quote_endpoint,
808 tokens: HashSet::from([token_in, token_out]),
809 tvl: 10.0,
810 auth_solver: "test_solver".to_string(),
811 auth_key: "test_key".to_string(),
812 quote_tokens: HashSet::new(),
813 poll_time: Duration::from_secs(0),
814 quote_timeout,
815 quote_expiry_secs: 300,
816 component_layout: ComponentLayout::PerPair,
817 quote_rule: QuoteRule::OncePerMaker,
818 use_legacy_auth: Arc::new(AtomicBool::new(false)),
819 }
820 }
821
822 fn make_quote_level(
823 base_token: &str,
824 quote_token: &str,
825 base_token_amount: &str,
826 quote_token_amount: &str,
827 partial_fill: Option<LiquoricePartialFill>,
828 ) -> LiquoriceQuoteLevel {
829 LiquoriceQuoteLevel {
830 maker_rfq_id: "maker-1".to_string(),
831 maker: "test-maker".to_string(),
832 expiry: 9999999999,
833 tx: LiquoriceTx {
834 to: "0x1111111111111111111111111111111111111111".to_string(),
835 data: "0xdeadbeef".to_string(),
836 },
837 base_token: base_token.to_string(),
838 quote_token: quote_token.to_string(),
839 base_token_amount: base_token_amount.to_string(),
840 quote_token_amount: quote_token_amount.to_string(),
841 partial_fill,
842 }
843 }
844
845 fn make_params(token_in: &str, token_out: &str, amount_in: u64) -> GetAmountOutParams {
846 GetAmountOutParams {
847 amount_in: BigUint::from(amount_in),
848 token_in: Bytes::from_str(token_in).unwrap(),
849 token_out: Bytes::from_str(token_out).unwrap(),
850 sender: Bytes::from_str("0x3333333333333333333333333333333333333333").unwrap(),
851 receiver: Bytes::from_str("0x4444444444444444444444444444444444444444").unwrap(),
852 origin: None,
853 }
854 }
855
856 #[test]
857 fn test_process_quote_response_no_liquidity() {
858 let response = LiquoriceQuoteResponse {
859 rfq_id: "r1".to_string(),
860 liquidity_available: false,
861 levels: vec![],
862 };
863 let params = make_params(
864 "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
865 "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599",
866 1_000_000_000_000_000_000,
867 );
868 let result = LiquoriceClient::process_quote_response(response, ¶ms, None);
869 assert!(
870 matches!(result, Err(RFQError::QuoteNotFound(_))),
871 "expected QuoteNotFound, got {:?}",
872 result
873 );
874 }
875
876 #[test]
877 fn test_process_quote_response_partial_fill_attributes() {
878 let token_in = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
879 let token_out = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
880 let amount_in = 1_000_000_000_000_000_000u64;
881
882 let level = make_quote_level(
883 token_in,
884 token_out,
885 &amount_in.to_string(),
886 "3329502",
887 Some(LiquoricePartialFill {
888 offset: 68,
889 min_base_token_amount: "500000000000000000".to_string(),
890 }),
891 );
892 let response = LiquoriceQuoteResponse {
893 rfq_id: "r1".to_string(),
894 liquidity_available: true,
895 levels: vec![level],
896 };
897 let params = make_params(token_in, token_out, amount_in);
898
899 let quote = LiquoriceClient::process_quote_response(response, ¶ms, None).unwrap();
900
901 let offset_bytes = quote.quote_attributes["partial_fill_offset"].clone();
903 assert_eq!(offset_bytes.as_ref(), &68u32.to_be_bytes());
904
905 let min_amount_bytes = quote.quote_attributes["min_base_token_amount"].clone();
907 assert_eq!(min_amount_bytes.len(), 32);
908 let expected_min = BigUint::from(500_000_000_000_000_000u64);
909 let actual_min = BigUint::from_bytes_be(min_amount_bytes.as_ref());
910 assert_eq!(actual_min, expected_min);
911 }
912
913 #[test]
914 fn test_process_quote_response_selects_best_valid_level() {
915 let token_in = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
916 let token_out = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
917 let amount_in = 1_000_000_000_000_000_000u64;
918
919 let invalid_level = make_quote_level(token_in, token_out, "999", "9999999", None);
923 let lower_level =
924 make_quote_level(token_in, token_out, &amount_in.to_string(), "3000000", None);
925 let best_level =
926 make_quote_level(token_in, token_out, &amount_in.to_string(), "3500000", None);
927
928 let response = LiquoriceQuoteResponse {
929 rfq_id: "r1".to_string(),
930 liquidity_available: true,
931 levels: vec![invalid_level, lower_level, best_level],
932 };
933 let params = make_params(token_in, token_out, amount_in);
934
935 let quote = LiquoriceClient::process_quote_response(response, ¶ms, None).unwrap();
936 assert_eq!(quote.amount_out, BigUint::from(3_500_000u64));
937 }
938
939 fn two_maker_response() -> (LiquoriceQuoteResponse, GetAmountOutParams) {
941 let token_in = "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2";
942 let token_out = "0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599";
943 let amount_in = 1_000_000_000_000_000_000u64;
944 let mut other_maker =
945 make_quote_level(token_in, token_out, &amount_in.to_string(), "3500000", None);
946 other_maker.maker = "other-maker".to_string();
947 let named_maker =
948 make_quote_level(token_in, token_out, &amount_in.to_string(), "3000000", None);
949 let response = LiquoriceQuoteResponse {
950 rfq_id: "r1".to_string(),
951 liquidity_available: true,
952 levels: vec![other_maker, named_maker],
953 };
954 (response, make_params(token_in, token_out, amount_in))
955 }
956
957 #[test]
958 fn test_process_quote_response_named_maker() {
959 let (response, params) = two_maker_response();
960 let quote =
961 LiquoriceClient::process_quote_response(response, ¶ms, Some("test-maker")).unwrap();
962 assert_eq!(quote.amount_out, BigUint::from(3_000_000u64), "the named maker's level wins");
963 }
964
965 #[test]
966 fn test_process_quote_response_named_maker_absent() {
967 let (response, params) = two_maker_response();
968 let missing = LiquoriceClient::process_quote_response(response, ¶ms, Some("absent"));
969 assert!(matches!(missing, Err(RFQError::QuoteNotFound(_))));
970 }
971
972 fn levels(base: &Bytes, quote: &Bytes, levels: &[(f64, f64)]) -> LiquoriceTokenPairPrice {
973 LiquoriceTokenPairPrice {
974 base_token: base.clone(),
975 quote_token: quote.clone(),
976 levels: levels
977 .iter()
978 .map(|&(quantity, price)| PriceLevel { quantity, price })
979 .collect(),
980 updated_at: None,
981 }
982 }
983
984 #[test]
985 fn test_all_pairs_component_keeps_books_above_tvl_threshold() {
986 let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
987 let wbtc = Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap();
988 let usdc = Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap();
989 let mut client =
990 create_test_liquorice_client("http://unused/rfq".to_string(), Duration::from_secs(1));
991 client.tokens = HashSet::from([weth.clone(), wbtc.clone(), usdc.clone()]);
992 client.quote_tokens = HashSet::from([usdc.clone()]);
993 client.tvl = 100.0;
994 let prices_by_mm = HashMap::from([
995 (
996 "mm_b".to_string(),
997 vec![
998 levels(&weth, &usdc, &[(1.0, 3000.0)]),
999 levels(&wbtc, &usdc, &[(0.001, 65.0)]),
1000 ],
1001 ),
1002 ("mm_a".to_string(), vec![levels(&weth, &usdc, &[(2.0, 3000.0)])]),
1003 ]);
1004
1005 let component = client
1006 .all_pairs_component(&prices_by_mm)
1007 .unwrap()
1008 .expect("two makers' price levels clear the threshold");
1009
1010 assert_eq!(
1011 component.component.id,
1012 component::component_id(LiquoriceClient::PROTOCOL_SYSTEM, client.chain)
1013 );
1014 assert_eq!(
1015 component.component_tvl,
1016 Some(9000.0),
1017 "the WBTC price levels are below the threshold"
1018 );
1019 let mut expected_tokens = vec![weth.clone(), usdc.clone()];
1020 expected_tokens.sort();
1021 assert_eq!(component.component.tokens, expected_tokens);
1022 let mut expected_directions = weth.to_vec();
1023 expected_directions.extend_from_slice(&usdc);
1024 assert_eq!(
1025 component.component.static_attributes[SWAP_DIRECTIONS_ATTRIBUTE].to_vec(),
1026 expected_directions
1027 );
1028 assert_eq!(
1029 component.component.static_attributes[QuoteRule::ATTRIBUTE].as_ref(),
1030 b"once_per_maker"
1031 );
1032 let price_levels: Vec<MakerPriceLevels> =
1033 serde_json::from_slice(&component.state.attributes[BOOKS_ATTRIBUTE]).unwrap();
1034 assert_eq!(price_levels.len(), 2);
1035 assert_eq!(price_levels[0].market_maker, "mm_a", "price levels are sorted by maker");
1036 assert_eq!(price_levels[0].levels[0].quantity, 2.0);
1037 assert_eq!(price_levels[1].market_maker, "mm_b");
1038 }
1039
1040 #[test]
1042 fn test_pair_components_keep_pairs_above_tvl_threshold() {
1043 let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
1044 let usdc = Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap();
1045 let mut client =
1046 create_test_liquorice_client("http://unused/rfq".to_string(), Duration::from_secs(1));
1047 client.tokens = HashSet::from([weth.clone(), usdc.clone()]);
1048 client.quote_tokens = HashSet::from([usdc.clone()]);
1049 client.tvl = 100.0;
1050 let prices_by_mm = HashMap::from([
1051 ("mm_a".to_string(), vec![levels(&weth, &usdc, &[(2.0, 3000.0)])]),
1052 ("mm_b".to_string(), vec![levels(&weth, &usdc, &[(1.0, 3000.0)])]),
1053 ]);
1054
1055 let components = client
1056 .pair_components(&prices_by_mm)
1057 .unwrap();
1058
1059 let pair_str = format!("liquorice_{}/{}", hex::encode(&weth), hex::encode(&usdc));
1060 let component = &components[&keccak256(pair_str.as_bytes()).to_string()];
1061 assert_eq!(components.len(), 1);
1062 assert_eq!(component.component.tokens, vec![weth, usdc]);
1063 assert_eq!(component.component_tvl, Some(6000.0), "the largest maker's TVL");
1064 let prices: HashMap<String, LiquoriceTokenPairPrice> =
1065 serde_json::from_slice(&component.state.attributes["prices"]).unwrap();
1066 assert_eq!(prices.len(), 2);
1067 }
1068
1069 #[tokio::test]
1070 async fn test_liquorice_quote_timeout() {
1071 let (addr, _) = mock_quote_server(500, LIQUORICE_QUOTE_RESPONSE).await;
1072
1073 let client_short_timeout = create_test_liquorice_client(
1074 format!("http://127.0.0.1:{}/rfq", addr.port()),
1075 Duration::from_millis(200),
1076 );
1077 let params = quote_params();
1078
1079 let start = std::time::Instant::now();
1080 let result = client_short_timeout
1081 .request_binding_quote(¶ms)
1082 .await;
1083 let elapsed = start.elapsed();
1084
1085 assert!(result.is_err());
1086 let err = result.unwrap_err();
1087 match err {
1088 RFQError::ConnectionError(msg) => {
1089 assert!(msg.contains("timed out"), "Expected timeout error, got: {}", msg);
1090 }
1091 _ => panic!("Expected ConnectionError, got: {:?}", err),
1092 }
1093 assert!(
1094 elapsed.as_millis() >= 200 && elapsed.as_millis() < 400,
1095 "Expected timeout around 200ms, got: {:?}",
1096 elapsed
1097 );
1098
1099 let client_long_timeout = create_test_liquorice_client(
1100 format!("http://127.0.0.1:{}/rfq", addr.port()),
1101 Duration::from_secs(1),
1102 );
1103
1104 let result = client_long_timeout
1105 .request_binding_quote(¶ms)
1106 .await;
1107 assert!(result.is_ok(), "Expected success, got: {:?}", result);
1108 }
1109
1110 async fn create_retry_server() -> (std::net::SocketAddr, std::sync::Arc<std::sync::Mutex<u32>>)
1111 {
1112 use std::sync::{Arc, Mutex};
1113
1114 use tokio::{io::AsyncWriteExt, net::TcpListener};
1115
1116 let request_count = Arc::new(Mutex::new(0u32));
1117 let request_count_clone = request_count.clone();
1118
1119 let listener = TcpListener::bind("127.0.0.1:0")
1120 .await
1121 .unwrap();
1122 let addr = listener.local_addr().unwrap();
1123
1124 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":[]}]}"#;
1125
1126 tokio::spawn(async move {
1127 while let Ok((mut stream, _)) = listener.accept().await {
1128 let count_clone = request_count_clone.clone();
1129 let json_response_clone = json_response.to_owned();
1130 tokio::spawn(async move {
1131 *count_clone.lock().unwrap() += 1;
1132 let count = *count_clone.lock().unwrap();
1133 println!("Mock server: Received request #{count}");
1134
1135 if count <= 2 {
1136 let response = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 21\r\n\r\nInternal Server Error";
1137 let _ = stream
1138 .write_all(response.as_bytes())
1139 .await;
1140 } else {
1141 let response = format!(
1142 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1143 json_response_clone.len(),
1144 json_response_clone
1145 );
1146 let _ = stream
1147 .write_all(response.as_bytes())
1148 .await;
1149 }
1150 let _ = stream.flush().await;
1151 let _ = stream.shutdown().await;
1152 });
1153 }
1154 });
1155
1156 tokio::time::sleep(Duration::from_millis(50)).await;
1157 (addr, request_count)
1158 }
1159
1160 #[tokio::test]
1161 async fn test_liquorice_quote_retry_on_bad_response() {
1162 let (addr, request_count) = create_retry_server().await;
1163
1164 let client = create_test_liquorice_client(
1165 format!("http://127.0.0.1:{}/rfq", addr.port()),
1166 Duration::from_secs(5),
1167 );
1168 let params = quote_params();
1169 let result = client
1170 .request_binding_quote(¶ms)
1171 .await;
1172
1173 assert!(result.is_ok(), "Expected success after retries, got: {:?}", result);
1174 let quote = result.unwrap();
1175
1176 assert_eq!(quote.amount_in, BigUint::from(1_000000000000000000u64));
1177 assert_eq!(quote.amount_out, BigUint::from(3329502u64));
1178
1179 let final_count = *request_count.lock().unwrap();
1180 assert_eq!(final_count, 3, "Expected 3 requests, got {}", final_count);
1181 }
1182}