Skip to main content

tycho_simulation/rfq/protocols/liquorice/
client.rs

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 that we want prices for
41    tokens: HashSet<Bytes>,
42    // Min tvl value in the quote token.
43    tvl: f64,
44    // solver header for authentication
45    #[serde(skip_serializing, default)]
46    auth_solver: String,
47    // key header for authentication
48    #[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(&quote_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        // Find the valid level with the largest quote_token_amount
162        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        // calldata (pre-encoded by Liquorice API)
183        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        // base_token_amount as U256 (32 bytes big-endian)
197        quote_attributes.insert(
198            "base_token_amount".to_string(),
199            Bytes::from(
200                biguint_to_u256(&BigUint::from_str(&quote_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        // partial fill info (if present)
214        if let Some(pf) = &quote_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(&quote_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(&quote_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                        // Group qualifying MMs by token pair
314                        struct PricesWithTvl {
315                            // MM name -> price levels for the token pair
316                            mm_prices: HashMap<String, LiquoriceTokenPairPrice>,
317                            // The highest TVL among MMs for this token pair, used as the component's TVL
318                            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(&quote_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(&quote_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(), // USDC
614            Bytes::from_str("0xdAC17F958D2ee523a2206206994597C13D831ec7").unwrap(), // USDT
615        ]);
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, &params);
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, &params).unwrap();
770
771        // partial_fill_offset: 4-byte big-endian encoding of 68
772        let offset_bytes = quote.quote_attributes["partial_fill_offset"].clone();
773        assert_eq!(offset_bytes.as_ref(), &68u32.to_be_bytes());
774
775        // min_base_token_amount: 32-byte big-endian U256 of 500000000000000000
776        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        // One level with wrong base_token_amount (will fail validate) and two
792        // valid levels with different quote_token_amounts; expect the higher one
793        // to be chosen.
794        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, &params).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(&params)
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(&params)
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(&params)
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}