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