Skip to main content

tycho_simulation/rfq/protocols/liquorice/
client.rs

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 that we want prices for
50    tokens: HashSet<Bytes>,
51    // Min tvl value in the quote token.
52    tvl: f64,
53    // solver header for authentication
54    #[serde(skip_serializing, default)]
55    auth_solver: String,
56    // key header for authentication
57    #[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    /// How often one route may take quotes from Liquorice. Read only by the all-pairs layout.
66    #[serde(default = "LiquoriceClient::default_quote_rule")]
67    quote_rule: QuoteRule,
68    /// Liquorice issues credentials under two schemes: newer accounts use
69    /// `Authorization: Basic base64(solver:key)`, legacy accounts use the
70    /// separate `solver` + `authorization` headers. We start with Basic and
71    /// permanently fall back to the legacy scheme if it returns 401.
72    #[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    /// The name the client's stream tags its messages with. The two layouts use different names,
125    /// so one stream builder can carry a client of each.
126    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    /// Build the `Authorization: Basic <base64(solver:token)>` header value
144    /// required by the Liquorice REST API.
145    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    /// Applies whichever auth scheme is currently active to the request.
151    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    /// Sends a request, retrying once with the legacy auth scheme if the
165    /// server rejects Basic auth. The working scheme is remembered for the
166    /// lifetime of the client.
167    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(&quote_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    /// Every market maker's prices on a pair whose tokens the client requested and whose TVL
241    /// clears the threshold, each with its normalized TVL.
242    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    /// One component per pair in `levels_above_tvl_threshold`, holding every market maker on the
284    /// pair. The component's TVL is the largest maker's.
285    fn pair_components(
286        &self,
287        prices_by_mm: &HashMap<String, Vec<LiquoriceTokenPairPrice>>,
288    ) -> Result<HashMap<String, ComponentWithState>, RFQError> {
289        struct PricesWithTvl {
290            // MM name -> price levels for the token pair
291            mm_prices: HashMap<String, LiquoriceTokenPairPrice>,
292            // The highest TVL among MMs for this token pair, used as the component's TVL
293            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(&quote_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    /// The all-pairs component for one poll: every maker's price levels in
331    /// `levels_above_tvl_threshold`. `None` when there is none.
332    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    /// The best valid level of the response, from `market_maker` alone when one is named.
358    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        // Find the valid level with the largest quote_token_amount
374        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        // calldata (pre-encoded by Liquorice API)
396        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        // base_token_amount as U256 (32 bytes big-endian)
410        quote_attributes.insert(
411            "base_token_amount".to_string(),
412            Bytes::from(
413                biguint_to_u256(&BigUint::from_str(&quote_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        // partial fill info (if present)
427        if let Some(pf) = &quote_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(&quote_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(&quote_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    /// Requests a firm quote. With `market_maker`, takes that maker's level alone.
503    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(&quote_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(), // USDC
780            Bytes::from_str("0xdAC17F958D2ee523a2206206994597C13D831ec7").unwrap(), // USDT
781        ]);
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, &params, 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, &params, None).unwrap();
900
901        // partial_fill_offset: 4-byte big-endian encoding of 68
902        let offset_bytes = quote.quote_attributes["partial_fill_offset"].clone();
903        assert_eq!(offset_bytes.as_ref(), &68u32.to_be_bytes());
904
905        // min_base_token_amount: 32-byte big-endian U256 of 500000000000000000
906        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        // One level with wrong base_token_amount (will fail validate) and two
920        // valid levels with different quote_token_amounts; expect the higher one
921        // to be chosen.
922        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, &params, None).unwrap();
936        assert_eq!(quote.amount_out, BigUint::from(3_500_000u64));
937    }
938
939    /// A level from `test-maker` paying 3000000 and one from `other-maker` paying 3500000.
940    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, &params, 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, &params, 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    /// Each component carries the id and attributes the per-pair state decodes.
1041    #[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(&params)
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(&params)
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(&params)
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}