1use std::{
2 collections::{BTreeSet, HashMap, HashSet},
3 str::FromStr,
4 sync::LazyLock,
5 time::SystemTime,
6};
7
8use alloy::primitives::{utils::keccak256, Address};
9use async_trait::async_trait;
10use futures::stream::BoxStream;
11use num_bigint::BigUint;
12use reqwest::Client;
13use serde::{Deserialize, Serialize};
14use tokio::time::{interval, timeout, Duration};
15use tracing::{error, info, warn};
16use tycho_common::{
17 models::{protocol::GetAmountOutParams, Chain},
18 simulation::indicatively_priced::SignedQuote,
19 Bytes,
20};
21
22use super::models::{NativeOrderbookEntry, NativeOrderbookSide, NativePriceData, NativePriceLevel};
23use crate::{
24 rfq::{
25 client::RFQClient,
26 errors::RFQError,
27 models::{ComponentLayout, QuoteRule, TimestampHeader},
28 protocols::{
29 component,
30 native::models::{
31 FirmQuoteRequest, FirmQuoteResponse, NativeApiErrorResponse, NativeSupportedChain,
32 },
33 utils::bytes_to_address,
34 },
35 },
36 tycho_client::feed::synchronizer::{ComponentWithState, StateSyncMessage},
37 tycho_common::models::protocol::{ProtocolComponent, ProtocolComponentState},
38};
39
40static NATIVE_HTTP_CLIENT: LazyLock<Client> = LazyLock::new(Client::new);
41const MAX_QUOTE_ATTEMPTS: u32 = 3;
42const TRANSIENT_RETRY_DELAY: Duration = Duration::from_millis(100);
43const NATIVE_API_RETRY_DELAY: Duration = Duration::from_secs(1);
44const TRADE_RFQT_SELECTOR: [u8; 4] = [0x70, 0x83, 0x52, 0x7c];
46const MIN_TRADE_RFQT_CALLDATA_LEN: usize = 4 + 3 * 32;
47const ACTUAL_SELLER_AMOUNT_OFFSET: usize = 4 + 32;
48const ACTUAL_MIN_OUTPUT_AMOUNT_OFFSET: usize = 4 + 2 * 32;
49
50enum QuoteAttemptError {
51 Retry { error: RFQError, delay: Duration },
52 Fatal(RFQError),
53}
54
55impl QuoteAttemptError {
56 fn into_error(self) -> RFQError {
57 match self {
58 Self::Retry { error, .. } | Self::Fatal(error) => error,
59 }
60 }
61}
62
63#[derive(Default)]
64struct AggregatedLevels {
65 levels: Vec<NativePriceLevel>,
66 minimum: f64,
68}
69
70impl AggregatedLevels {
71 fn extend(&mut self, levels: Vec<NativePriceLevel>, minimum: f64) {
72 self.levels.extend(levels);
73 self.minimum = self.minimum.max(minimum);
74 }
75}
76
77#[derive(Clone, Debug, Serialize, Deserialize)]
78pub struct NativeClient {
79 chain: Chain,
80 endpoint: String,
81 #[serde(skip_serializing, default)]
82 api_key: String,
83 tokens: HashSet<Bytes>,
84 tvl: f64,
85 quote_tokens: HashSet<Bytes>,
86 poll_time: Duration,
87 quote_timeout: Duration,
88 #[serde(default)]
89 component_layout: ComponentLayout,
90}
91
92impl NativeClient {
93 pub const PROTOCOL_SYSTEM: &'static str = "rfq:native";
94 pub const DEFAULT_ENDPOINT: &'static str = "https://v2.api.native.org/swap-api-v2/v1";
95
96 fn classify_api_error(error: &NativeApiErrorResponse) -> QuoteAttemptError {
99 let message = format!("Native API error {}: {}", error.code, error.message);
100 match error.code {
101 301016 | 405030 => QuoteAttemptError::Retry {
103 error: RFQError::QuoteNotFound(message),
104 delay: NATIVE_API_RETRY_DELAY,
105 },
106 201005 => QuoteAttemptError::Retry {
107 error: RFQError::ConnectionError(message),
108 delay: NATIVE_API_RETRY_DELAY,
109 },
110 101010 | 171037 | 171011 | 171015 | 171055 | 101007 => {
114 QuoteAttemptError::Fatal(RFQError::QuoteNotFound(message))
115 }
116 131003 | 131004 | 131011 | 171018 | 171053 | 131005 => {
118 QuoteAttemptError::Fatal(RFQError::InvalidInput(message))
119 }
120 201001 => QuoteAttemptError::Fatal(RFQError::FatalError(message)),
121 _ => QuoteAttemptError::Fatal(RFQError::FatalError(format!(
122 "Unknown Native API error {}: {}",
123 error.code, error.message
124 ))),
125 }
126 }
127
128 pub fn new(
129 chain: Chain,
130 api_key: String,
131 tokens: HashSet<Bytes>,
132 tvl: f64,
133 quote_tokens: HashSet<Bytes>,
134 poll_time: Duration,
135 quote_timeout: Duration,
136 ) -> Result<Self, RFQError> {
137 NativeSupportedChain::try_from(chain).map_err(RFQError::InvalidInput)?;
138 if poll_time.is_zero() {
139 return Err(RFQError::InvalidInput(
140 "Native polling interval must be greater than zero".to_string(),
141 ))
142 }
143 Ok(Self {
144 chain,
145 endpoint: Self::DEFAULT_ENDPOINT.to_string(),
146 api_key,
147 tokens,
148 tvl,
149 quote_tokens,
150 poll_time,
151 quote_timeout,
152 component_layout: ComponentLayout::PerPair,
153 })
154 }
155
156 pub(crate) fn with_component_layout(mut self, component_layout: ComponentLayout) -> Self {
157 self.component_layout = component_layout;
158 self
159 }
160
161 fn stream_name(&self) -> &'static str {
164 match self.component_layout {
165 ComponentLayout::PerPair => "native",
166 ComponentLayout::AllPairs => "native_all_pairs",
167 }
168 }
169
170 fn select_tvl_conversion_book<'a>(
171 &self,
172 quote_address: &Bytes,
173 books: &'a HashMap<String, NativePriceData>,
174 ) -> Option<&'a NativePriceData> {
175 books
176 .values()
177 .filter(|candidate| {
178 candidate.base_address == *quote_address &&
181 self.quote_tokens
182 .contains(&candidate.quote_address)
183 })
184 .filter_map(|candidate| {
185 candidate
186 .calculate_tvl(None)
187 .map(|liquidity| (candidate, liquidity))
188 })
189 .max_by(|(candidate_a, liquidity_a), (candidate_b, liquidity_b)| {
190 liquidity_a
191 .total_cmp(liquidity_b)
192 .then_with(|| {
195 candidate_b
196 .quote_address
197 .as_ref()
198 .cmp(candidate_a.quote_address.as_ref())
199 })
200 })
201 .map(|(candidate, _)| candidate)
202 }
203
204 fn create_component_with_state(
205 &self,
206 component_id: String,
207 tokens: Vec<Bytes>,
208 book: NativePriceData,
209 tvl: f64,
210 ) -> ComponentWithState {
211 let protocol_component = ProtocolComponent {
212 id: component_id.clone(),
213 protocol_system: Self::PROTOCOL_SYSTEM.to_string(),
214 protocol_type_name: "native_relay_pool".to_string(),
215 chain: self.chain,
216 tokens,
217 contract_addresses: vec![],
218 static_attributes: Default::default(),
219 change: Default::default(),
220 creation_tx: Default::default(),
221 created_at: Default::default(),
222 };
223
224 let mut attributes = HashMap::new();
225
226 let book_json = serde_json::to_string(&book).unwrap_or_default();
227 attributes.insert("book".to_string(), book_json.as_bytes().to_vec().into());
228
229 ComponentWithState {
230 state: ProtocolComponentState::new(&component_id, attributes, HashMap::new()),
231 component: protocol_component,
232 component_tvl: Some(tvl),
233 entrypoints: vec![],
234 }
235 }
236
237 fn books_above_tvl_threshold<'a>(
240 &self,
241 books: &'a HashMap<String, NativePriceData>,
242 ) -> Vec<(&'a str, &'a NativePriceData, f64)> {
243 let mut kept = Vec::new();
244
245 for (component_id, book) in books {
246 if !self.tokens.contains(&book.base_address) ||
249 !self
250 .tokens
251 .contains(&book.quote_address)
252 {
253 continue;
254 }
255
256 let quote_price_data = if self
257 .quote_tokens
258 .contains(&book.quote_address)
259 {
260 None
261 } else {
262 self.select_tvl_conversion_book(&book.quote_address, books)
266 };
267
268 if !self
269 .quote_tokens
270 .contains(&book.quote_address) &&
271 quote_price_data.is_none()
272 {
273 continue;
274 }
275
276 let Some(incoming_tvl) = book.calculate_tvl(quote_price_data) else {
277 warn!("Skipping Native Relay market {component_id} because its TVL is unavailable or non-finite");
278 continue;
279 };
280
281 if incoming_tvl < self.tvl {
282 info!(
283 "Filtering out Native Relay market {} due to low TVL: {:.2} < {:.2}",
284 component_id, incoming_tvl, self.tvl
285 );
286 continue;
287 }
288
289 kept.push((component_id.as_str(), book, incoming_tvl));
290 }
291 kept
292 }
293
294 fn pair_components(
296 &self,
297 books: &HashMap<String, NativePriceData>,
298 ) -> HashMap<String, ComponentWithState> {
299 let mut new_components = HashMap::new();
300 for (component_id, book, tvl) in self.books_above_tvl_threshold(books) {
301 let tokens = vec![book.base_address.clone(), book.quote_address.clone()];
302 let component_with_state = self.create_component_with_state(
303 component_id.to_string(),
304 tokens,
305 book.clone(),
306 tvl,
307 );
308 new_components.insert(component_id.to_string(), component_with_state);
309 }
310 new_components
311 }
312
313 fn all_pairs_component(
317 &self,
318 grouped_books: &HashMap<String, NativePriceData>,
319 ) -> Result<Option<ComponentWithState>, RFQError> {
320 let mut books = Vec::new();
321 let mut tvl = 0.0;
322 for (_, book, book_tvl) in self.books_above_tvl_threshold(grouped_books) {
323 if book.bids.is_empty() && book.asks.is_empty() {
324 continue;
325 }
326 tvl += book_tvl;
327 books.push(book.clone());
328 }
329 if books.is_empty() {
330 return Ok(None);
331 }
332 books.sort_by(|a, b| {
333 (&a.base_address, &a.quote_address).cmp(&(&b.base_address, &b.quote_address))
334 });
335
336 let mut swap_directions = BTreeSet::new();
337 for book in &books {
338 if !book.bids.is_empty() {
339 swap_directions.insert((book.base_address.clone(), book.quote_address.clone()));
340 }
341 if !book.asks.is_empty() {
342 swap_directions.insert((book.quote_address.clone(), book.base_address.clone()));
343 }
344 }
345 let component = component::all_pairs_component(
346 Self::PROTOCOL_SYSTEM,
347 "native_relay_pool",
348 self.chain,
349 &swap_directions,
350 &books,
351 tvl,
352 QuoteRule::OncePerVenue,
353 )?;
354 Ok(Some(component))
355 }
356
357 async fn fetch_orderbook(&self) -> Result<Vec<NativeOrderbookEntry>, RFQError> {
358 let chain = NativeSupportedChain::try_from(self.chain).map_err(RFQError::InvalidInput)?;
359 let response = NATIVE_HTTP_CLIENT
360 .get(format!("{}/orderbook", self.endpoint))
361 .query(&[("chain", chain.as_str()), ("showNative", "0x0")])
364 .header("accept", "application/json")
365 .header("apikey", &self.api_key)
366 .send()
367 .await
368 .map_err(|e| RFQError::ConnectionError(e.to_string()))?;
369
370 let status = response.status();
371 let response_body = response
372 .bytes()
373 .await
374 .map_err(|e| RFQError::ConnectionError(e.to_string()))?;
375
376 if let Ok(api_error) = serde_json::from_slice::<NativeApiErrorResponse>(&response_body) {
378 return Err(Self::classify_api_error(&api_error).into_error());
379 }
380
381 if !status.is_success() {
382 return Err(RFQError::ConnectionError(format!(
383 "Native Relay orderbook HTTP error {}: {}",
384 status,
385 String::from_utf8_lossy(&response_body)
386 )));
387 }
388
389 serde_json::from_slice(&response_body).map_err(|e| {
390 RFQError::ParsingError(format!("Failed to parse Native Relay orderbook: {e}"))
391 })
392 }
393
394 fn group_orderbook(
395 &self,
396 entries: Vec<NativeOrderbookEntry>,
397 ) -> HashMap<String, NativePriceData> {
398 let mut entries_by_pair: HashMap<(Bytes, Bytes), Vec<NativeOrderbookEntry>> =
399 HashMap::new();
400
401 for entry in entries {
402 let pair = if entry.base_address.as_ref() <= entry.quote_address.as_ref() {
403 (entry.base_address.clone(), entry.quote_address.clone())
404 } else {
405 (entry.quote_address.clone(), entry.base_address.clone())
406 };
407 entries_by_pair
408 .entry(pair)
409 .or_default()
410 .push(entry);
411 }
412
413 let mut books = HashMap::new();
414 for ((token0, token1), entries) in entries_by_pair {
415 let token0_is_quote = self.quote_tokens.contains(&token0);
420 let token1_is_quote = self.quote_tokens.contains(&token1);
421 let (base_address, quote_address) = match (token0_is_quote, token1_is_quote) {
422 (true, false) => (token1.clone(), token0.clone()),
423 (false, true) => (token0.clone(), token1.clone()),
424 (true, true) | (false, false) => (token0.clone(), token1.clone()),
425 };
426 let mut direct_bids = AggregatedLevels::default();
427 let mut direct_asks = AggregatedLevels::default();
428 let mut mirrored_bids = AggregatedLevels::default();
429 let mut mirrored_asks = AggregatedLevels::default();
430
431 for entry in entries {
436 let levels: Vec<_> = entry
440 .levels
441 .into_iter()
442 .filter(|level| level.quantity != 0.0)
443 .collect();
444 if levels.is_empty() {
445 continue
446 }
447
448 let is_direct =
449 entry.base_address == base_address && entry.quote_address == quote_address;
450 if is_direct {
451 match entry.side {
452 NativeOrderbookSide::Bid => {
453 direct_bids.extend(levels, entry.minimum_in_base)
454 }
455 NativeOrderbookSide::Ask => {
456 direct_asks.extend(levels, entry.minimum_in_base)
457 }
458 }
459 } else {
460 let levels = NativePriceData::invert_price_levels(&levels);
461 match entry.side {
462 NativeOrderbookSide::Bid => {
463 mirrored_asks.extend(levels, entry.minimum_in_base)
464 }
465 NativeOrderbookSide::Ask => {
466 mirrored_bids.extend(levels, entry.minimum_in_base)
467 }
468 }
469 }
470 }
471
472 let (bids, minimum_in_base, minimum_out_quote) = if direct_bids.levels.is_empty() {
476 (mirrored_bids.levels, 0.0, mirrored_bids.minimum)
477 } else {
478 (direct_bids.levels, direct_bids.minimum, 0.0)
479 };
480 let (asks, minimum_in_quote, minimum_out_base) = if direct_asks.levels.is_empty() {
481 (mirrored_asks.levels, mirrored_asks.minimum, 0.0)
482 } else {
483 (direct_asks.levels, 0.0, direct_asks.minimum)
484 };
485 let pair = format!("native_{}/{}", hex::encode(&token0), hex::encode(&token1));
488 let component_id = keccak256(pair.as_bytes()).to_string();
489 books.insert(
490 component_id,
491 NativePriceData {
492 base_address,
493 quote_address,
494 minimum_in_base,
495 minimum_in_quote,
496 minimum_out_base,
497 minimum_out_quote,
498 bids,
499 asks,
500 },
501 );
502 }
503
504 books
505 }
506
507 fn process_quote_response(
508 quote_response: FirmQuoteResponse,
509 params: &GetAmountOutParams,
510 ) -> Result<SignedQuote, RFQError> {
511 if !quote_response.success {
513 return Err(RFQError::QuoteNotFound(format!(
514 "Native Relay quote request failed: {}",
515 quote_response.error_message
516 )));
517 }
518
519 if quote_response.router_version != "6" {
520 return Err(RFQError::ParsingError(format!(
521 "Unexpected Native router version: expected 6, got {}",
522 quote_response.router_version
523 )));
524 }
525
526 let order = quote_response
528 .orders
529 .first()
530 .ok_or_else(|| {
531 RFQError::QuoteNotFound(format!(
532 "No Native Relay orders for {} {} -> {}",
533 params.amount_in, params.token_in, params.token_out,
534 ))
535 })?;
536
537 let seller_token = bytes_to_address(¶ms.token_in)?;
539 let buyer_token = bytes_to_address(¶ms.token_out)?;
540 let order_seller_token = Address::from_str(&order.seller_token).map_err(|e| {
541 RFQError::ParsingError(format!(
542 "Invalid Native seller token {}: {e}",
543 order.seller_token
544 ))
545 })?;
546 let order_buyer_token = Address::from_str(&order.buyer_token).map_err(|e| {
547 RFQError::ParsingError(format!("Invalid Native buyer token {}: {e}", order.buyer_token))
548 })?;
549 if order_seller_token != seller_token || order_buyer_token != buyer_token {
550 return Err(RFQError::ParsingError(format!(
551 "Native Relay quote token mismatch: expected {}/{}, got {}/{}",
552 seller_token, buyer_token, order_seller_token, order_buyer_token
553 )));
554 }
555
556 let receiver = bytes_to_address(¶ms.receiver)?;
557 let order_recipient = Address::from_str(&order.recipient).map_err(|e| {
558 RFQError::ParsingError(format!(
559 "Invalid Native order recipient {}: {e}",
560 order.recipient
561 ))
562 })?;
563 if order_recipient != receiver {
564 return Err(RFQError::ParsingError(format!(
565 "Native Relay quote recipient mismatch: expected {receiver}, got {order_recipient}"
566 )));
567 }
568
569 let now = SystemTime::now()
571 .duration_since(SystemTime::UNIX_EPOCH)
572 .map_err(|_| RFQError::ParsingError("SystemTime before UNIX EPOCH!".to_string()))?
573 .as_secs();
574
575 if order.deadline_timestamp <= now {
576 return Err(RFQError::QuoteNotFound(format!(
577 "Native Relay quote already expired: deadline {} <= now {}",
578 order.deadline_timestamp, now
579 )));
580 }
581
582 let quoted_amount_in = BigUint::from_str("e_response.amount_in).map_err(|_| {
586 RFQError::ParsingError(format!(
587 "Failed to parse amount_in: {}",
588 quote_response.amount_in
589 ))
590 })?;
591 if quoted_amount_in != params.amount_in {
592 return Err(RFQError::ParsingError(format!(
593 "Native Relay quote input amount mismatch: expected {}, got {}",
594 params.amount_in, quoted_amount_in
595 )));
596 }
597
598 let signed_amount_in = BigUint::from_str(&order.seller_token_amount).map_err(|_| {
599 RFQError::ParsingError(format!(
600 "Failed to parse signed seller token amount: {}",
601 order.seller_token_amount
602 ))
603 })?;
604 if signed_amount_in != params.amount_in {
605 return Err(RFQError::ParsingError(format!(
606 "Native Relay signed input amount mismatch: expected {}, got {}",
607 params.amount_in, signed_amount_in
608 )));
609 }
610
611 let quoted_value = BigUint::from_str("e_response.tx_request.value).map_err(|_| {
618 RFQError::ParsingError(format!(
619 "Failed to parse Native txRequest.value: {}",
620 quote_response.tx_request.value
621 ))
622 })?;
623 let expected_value =
624 if seller_token == Address::ZERO { quoted_amount_in.clone() } else { BigUint::ZERO };
625 if quoted_value != expected_value {
626 return Err(RFQError::ParsingError(format!(
627 "Native Relay payable value mismatch: expected {}, got {}",
628 expected_value, quoted_value
629 )));
630 }
631
632 if quote_response
633 .tx_request
634 .calldata
635 .is_empty()
636 {
637 return Err(RFQError::QuoteNotFound(
638 "Native Relay quote did not include calldata".to_string(),
639 ));
640 }
641 let calldata = hex::decode(
643 quote_response
644 .tx_request
645 .calldata
646 .trim_start_matches("0x"),
647 )
648 .map_err(|e| RFQError::ParsingError(format!("Failed to decode calldata: {e}")))?;
649
650 if calldata.len() < MIN_TRADE_RFQT_CALLDATA_LEN {
651 return Err(RFQError::ParsingError(format!(
652 "Native tradeRFQT calldata too short: expected at least {} bytes, got {}",
653 MIN_TRADE_RFQT_CALLDATA_LEN,
654 calldata.len()
655 )));
656 }
657
658 if calldata[..TRADE_RFQT_SELECTOR.len()] != TRADE_RFQT_SELECTOR {
659 return Err(RFQError::ParsingError(format!(
660 "Unexpected Native V6 selector: expected 0x{}, got 0x{}",
661 hex::encode(TRADE_RFQT_SELECTOR),
662 hex::encode(&calldata[..TRADE_RFQT_SELECTOR.len()]),
663 )));
664 }
665
666 if quote_response.amount_in_offset as usize != ACTUAL_SELLER_AMOUNT_OFFSET ||
671 quote_response.amount_out_minimum_offset as usize != ACTUAL_MIN_OUTPUT_AMOUNT_OFFSET
672 {
673 return Err(RFQError::ParsingError(format!(
674 "Unexpected Native V6 override offsets: expected {}/{} but got {}/{}",
675 ACTUAL_SELLER_AMOUNT_OFFSET,
676 ACTUAL_MIN_OUTPUT_AMOUNT_OFFSET,
677 quote_response.amount_in_offset,
678 quote_response.amount_out_minimum_offset,
679 )));
680 }
681
682 if calldata[ACTUAL_SELLER_AMOUNT_OFFSET..ACTUAL_MIN_OUTPUT_AMOUNT_OFFSET]
683 .iter()
684 .any(|byte| *byte != 0)
685 {
686 return Err(RFQError::ParsingError(
687 "Native actualSellerAmount override must be zero".to_string(),
688 ));
689 }
690 if calldata[ACTUAL_MIN_OUTPUT_AMOUNT_OFFSET..MIN_TRADE_RFQT_CALLDATA_LEN]
691 .iter()
692 .any(|byte| *byte != 0)
693 {
694 return Err(RFQError::ParsingError(
695 "Native actualMinOutputAmount override must be zero".to_string(),
696 ));
697 }
698
699 let target = Bytes::from_str("e_response.tx_request.target).map_err(|_| {
700 RFQError::ParsingError(format!(
701 "Failed to parse router target address: {}",
702 quote_response.tx_request.target
703 ))
704 })?;
705
706 let mut quote_attributes: HashMap<String, Bytes> = HashMap::new();
707 quote_attributes.insert("target".to_string(), target);
708 quote_attributes.insert("calldata".to_string(), Bytes::from(calldata));
709 quote_attributes.insert(
710 "deadline_timestamp".to_string(),
711 Bytes::from(
712 order
713 .deadline_timestamp
714 .to_be_bytes()
715 .to_vec(),
716 ),
717 );
718
719 Ok(SignedQuote {
720 base_token: params.token_in.clone(),
721 quote_token: params.token_out.clone(),
722 amount_in: quoted_amount_in,
723 amount_out: BigUint::from_str("e_response.amount_out).map_err(|_| {
724 RFQError::ParsingError(format!(
725 "Failed to parse amount_out: {}",
726 quote_response.amount_out
727 ))
728 })?,
729 quote_attributes,
730 })
731 }
732
733 async fn try_quote(
734 &self,
735 request_data: &FirmQuoteRequest,
736 params: &GetAmountOutParams,
737 ) -> Result<SignedQuote, QuoteAttemptError> {
738 let response = NATIVE_HTTP_CLIENT
739 .get(format!("{}/firm-quote", self.endpoint))
740 .query(request_data)
741 .header("apikey", &self.api_key)
742 .send()
743 .await
744 .map_err(|e| QuoteAttemptError::Retry {
745 error: RFQError::ConnectionError(format!(
746 "Failed to make Native quote request: {e}"
747 )),
748 delay: TRANSIENT_RETRY_DELAY,
749 })?;
750
751 let status = response.status();
752 let response_body = response
753 .bytes()
754 .await
755 .map_err(|e| QuoteAttemptError::Retry {
756 error: RFQError::ConnectionError(format!(
757 "Failed to read Native quote response: {e}"
758 )),
759 delay: TRANSIENT_RETRY_DELAY,
760 })?;
761
762 if let Ok(api_error) = serde_json::from_slice::<NativeApiErrorResponse>(&response_body) {
764 return Err(Self::classify_api_error(&api_error));
765 }
766
767 if !status.is_success() {
768 let response_text = String::from_utf8_lossy(&response_body);
769 if status.is_server_error() {
770 return Err(QuoteAttemptError::Retry {
771 error: RFQError::ConnectionError(format!(
772 "Native quote server error ({status}): {response_text}"
773 )),
774 delay: TRANSIENT_RETRY_DELAY,
775 });
776 }
777
778 return Err(QuoteAttemptError::Fatal(RFQError::ConnectionError(format!(
779 "Unexpected Native quote HTTP response ({status}): {response_text}"
780 ))));
781 }
782
783 let quote_response =
784 serde_json::from_slice::<FirmQuoteResponse>(&response_body).map_err(|e| {
785 QuoteAttemptError::Retry {
786 error: RFQError::ParsingError(format!(
787 "Failed to parse Native quote response: {e}"
788 )),
789 delay: TRANSIENT_RETRY_DELAY,
790 }
791 })?;
792
793 Self::process_quote_response(quote_response, params).map_err(QuoteAttemptError::Fatal)
794 }
795}
796
797#[async_trait]
798impl RFQClient for NativeClient {
799 fn component_layout(&self) -> ComponentLayout {
800 self.component_layout
801 }
802
803 fn stream(
804 &self,
805 ) -> BoxStream<'static, Result<(String, StateSyncMessage<TimestampHeader>), RFQError>> {
806 let client = self.clone();
807
808 Box::pin(async_stream::stream! {
809 let mut current_components: HashMap<String, ComponentWithState> = HashMap::new();
810 let mut ticker = interval(client.poll_time);
811
812 loop {
813 ticker.tick().await;
814
815 let books = match client.fetch_orderbook().await {
819 Ok(entries) => client.group_orderbook(entries),
820 Err(e) => {
821 error!("Failed to fetch Native Relay orderbook: {}", e);
822 continue;
823 }
824 };
825
826 let components = match client.component_layout {
827 ComponentLayout::PerPair => client.pair_components(&books),
828 ComponentLayout::AllPairs => match client.all_pairs_component(&books) {
829 Ok(component) => component
830 .into_iter()
831 .map(|component| (component.component.id.clone(), component))
832 .collect(),
833 Err(e) => {
834 error!("Failed to build the Native Relay component: {}", e);
835 continue;
836 }
837 },
838 };
839 let timestamp = component::unix_timestamp().unwrap_or_default();
840 let msg = component::poll_message(&mut current_components, components, timestamp);
841 yield Ok((client.stream_name().to_string(), msg));
842 }
843 })
844 }
845
846 async fn request_binding_quote(
847 &self,
848 params: &GetAmountOutParams,
849 ) -> Result<SignedQuote, RFQError> {
850 let receiver = bytes_to_address(¶ms.receiver)?;
851 let token_in = bytes_to_address(¶ms.token_in)?;
852 let token_out = bytes_to_address(¶ms.token_out)?;
853
854 let chain = NativeSupportedChain::try_from(self.chain).map_err(RFQError::FatalError)?;
855
856 let request_data = FirmQuoteRequest {
857 src_chain: chain,
858 dst_chain: chain,
859 from_address: receiver.to_string(),
860 amount_wei: params.amount_in.to_string(),
861 token_in: token_in.to_string(),
862 token_out: token_out.to_string(),
863 version: 6,
864 allow_multihop: false,
865 };
866 let mut last_error = None;
867
868 let attempts = async {
869 for attempt in 1..=MAX_QUOTE_ATTEMPTS {
870 match self
871 .try_quote(&request_data, params)
872 .await
873 {
874 Ok(quote) => return Ok(quote),
875 Err(QuoteAttemptError::Fatal(error)) => return Err(error),
876 Err(QuoteAttemptError::Retry { error, delay }) => {
877 warn!(
878 "Native quote attempt {}/{} failed: {}",
879 attempt, MAX_QUOTE_ATTEMPTS, error
880 );
881 last_error = Some(error);
882
883 if attempt < MAX_QUOTE_ATTEMPTS {
884 tokio::time::sleep(delay).await;
885 }
886 }
887 }
888 }
889
890 Err(last_error.take().unwrap_or_else(|| {
891 RFQError::ConnectionError(
892 "Native quote request failed after all attempts".to_string(),
893 )
894 }))
895 };
896
897 let result = timeout(self.quote_timeout, attempts).await;
900 match result {
901 Ok(result) => result,
902 Err(_) => Err(last_error.unwrap_or_else(|| {
903 RFQError::ConnectionError(format!(
904 "Native quote request timed out after {:?}",
905 self.quote_timeout
906 ))
907 })),
908 }
909 }
910}
911
912#[cfg(test)]
913mod tests {
914 use std::{
915 collections::{HashMap, HashSet},
916 str::FromStr,
917 sync::{
918 atomic::{AtomicUsize, Ordering},
919 Arc,
920 },
921 };
922
923 use futures::StreamExt;
924 use rstest::rstest;
925 use tokio::{
926 io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
927 net::TcpListener,
928 };
929 use tycho_common::models::Chain;
930
931 use super::*;
932 use crate::rfq::protocols::{
933 component::{BOOKS_ATTRIBUTE, SWAP_DIRECTIONS_ATTRIBUTE},
934 native::client_builder::NativeClientBuilder,
935 };
936
937 fn successful_quote_json(amount_in: &str) -> serde_json::Value {
938 let calldata = format!("0x7083527c{:064x}{:064x}{:064x}", 0x60u8, 0u8, 0u8);
939 serde_json::json!({
940 "success": true,
941 "orders": [{
942 "pool": "0x1111111111111111111111111111111111111111",
943 "signer": "0x2222222222222222222222222222222222222222",
944 "recipient": "0x4444444444444444444444444444444444444444",
945 "sellerToken": "0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2",
946 "buyerToken": "0xdAC17F958D2ee523a2206206994597C13D831ec7",
947 "effectiveSellerTokenAmount": amount_in,
948 "sellerTokenAmount": amount_in,
949 "buyerTokenAmount": "2",
950 "deadlineTimestamp": u64::MAX,
951 "nonce": 1,
952 "quoteId": "test-quote",
953 "multiHop": false,
954 "signature": "",
955 "externalSwapCalldata": "",
956 "amountOutMinimum": "2",
957 "widgetFee": {
958 "signer": "0x0000000000000000000000000000000000000000",
959 "feeRecipient": "0x0000000000000000000000000000000000000000",
960 "feeRate": 0.0
961 },
962 "widgetFeeSignature": ""
963 }],
964 "widgetFee": {
965 "signer": "0x0000000000000000000000000000000000000000",
966 "feeRecipient": "0x0000000000000000000000000000000000000000",
967 "feeRate": 0.0
968 },
969 "widgetFeeSignature": "",
970 "recipient": "0x4444444444444444444444444444444444444444",
971 "amountIn": amount_in,
972 "amountOut": "2",
973 "amountOutBeforeFee": "2",
974 "fallbackSwapDataArray": null,
975 "tokenTransferFeeOnPercent": 0.0,
976 "txRequest": {
977 "target": "0x4777A6B3A9A889ABfd4C7666Bdd2a7AB633293be",
978 "calldata": calldata,
979 "value": "0"
980 },
981 "source": [6],
982 "errorMessage": "",
983 "router_version": "6",
984 "toWrap": false,
985 "toUnwrap": false,
986 "amountInOffset": 36,
987 "amountOutMinimumOffset": 68
988 })
989 }
990
991 fn successful_quote_response(amount_in: &str) -> FirmQuoteResponse {
992 serde_json::from_value(successful_quote_json(amount_in)).unwrap()
993 }
994
995 fn conversion_book(
996 base_address: Bytes,
997 quote_address: Bytes,
998 quantity: f64,
999 price: f64,
1000 ) -> NativePriceData {
1001 NativePriceData {
1002 base_address,
1003 quote_address,
1004 minimum_in_base: 0.0,
1005 minimum_in_quote: 0.0,
1006 minimum_out_base: 0.0,
1007 minimum_out_quote: 0.0,
1008 bids: vec![NativePriceLevel { quantity, price }],
1009 asks: vec![],
1010 }
1011 }
1012
1013 #[test]
1014 fn test_native_client_serialization() {
1015 let mut tokens = HashSet::new();
1016 tokens.insert(Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap());
1017 tokens.insert(Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap());
1018
1019 let client = NativeClient::new(
1020 Chain::Ethereum,
1021 "test-api-key".to_string(),
1022 tokens,
1023 10.0,
1024 HashSet::new(),
1025 Duration::from_secs(1),
1026 Duration::from_secs(5),
1027 )
1028 .unwrap();
1029
1030 let serialized = serde_json::to_string(&client).unwrap();
1031 let deserialized: NativeClient = serde_json::from_str(&serialized).unwrap();
1032
1033 assert_eq!(deserialized.chain, client.chain);
1034 assert_eq!(deserialized.endpoint, client.endpoint);
1035 assert_eq!(deserialized.tokens, client.tokens);
1036 assert_eq!(deserialized.tvl, client.tvl);
1037 assert!(deserialized.api_key.is_empty());
1038 }
1039
1040 #[rstest]
1041 #[case::ethereum(Chain::Ethereum, "ethereum")]
1042 #[case::bsc(Chain::Bsc, "bsc")]
1043 #[case::arbitrum(Chain::Arbitrum, "arbitrum")]
1044 #[case::base(Chain::Base, "base")]
1045 #[case::robinhood(Chain::Robinhood, "robinhood")]
1046 fn accepts_every_chain_native_serves(#[case] chain: Chain, #[case] native_name: &str) {
1047 let client = NativeClient::new(
1048 chain,
1049 "test-api-key".to_string(),
1050 HashSet::new(),
1051 0.0,
1052 HashSet::new(),
1053 Duration::from_secs(1),
1054 Duration::from_secs(5),
1055 );
1056
1057 assert!(client.is_ok());
1058 assert_eq!(
1059 NativeSupportedChain::try_from(chain)
1060 .unwrap()
1061 .as_str(),
1062 native_name
1063 );
1064 }
1065
1066 #[test]
1067 fn rejects_unsupported_chain_at_construction() {
1068 let result = NativeClient::new(
1069 Chain::Polygon,
1070 "test-api-key".to_string(),
1071 HashSet::new(),
1072 0.0,
1073 HashSet::new(),
1074 Duration::from_secs(1),
1075 Duration::from_secs(5),
1076 );
1077
1078 assert!(matches!(result, Err(RFQError::InvalidInput(_))));
1079 }
1080
1081 #[test]
1082 fn builder_rejects_zero_poll_time() {
1083 let result = NativeClientBuilder::new(Chain::Ethereum, "test-api-key".to_string())
1084 .poll_time(Duration::ZERO)
1085 .build();
1086
1087 assert!(matches!(
1088 result,
1089 Err(RFQError::InvalidInput(message))
1090 if message == "Native polling interval must be greater than zero"
1091 ));
1092 }
1093
1094 #[test]
1095 fn selects_most_liquid_tvl_conversion_book() {
1096 let weth = Bytes::from_str("0x3333333333333333333333333333333333333333").unwrap();
1097 let usdc = Bytes::from_str("0x1111111111111111111111111111111111111111").unwrap();
1098 let usdt = Bytes::from_str("0x2222222222222222222222222222222222222222").unwrap();
1099 let wbtc = Bytes::from_str("0x4444444444444444444444444444444444444444").unwrap();
1100 let unapproved = Bytes::from_str("0x5555555555555555555555555555555555555555").unwrap();
1101 let client = NativeClient::new(
1102 Chain::Ethereum,
1103 "test-api-key".to_string(),
1104 HashSet::from([
1105 weth.clone(),
1106 usdc.clone(),
1107 usdt.clone(),
1108 wbtc.clone(),
1109 unapproved.clone(),
1110 ]),
1111 0.0,
1112 HashSet::from([usdc.clone(), usdt.clone()]),
1113 Duration::from_secs(1),
1114 Duration::from_secs(5),
1115 )
1116 .unwrap();
1117 let books = HashMap::from([
1118 ("usdc".to_string(), conversion_book(weth.clone(), usdc.clone(), 1.0, 100.0)),
1119 ("usdt".to_string(), conversion_book(weth.clone(), usdt.clone(), 2.0, 100.0)),
1120 ("unrelated".to_string(), conversion_book(wbtc, usdc, 1_000.0, 100.0)),
1121 ("unapproved".to_string(), conversion_book(weth.clone(), unapproved, 2_000.0, 100.0)),
1122 ]);
1123
1124 let selected = client
1125 .select_tvl_conversion_book(&weth, &books)
1126 .expect("one conversion book");
1127
1128 assert_eq!(selected.quote_address, usdt);
1129 }
1130
1131 #[test]
1132 fn selects_lower_quote_address_for_equal_tvl_conversion_liquidity() {
1133 let weth = Bytes::from_str("0x3333333333333333333333333333333333333333").unwrap();
1134 let lower_quote = Bytes::from_str("0x1111111111111111111111111111111111111111").unwrap();
1135 let higher_quote = Bytes::from_str("0x2222222222222222222222222222222222222222").unwrap();
1136 let client = NativeClient::new(
1137 Chain::Ethereum,
1138 "test-api-key".to_string(),
1139 HashSet::from([weth.clone(), lower_quote.clone(), higher_quote.clone()]),
1140 0.0,
1141 HashSet::from([lower_quote.clone(), higher_quote.clone()]),
1142 Duration::from_secs(1),
1143 Duration::from_secs(5),
1144 )
1145 .unwrap();
1146 let books = HashMap::from([
1147 ("higher".to_string(), conversion_book(weth.clone(), higher_quote, 2.0, 100.0)),
1148 ("lower".to_string(), conversion_book(weth.clone(), lower_quote.clone(), 1.0, 200.0)),
1149 ]);
1150
1151 let selected = client
1152 .select_tvl_conversion_book(&weth, &books)
1153 .expect("one conversion book");
1154
1155 assert_eq!(selected.quote_address, lower_quote);
1156 }
1157
1158 #[test]
1159 fn creates_per_pair_component_from_relay_orderbook() {
1160 let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
1161 let usdt = Bytes::from_str("0xdac17f958d2ee523a2206206994597c13d831ec7").unwrap();
1162 let client = NativeClient::new(
1163 Chain::Ethereum,
1164 "test-api-key".to_string(),
1165 HashSet::from([weth.clone(), usdt.clone()]),
1166 0.0,
1167 HashSet::from([usdt.clone()]),
1168 Duration::from_secs(1),
1169 Duration::from_secs(5),
1170 )
1171 .unwrap();
1172
1173 let books = client.group_orderbook(vec![
1174 NativeOrderbookEntry {
1175 base_address: weth.clone(),
1176 quote_address: usdt.clone(),
1177 minimum_in_base: 0.0,
1178 side: NativeOrderbookSide::Bid,
1179 levels: vec![NativePriceLevel { quantity: 0.0001, price: 3213.12345 }],
1180 },
1181 NativeOrderbookEntry {
1182 base_address: weth.clone(),
1183 quote_address: usdt.clone(),
1184 minimum_in_base: 0.0,
1185 side: NativeOrderbookSide::Ask,
1186 levels: vec![NativePriceLevel { quantity: 2.0, price: 3214.0 }],
1187 },
1188 NativeOrderbookEntry {
1189 base_address: usdt.clone(),
1190 quote_address: weth.clone(),
1191 minimum_in_base: 100.0,
1192 side: NativeOrderbookSide::Bid,
1193 levels: vec![NativePriceLevel { quantity: 6428.0, price: 1.0 / 3214.0 }],
1194 },
1195 NativeOrderbookEntry {
1196 base_address: usdt.clone(),
1197 quote_address: weth.clone(),
1198 minimum_in_base: 100.0,
1199 side: NativeOrderbookSide::Ask,
1200 levels: vec![NativePriceLevel { quantity: 0.321312345, price: 1.0 / 3213.12345 }],
1201 },
1202 ]);
1203
1204 let (component_id, book) = books
1205 .into_iter()
1206 .next()
1207 .expect("one grouped book");
1208 let component = client.create_component_with_state(
1209 component_id.clone(),
1210 vec![book.base_address.clone(), book.quote_address.clone()],
1211 book.clone(),
1212 book.calculate_tvl(None)
1213 .expect("TVL should be finite"),
1214 );
1215
1216 assert_eq!(component.component.id, component_id);
1217 assert_eq!(component.component.protocol_system, NativeClient::PROTOCOL_SYSTEM);
1218 assert_eq!(component.component.protocol_type_name, "native_relay_pool");
1219 assert_eq!(component.component.tokens, vec![weth, usdt]);
1220 assert_eq!(component.state.component_id, component_id);
1221
1222 let encoded_book = component
1223 .state
1224 .attributes
1225 .get("book")
1226 .expect("book attribute");
1227 let decoded_book: NativePriceData = serde_json::from_slice(encoded_book).unwrap();
1228 assert_eq!(decoded_book.bids.len(), 1);
1229 assert_eq!(decoded_book.asks.len(), 1);
1230 assert_eq!(decoded_book.bids[0].quantity, 0.0001);
1231 assert_eq!(decoded_book.bids[0].price, 3213.12345);
1232 }
1233
1234 #[test]
1235 fn creates_all_pairs_component_from_relay_orderbook() {
1236 let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
1237 let usdt = Bytes::from_str("0xdac17f958d2ee523a2206206994597c13d831ec7").unwrap();
1238 let client = NativeClient::new(
1239 Chain::Ethereum,
1240 "test-api-key".to_string(),
1241 HashSet::from([weth.clone(), usdt.clone()]),
1242 0.0,
1243 HashSet::from([usdt.clone()]),
1244 Duration::from_secs(1),
1245 Duration::from_secs(5),
1246 )
1247 .unwrap();
1248
1249 let books = client.group_orderbook(vec![
1250 NativeOrderbookEntry {
1251 base_address: weth.clone(),
1252 quote_address: usdt.clone(),
1253 minimum_in_base: 0.0,
1254 side: NativeOrderbookSide::Bid,
1255 levels: vec![NativePriceLevel { quantity: 0.0001, price: 3213.12345 }],
1256 },
1257 NativeOrderbookEntry {
1258 base_address: weth.clone(),
1259 quote_address: usdt.clone(),
1260 minimum_in_base: 0.0,
1261 side: NativeOrderbookSide::Ask,
1262 levels: vec![NativePriceLevel { quantity: 2.0, price: 3214.0 }],
1263 },
1264 NativeOrderbookEntry {
1265 base_address: usdt.clone(),
1266 quote_address: weth.clone(),
1267 minimum_in_base: 100.0,
1268 side: NativeOrderbookSide::Bid,
1269 levels: vec![NativePriceLevel { quantity: 6428.0, price: 1.0 / 3214.0 }],
1270 },
1271 NativeOrderbookEntry {
1272 base_address: usdt.clone(),
1273 quote_address: weth.clone(),
1274 minimum_in_base: 100.0,
1275 side: NativeOrderbookSide::Ask,
1276 levels: vec![NativePriceLevel { quantity: 0.321312345, price: 1.0 / 3213.12345 }],
1277 },
1278 ]);
1279
1280 let component = client
1281 .all_pairs_component(&books)
1282 .unwrap()
1283 .expect("the book clears the threshold");
1284
1285 assert_eq!(
1286 component.component.id,
1287 component::component_id(NativeClient::PROTOCOL_SYSTEM, client.chain)
1288 );
1289 assert_eq!(component.component.protocol_system, NativeClient::PROTOCOL_SYSTEM);
1290 assert_eq!(component.component.protocol_type_name, "native_relay_pool");
1291 let mut expected_tokens = vec![weth.clone(), usdt.clone()];
1292 expected_tokens.sort();
1293 assert_eq!(component.component.tokens, expected_tokens);
1294 assert_eq!(
1295 component.state.component_id,
1296 component::component_id(NativeClient::PROTOCOL_SYSTEM, client.chain)
1297 );
1298 assert_eq!(
1299 component.component.static_attributes[SWAP_DIRECTIONS_ATTRIBUTE].len(),
1300 80,
1301 "bids and asks"
1302 );
1303 assert_eq!(
1304 component.component.static_attributes[QuoteRule::ATTRIBUTE].as_ref(),
1305 b"once_per_venue"
1306 );
1307 let decoded_books: Vec<NativePriceData> =
1308 serde_json::from_slice(&component.state.attributes[BOOKS_ATTRIBUTE]).unwrap();
1309 assert_eq!(decoded_books.len(), 1);
1310 assert_eq!(decoded_books[0].bids.len(), 1);
1311 assert_eq!(decoded_books[0].asks.len(), 1);
1312 assert_eq!(decoded_books[0].bids[0].quantity, 0.0001);
1313 assert_eq!(decoded_books[0].bids[0].price, 3213.12345);
1314 }
1315
1316 #[test]
1317 fn uses_stable_component_id_and_direction_when_merging_mirrored_books() {
1318 let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
1319 let usdc = Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap();
1320 let client = NativeClient::new(
1321 Chain::Ethereum,
1322 "test-api-key".to_string(),
1323 HashSet::from([weth.clone(), usdc.clone()]),
1324 0.0,
1325 HashSet::from([usdc.clone()]),
1326 Duration::from_secs(1),
1327 Duration::from_secs(5),
1328 )
1329 .unwrap();
1330 let entries = vec![
1331 NativeOrderbookEntry {
1332 base_address: weth.clone(),
1333 quote_address: usdc.clone(),
1334 minimum_in_base: 100_000_000_000.0,
1335 side: NativeOrderbookSide::Bid,
1336 levels: vec![NativePriceLevel { quantity: 1.0, price: 2_000.0 }],
1337 },
1338 NativeOrderbookEntry {
1339 base_address: weth.clone(),
1340 quote_address: usdc.clone(),
1341 minimum_in_base: 300_000_000_000.0,
1342 side: NativeOrderbookSide::Ask,
1343 levels: vec![NativePriceLevel { quantity: 1.0, price: 2_100.0 }],
1344 },
1345 NativeOrderbookEntry {
1346 base_address: usdc.clone(),
1347 quote_address: weth.clone(),
1348 minimum_in_base: 100.0,
1349 side: NativeOrderbookSide::Bid,
1350 levels: vec![NativePriceLevel { quantity: 2_000.0, price: 0.0005 }],
1351 },
1352 NativeOrderbookEntry {
1353 base_address: usdc.clone(),
1354 quote_address: weth.clone(),
1355 minimum_in_base: 250.0,
1356 side: NativeOrderbookSide::Bid,
1357 levels: vec![NativePriceLevel { quantity: 2_000.0, price: 0.0005 }],
1358 },
1359 NativeOrderbookEntry {
1360 base_address: usdc.clone(),
1361 quote_address: weth.clone(),
1362 minimum_in_base: 400.0,
1363 side: NativeOrderbookSide::Ask,
1364 levels: vec![NativePriceLevel { quantity: 2.0, price: 0.5 }],
1365 },
1366 ];
1367
1368 let forward_only = client.group_orderbook(entries[..2].to_vec());
1369 let reverse_entries = entries[2..].to_vec();
1370 let reverse_only = client.group_orderbook(reverse_entries.clone());
1371 let reversed_reverse_only = client.group_orderbook(
1372 reverse_entries
1373 .into_iter()
1374 .rev()
1375 .collect(),
1376 );
1377 assert_eq!(reverse_only, reversed_reverse_only);
1378 let pair = format!("native_{}/{}", hex::encode(&usdc), hex::encode(&weth));
1379 let component_id = keccak256(pair.as_bytes()).to_string();
1380
1381 let forward_book = forward_only
1382 .get(&component_id)
1383 .expect("forward-only book uses the stable component ID");
1384 assert_eq!(forward_book.base_address, weth);
1385 assert_eq!(forward_book.quote_address, usdc);
1386 assert_eq!(forward_book.minimum_in_base, 100_000_000_000.0);
1387 assert_eq!(forward_book.minimum_in_quote, 0.0);
1388 assert_eq!(forward_book.minimum_out_base, 300_000_000_000.0);
1389 assert_eq!(forward_book.minimum_out_quote, 0.0);
1390
1391 let reverse_book = reverse_only
1392 .get(&component_id)
1393 .expect("reverse-only book uses the stable component ID");
1394 assert_eq!(reverse_book.base_address, weth);
1395 assert_eq!(reverse_book.quote_address, usdc);
1396 assert_eq!(reverse_book.minimum_in_base, 0.0);
1397 assert_eq!(reverse_book.minimum_in_quote, 250.0);
1398 assert_eq!(reverse_book.minimum_out_base, 0.0);
1399 assert_eq!(reverse_book.minimum_out_quote, 400.0);
1400 assert_eq!(reverse_book.bids, vec![NativePriceLevel { quantity: 1.0, price: 2.0 }]);
1401 assert_eq!(
1402 reverse_book.asks,
1403 vec![
1404 NativePriceLevel { quantity: 1.0, price: 2_000.0 },
1405 NativePriceLevel { quantity: 1.0, price: 2_000.0 },
1406 ]
1407 );
1408
1409 let mixed = client.group_orderbook(vec![entries[0].clone(), entries[2].clone()]);
1410 let mixed_book = mixed.get(&component_id).unwrap();
1411 assert_eq!(mixed_book.minimum_in_base, 100_000_000_000.0);
1412 assert_eq!(mixed_book.minimum_in_quote, 100.0);
1413 assert_eq!(mixed_book.minimum_out_base, 0.0);
1414 assert_eq!(mixed_book.minimum_out_quote, 0.0);
1415 assert_eq!(mixed_book.bids, vec![NativePriceLevel { quantity: 1.0, price: 2_000.0 }]);
1416 assert_eq!(mixed_book.asks, vec![NativePriceLevel { quantity: 1.0, price: 2_000.0 }]);
1417
1418 let books = client.group_orderbook(entries.clone());
1419 let reversed_books = client.group_orderbook(entries.into_iter().rev().collect());
1420
1421 assert_eq!(books, reversed_books);
1422 assert_eq!(books.len(), 1);
1423 let book = books.get(&component_id).unwrap();
1424 assert_eq!(book.base_address, weth);
1425 assert_eq!(book.quote_address, usdc);
1426 assert_eq!(book.minimum_in_base, 100_000_000_000.0);
1427 assert_eq!(book.minimum_in_quote, 0.0);
1428 assert_eq!(book.minimum_out_base, 300_000_000_000.0);
1429 assert_eq!(book.minimum_out_quote, 0.0);
1430 assert_eq!(book.bids, vec![NativePriceLevel { quantity: 1.0, price: 2_000.0 }]);
1431 assert_eq!(book.asks, vec![NativePriceLevel { quantity: 1.0, price: 2_100.0 }]);
1432 }
1433
1434 #[test]
1435 fn zero_only_direct_side_does_not_suppress_mirrored_liquidity() {
1436 let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
1437 let usdc = Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap();
1438 let client = NativeClient::new(
1439 Chain::Ethereum,
1440 "test-api-key".to_string(),
1441 HashSet::from([weth.clone(), usdc.clone()]),
1442 0.0,
1443 HashSet::from([usdc.clone()]),
1444 Duration::from_secs(1),
1445 Duration::from_secs(5),
1446 )
1447 .unwrap();
1448
1449 let books = client.group_orderbook(vec![
1450 NativeOrderbookEntry {
1451 base_address: weth.clone(),
1452 quote_address: usdc.clone(),
1453 minimum_in_base: 999.0,
1454 side: NativeOrderbookSide::Bid,
1455 levels: vec![NativePriceLevel { quantity: 0.0, price: 2_000.0 }],
1456 },
1457 NativeOrderbookEntry {
1458 base_address: usdc,
1459 quote_address: weth,
1460 minimum_in_base: 250.0,
1461 side: NativeOrderbookSide::Ask,
1462 levels: vec![NativePriceLevel { quantity: 2.0, price: 0.5 }],
1463 },
1464 ]);
1465
1466 let book = books
1467 .values()
1468 .next()
1469 .expect("one grouped book");
1470 assert_eq!(book.bids, vec![NativePriceLevel { quantity: 1.0, price: 2.0 }]);
1471 assert_eq!(book.minimum_in_base, 0.0);
1472 assert_eq!(book.minimum_out_quote, 250.0);
1473 }
1474
1475 #[rstest]
1476 #[case::token0_only(true, false, true)]
1477 #[case::token1_only(false, true, false)]
1478 #[case::both_tokens(true, true, false)]
1479 #[case::neither_token(false, false, false)]
1480 fn selects_stable_direction_for_quote_token_preferences(
1481 #[case] token0_is_quote: bool,
1482 #[case] token1_is_quote: bool,
1483 #[case] expected_quote_is_token0: bool,
1484 ) {
1485 let token0 = Bytes::from_str("0x1111111111111111111111111111111111111111").unwrap();
1486 let token1 = Bytes::from_str("0x2222222222222222222222222222222222222222").unwrap();
1487 assert!(token0.as_ref() < token1.as_ref());
1488
1489 let mut quote_tokens = HashSet::new();
1490 if token0_is_quote {
1491 quote_tokens.insert(token0.clone());
1492 }
1493 if token1_is_quote {
1494 quote_tokens.insert(token1.clone());
1495 }
1496 let client = NativeClient::new(
1497 Chain::Ethereum,
1498 "test-api-key".to_string(),
1499 HashSet::from([token0.clone(), token1.clone()]),
1500 0.0,
1501 quote_tokens,
1502 Duration::from_secs(1),
1503 Duration::from_secs(5),
1504 )
1505 .unwrap();
1506
1507 let books = client.group_orderbook(vec![NativeOrderbookEntry {
1509 base_address: token1.clone(),
1510 quote_address: token0.clone(),
1511 minimum_in_base: 0.0,
1512 side: NativeOrderbookSide::Bid,
1513 levels: vec![NativePriceLevel { quantity: 1.0, price: 1.0 }],
1514 }]);
1515
1516 let book = books
1517 .values()
1518 .next()
1519 .expect("one grouped book");
1520 let (expected_base, expected_quote) =
1521 if expected_quote_is_token0 { (token1, token0) } else { (token0, token1) };
1522 assert_eq!(book.base_address, expected_base);
1523 assert_eq!(book.quote_address, expected_quote);
1524 }
1525
1526 fn create_test_quote_params() -> GetAmountOutParams {
1527 GetAmountOutParams {
1528 amount_in: BigUint::from(1u64),
1529 token_in: Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(),
1530 token_out: Bytes::from_str("0xdac17f958d2ee523a2206206994597c13d831ec7").unwrap(),
1531 sender: Bytes::from_str("0x3333333333333333333333333333333333333333").unwrap(),
1532 receiver: Bytes::from_str("0x4444444444444444444444444444444444444444").unwrap(),
1533 origin: None,
1534 }
1535 }
1536
1537 #[test]
1538 fn accepts_quote_with_requested_input_amount() {
1539 let params = create_test_quote_params();
1540 let response = successful_quote_response(¶ms.amount_in.to_string());
1541
1542 let quote = NativeClient::process_quote_response(response, ¶ms).unwrap();
1543
1544 assert_eq!(quote.amount_in, params.amount_in);
1545 assert_eq!(
1546 quote
1547 .quote_attributes
1548 .get("deadline_timestamp")
1549 .unwrap()
1550 .as_ref(),
1551 u64::MAX.to_be_bytes()
1552 );
1553 }
1554
1555 #[test]
1556 fn rejects_quote_with_mismatched_input_amount() {
1557 let params = create_test_quote_params();
1558 let mut response = successful_quote_response(¶ms.amount_in.to_string());
1559 response.amount_in = "2".to_string();
1561
1562 let result = NativeClient::process_quote_response(response, ¶ms);
1563
1564 assert!(matches!(
1565 result,
1566 Err(RFQError::ParsingError(message)) if message.contains("Native Relay quote input amount mismatch")
1567 ));
1568 }
1569
1570 #[test]
1571 fn rejects_quote_with_mismatched_signed_input_amount() {
1572 let params = create_test_quote_params();
1573 let mut response = successful_quote_response(¶ms.amount_in.to_string());
1574 response.orders[0].seller_token_amount = "2".to_string();
1575
1576 let result = NativeClient::process_quote_response(response, ¶ms);
1577
1578 assert!(matches!(
1579 result,
1580 Err(RFQError::ParsingError(message)) if message.contains("signed input amount mismatch")
1581 ));
1582 }
1583
1584 #[test]
1585 fn accepts_quote_with_different_effective_input_amount() {
1586 let mut params = create_test_quote_params();
1587 params.amount_in = BigUint::from(100u64);
1588 let mut response = successful_quote_response(¶ms.amount_in.to_string());
1589 response.orders[0].effective_seller_token_amount = "99".to_string();
1590
1591 let quote = NativeClient::process_quote_response(response, ¶ms).unwrap();
1592
1593 assert_eq!(quote.amount_in, params.amount_in);
1594 }
1595
1596 #[rstest]
1597 #[case::seller_token(true)]
1598 #[case::buyer_token(false)]
1599 fn rejects_quote_with_mismatched_token(#[case] mutate_seller_token: bool) {
1600 let params = create_test_quote_params();
1601 let mut response = successful_quote_response(¶ms.amount_in.to_string());
1602 let mismatched_token = "0x5555555555555555555555555555555555555555".to_string();
1603 if mutate_seller_token {
1604 response.orders[0].seller_token = mismatched_token;
1605 } else {
1606 response.orders[0].buyer_token = mismatched_token;
1607 }
1608
1609 let result = NativeClient::process_quote_response(response, ¶ms);
1610
1611 assert!(matches!(
1612 result,
1613 Err(RFQError::ParsingError(message)) if message.contains("quote token mismatch")
1614 ));
1615 }
1616
1617 #[test]
1618 fn rejects_quote_with_mismatched_recipient() {
1619 let params = create_test_quote_params();
1620 let mut response = successful_quote_response(¶ms.amount_in.to_string());
1621 response.orders[0].recipient = "0x5555555555555555555555555555555555555555".to_string();
1622
1623 let result = NativeClient::process_quote_response(response, ¶ms);
1624
1625 assert!(matches!(
1626 result,
1627 Err(RFQError::ParsingError(message)) if message.contains("recipient mismatch")
1628 ));
1629 }
1630
1631 #[test]
1632 fn rejects_quote_without_calldata() {
1633 let params = create_test_quote_params();
1634 let mut response = successful_quote_response(¶ms.amount_in.to_string());
1635 response.tx_request.calldata.clear();
1636
1637 let result = NativeClient::process_quote_response(response, ¶ms);
1638
1639 assert!(matches!(
1640 result,
1641 Err(RFQError::QuoteNotFound(message)) if message.contains("did not include calldata")
1642 ));
1643 }
1644
1645 #[test]
1646 fn rejects_truncated_trade_calldata() {
1647 let params = create_test_quote_params();
1648 let mut response = successful_quote_response(¶ms.amount_in.to_string());
1649 response.tx_request.calldata = format!("0x7083527c{}", "00".repeat(95));
1650
1651 let result = NativeClient::process_quote_response(response, ¶ms);
1652
1653 assert!(matches!(
1654 result,
1655 Err(RFQError::ParsingError(message)) if message.contains("calldata too short")
1656 ));
1657 }
1658
1659 #[test]
1660 fn rejects_quote_with_wrong_trade_rfqt_selector() {
1661 let params = create_test_quote_params();
1662 let mut response = successful_quote_response(¶ms.amount_in.to_string());
1663 let mut calldata = hex::decode(
1664 response
1665 .tx_request
1666 .calldata
1667 .trim_start_matches("0x"),
1668 )
1669 .unwrap();
1670 calldata[..4].copy_from_slice(&[0x09, 0x47, 0xc2, 0xd9]);
1672 response.tx_request.calldata = format!("0x{}", hex::encode(calldata));
1673
1674 let result = NativeClient::process_quote_response(response, ¶ms);
1675
1676 assert!(matches!(
1677 result,
1678 Err(RFQError::ParsingError(message)) if message.contains("selector")
1679 ));
1680 }
1681
1682 #[rstest]
1683 #[case::v4("4")]
1684 #[case::unknown("7")]
1685 fn rejects_quote_with_wrong_router_version(#[case] version: &str) {
1686 let params = create_test_quote_params();
1687 let mut response = successful_quote_response(¶ms.amount_in.to_string());
1688 response.router_version = version.to_string();
1689
1690 assert!(matches!(
1691 NativeClient::process_quote_response(response, ¶ms),
1692 Err(RFQError::ParsingError(message)) if message.contains("Unexpected Native router version")
1693 ));
1694 }
1695
1696 #[rstest]
1697 #[case::seller_offset(68, 68)]
1698 #[case::minimum_offset(36, 36)]
1699 fn rejects_quote_with_noncanonical_override_offsets(
1700 #[case] amount_in_offset: u32,
1701 #[case] amount_out_minimum_offset: u32,
1702 ) {
1703 let params = create_test_quote_params();
1704 let mut response = successful_quote_response(¶ms.amount_in.to_string());
1705 response.amount_in_offset = amount_in_offset;
1706 response.amount_out_minimum_offset = amount_out_minimum_offset;
1707
1708 let result = NativeClient::process_quote_response(response, ¶ms);
1709
1710 assert!(matches!(
1711 result,
1712 Err(RFQError::ParsingError(message)) if message.contains(
1713 "Unexpected Native V6 override offsets"
1714 )
1715 ));
1716 }
1717
1718 #[rstest]
1719 #[case::seller(ACTUAL_SELLER_AMOUNT_OFFSET, "actualSellerAmount")]
1720 #[case::minimum(ACTUAL_MIN_OUTPUT_AMOUNT_OFFSET, "actualMinOutputAmount")]
1721 fn rejects_quote_with_preset_override(
1722 #[case] override_offset: usize,
1723 #[case] field_name: &str,
1724 ) {
1725 let params = create_test_quote_params();
1726 let mut response = successful_quote_response(¶ms.amount_in.to_string());
1727 let mut calldata = hex::decode(
1728 response
1729 .tx_request
1730 .calldata
1731 .trim_start_matches("0x"),
1732 )
1733 .unwrap();
1734 calldata[override_offset + 31] = 1;
1735 response.tx_request.calldata = format!("0x{}", hex::encode(calldata));
1736
1737 let result = NativeClient::process_quote_response(response, ¶ms);
1738
1739 assert!(matches!(
1740 result,
1741 Err(RFQError::ParsingError(message)) if message.contains(field_name)
1742 ));
1743 }
1744
1745 #[test]
1746 fn accepts_native_eth_response_using_zero_address() {
1747 let mut params = create_test_quote_params();
1748 params.token_in = Bytes::zero(20);
1749 let mut response = successful_quote_response(¶ms.amount_in.to_string());
1750 response.orders[0].seller_token = "0x0000000000000000000000000000000000000000".to_string();
1751 response.tx_request.value = params.amount_in.to_string();
1752
1753 let quote = NativeClient::process_quote_response(response, ¶ms).unwrap();
1754
1755 assert_eq!(quote.amount_in, params.amount_in);
1756 }
1757
1758 #[test]
1759 fn rejects_native_quote_with_mismatched_payable_value() {
1760 let mut params = create_test_quote_params();
1761 params.token_in = Bytes::zero(20);
1762 let mut response = successful_quote_response(¶ms.amount_in.to_string());
1763 response.orders[0].seller_token = "0x0000000000000000000000000000000000000000".to_string();
1764 response.tx_request.value = "2".to_string();
1765
1766 let result = NativeClient::process_quote_response(response, ¶ms);
1767
1768 assert!(matches!(
1769 result,
1770 Err(RFQError::ParsingError(message)) if message.contains("payable value mismatch")
1771 ));
1772 }
1773
1774 #[test]
1775 fn rejects_malformed_payable_value() {
1776 let params = create_test_quote_params();
1777 let mut response = successful_quote_response(¶ms.amount_in.to_string());
1778 response.tx_request.value = "not-a-number".to_string();
1779
1780 let result = NativeClient::process_quote_response(response, ¶ms);
1781
1782 assert!(matches!(
1783 result,
1784 Err(RFQError::ParsingError(message)) if message.contains("txRequest.value")
1785 ));
1786 }
1787
1788 #[test]
1789 fn rejects_erc20_quote_with_nonzero_payable_value() {
1790 let params = create_test_quote_params();
1791 let mut response = successful_quote_response(¶ms.amount_in.to_string());
1792 response.tx_request.value = "1".to_string();
1793
1794 let result = NativeClient::process_quote_response(response, ¶ms);
1795
1796 assert!(matches!(
1797 result,
1798 Err(RFQError::ParsingError(message)) if message.contains("payable value mismatch")
1799 ));
1800 }
1801
1802 #[test]
1803 fn accepts_native_eth_orderbook_using_zero_address() {
1804 let tycho_native_eth = Bytes::zero(20);
1805 let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap();
1806 let client = NativeClient::new(
1807 Chain::Ethereum,
1808 "test-api-key".to_string(),
1809 HashSet::from([tycho_native_eth.clone(), usdc.clone()]),
1810 0.0,
1811 HashSet::from([usdc.clone()]),
1812 Duration::from_secs(1),
1813 Duration::from_secs(5),
1814 )
1815 .unwrap();
1816
1817 let entry: NativeOrderbookEntry = serde_json::from_value(serde_json::json!({
1818 "base_address": tycho_native_eth.to_string(),
1819 "quote_address": usdc.to_string(),
1820 "minimum_in_base": 1.0,
1821 "side": "bid",
1822 "levels": [[1.0, 3_000.0]]
1823 }))
1824 .unwrap();
1825 let books = client.group_orderbook(vec![entry]);
1826
1827 let book = books.values().next().unwrap();
1828 assert_eq!(book.base_address, tycho_native_eth);
1829 }
1830
1831 fn create_test_client(endpoint: String) -> NativeClient {
1832 let mut client = NativeClient::new(
1833 Chain::Ethereum,
1834 "test-api-key".to_string(),
1835 HashSet::new(),
1836 0.0,
1837 HashSet::new(),
1838 Duration::from_secs(1),
1839 Duration::from_secs(5),
1840 )
1841 .unwrap();
1842 client.endpoint = endpoint;
1843 client
1844 }
1845
1846 #[tokio::test]
1847 async fn requests_v6_firm_quote() {
1848 let listener = TcpListener::bind("127.0.0.1:0")
1849 .await
1850 .unwrap();
1851 let address = listener.local_addr().unwrap();
1852 let server = tokio::spawn(async move {
1853 let (stream, _) = listener.accept().await.unwrap();
1854 let mut reader = BufReader::new(stream);
1855 let mut request_line = String::new();
1856 reader
1857 .read_line(&mut request_line)
1858 .await
1859 .unwrap();
1860 let body = successful_quote_json("1").to_string();
1861 let response = format!(
1862 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1863 body.len()
1864 );
1865 reader
1866 .into_inner()
1867 .write_all(response.as_bytes())
1868 .await
1869 .unwrap();
1870 request_line
1871 });
1872 let client = create_test_client(format!("http://{address}"));
1873 let params = create_test_quote_params();
1874
1875 let quote = client
1876 .request_binding_quote(¶ms)
1877 .await
1878 .unwrap();
1879 let request = server.await.unwrap();
1880 let url = reqwest::Url::parse(&format!(
1881 "http://{address}{}",
1882 request
1883 .split_whitespace()
1884 .nth(1)
1885 .unwrap()
1886 ))
1887 .unwrap();
1888 let query: HashMap<_, _> = url.query_pairs().into_owned().collect();
1889
1890 assert_eq!(url.path(), "/firm-quote");
1891 assert_eq!(query.get("version").map(String::as_str), Some("6"));
1892 assert_eq!(
1893 query
1894 .get("allow_multihop")
1895 .map(String::as_str),
1896 Some("false")
1897 );
1898 assert_eq!(quote.amount_in, params.amount_in);
1899 }
1900
1901 #[tokio::test]
1902 async fn requests_native_token_orderbooks() {
1903 let listener = TcpListener::bind("127.0.0.1:0")
1904 .await
1905 .unwrap();
1906 let address = listener.local_addr().unwrap();
1907 let server = tokio::spawn(async move {
1908 let (stream, _) = listener.accept().await.unwrap();
1909 let mut reader = BufReader::new(stream);
1910 let mut request_line = String::new();
1911 reader
1912 .read_line(&mut request_line)
1913 .await
1914 .unwrap();
1915 let mut stream = reader.into_inner();
1916 let response = "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]";
1917 stream
1918 .write_all(response.as_bytes())
1919 .await
1920 .unwrap();
1921 request_line
1922 });
1923 let client = create_test_client(format!("http://{address}"));
1924
1925 let orderbook = client.fetch_orderbook().await.unwrap();
1926 let request = server.await.unwrap();
1927
1928 assert!(orderbook.is_empty());
1929 assert!(request.starts_with("GET /orderbook?"));
1930 assert!(request.contains("chain=ethereum"));
1931 assert!(request.contains("showNative=0x0"));
1932 }
1933
1934 #[rstest]
1935 #[case::with_conversion_helper(true, 300.0, Some(400.0))]
1936 #[case::without_conversion_helper(false, 300.0, None)]
1937 #[case::below_normalized_tvl_threshold(true, 401.0, None)]
1938 #[tokio::test]
1939 async fn stream_uses_unrequested_books_only_for_tvl_conversion(
1940 #[case] include_helper: bool,
1941 #[case] tvl_threshold: f64,
1942 #[case] expected_tvl: Option<f64>,
1943 #[values(ComponentLayout::PerPair, ComponentLayout::AllPairs)] layout: ComponentLayout,
1944 ) {
1945 let weth = Bytes::from_str("0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2").unwrap();
1946 let usdt = Bytes::from_str("0xdac17f958d2ee523a2206206994597c13d831ec7").unwrap();
1947 let usdc = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap();
1948 let mut entries = vec![serde_json::json!({
1949 "base_address": weth.to_string(),
1950 "quote_address": usdt.to_string(),
1951 "minimum_in_base": 0.0,
1952 "side": "bid",
1953 "levels": [[2.0, 100.0]]
1954 })];
1955 if include_helper {
1956 entries.push(serde_json::json!({
1959 "base_address": usdc.to_string(),
1960 "quote_address": usdt.to_string(),
1961 "minimum_in_base": 0.0,
1962 "side": "bid",
1963 "levels": [[1_000.0, 0.5]]
1964 }));
1965 }
1966 let (address, _) =
1967 create_quote_server("200 OK", serde_json::to_string(&entries).unwrap()).await;
1968 let mut client =
1969 create_test_client(format!("http://{address}")).with_component_layout(layout);
1970 client.tokens = HashSet::from([weth.clone(), usdt.clone()]);
1971 client.quote_tokens = HashSet::from([usdc]);
1972 client.tvl = tvl_threshold;
1973
1974 let (_, update) = timeout(Duration::from_secs(5), client.stream().next())
1975 .await
1976 .expect("orderbook poll timed out")
1977 .expect("stream ended")
1978 .expect("orderbook poll failed");
1979
1980 if let Some(tvl) = expected_tvl {
1981 assert_eq!(update.snapshots.states.len(), 1, "helper must not be emitted");
1982 let component = update
1983 .snapshots
1984 .states
1985 .values()
1986 .next()
1987 .unwrap();
1988 assert_eq!(component.component.tokens, vec![weth, usdt]);
1989 assert_eq!(component.component_tvl, Some(tvl));
1990 } else {
1991 assert!(update.snapshots.states.is_empty());
1992 }
1993 }
1994
1995 async fn create_quote_server(
1996 final_status: impl Into<String>,
1997 final_body: impl Into<String>,
1998 ) -> (std::net::SocketAddr, Arc<AtomicUsize>) {
1999 let final_status = final_status.into();
2000 let final_body = final_body.into();
2001 let listener = TcpListener::bind("127.0.0.1:0")
2002 .await
2003 .unwrap();
2004 let address = listener.local_addr().unwrap();
2005 let request_count = Arc::new(AtomicUsize::new(0));
2006 let server_request_count = request_count.clone();
2007
2008 tokio::spawn(async move {
2009 while let Ok((mut stream, _)) = listener.accept().await {
2010 server_request_count.fetch_add(1, Ordering::SeqCst);
2011 let response = format!(
2012 "HTTP/1.1 {final_status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{final_body}",
2013 final_body.len()
2014 );
2015 let _ = stream
2016 .write_all(response.as_bytes())
2017 .await;
2018 let _ = stream.shutdown().await;
2019 }
2020 });
2021
2022 (address, request_count)
2023 }
2024
2025 async fn create_hanging_quote_server() -> std::net::SocketAddr {
2026 let listener = TcpListener::bind("127.0.0.1:0")
2027 .await
2028 .unwrap();
2029 let address = listener.local_addr().unwrap();
2030
2031 tokio::spawn(async move {
2032 let (_stream, _) = listener.accept().await.unwrap();
2033 std::future::pending::<()>().await;
2034 });
2035
2036 address
2037 }
2038
2039 #[tokio::test]
2040 async fn handles_orderbook_api_error_with_http_200() {
2041 let (address, request_count) = create_quote_server(
2042 "200 OK",
2043 r#"{"code":171015,"message":"quoted token not available"}"#,
2044 )
2045 .await;
2046 let client = create_test_client(format!("http://{address}"));
2047
2048 let result = client.fetch_orderbook().await;
2049
2050 assert!(matches!(
2051 result,
2052 Err(RFQError::QuoteNotFound(message)) if message.contains("171015")
2053 ));
2054 assert_eq!(request_count.load(Ordering::SeqCst), 1);
2055 }
2056
2057 #[tokio::test]
2058 async fn handles_documented_quote_error_without_retrying() {
2059 let (address, request_count) = create_quote_server(
2060 "200 OK",
2061 r#"{"code":171015,"message":"quoted token not available"}"#,
2062 )
2063 .await;
2064 let client = create_test_client(format!("http://{address}"));
2065
2066 let result = client
2067 .request_binding_quote(&create_test_quote_params())
2068 .await;
2069
2070 match result {
2071 Err(RFQError::QuoteNotFound(message)) => {
2072 assert!(message.contains("171015"));
2073 assert!(message.contains("quoted token not available"));
2074 }
2075 other => panic!("Expected Native API error, got {other:?}"),
2076 }
2077 assert_eq!(request_count.load(Ordering::SeqCst), 1);
2078 }
2079
2080 #[tokio::test]
2081 async fn handles_success_false_as_quote_not_found_without_retrying() {
2082 let mut response = successful_quote_json("1");
2083 response["success"] = serde_json::Value::Bool(false);
2084 response["errorMessage"] = serde_json::Value::String("quote unavailable".to_string());
2085 let (address, request_count) = create_quote_server("200 OK", response.to_string()).await;
2086 let client = create_test_client(format!("http://{address}"));
2087
2088 let result = client
2089 .request_binding_quote(&create_test_quote_params())
2090 .await;
2091
2092 assert!(matches!(
2093 result,
2094 Err(RFQError::QuoteNotFound(message)) if message.contains("quote unavailable")
2095 ));
2096 assert_eq!(request_count.load(Ordering::SeqCst), 1);
2097 }
2098
2099 #[tokio::test]
2100 async fn retries_documented_temporary_api_error() {
2101 let (address, request_count) = create_quote_server(
2102 "200 OK",
2103 r#"{"code":301016,"message":"quote invalid, risk management checks failed"}"#,
2104 )
2105 .await;
2106 let client = create_test_client(format!("http://{address}"));
2107
2108 let result = client
2109 .request_binding_quote(&create_test_quote_params())
2110 .await;
2111
2112 assert!(matches!(result, Err(RFQError::QuoteNotFound(_))));
2113 assert_eq!(request_count.load(Ordering::SeqCst), 3);
2114 }
2115
2116 #[tokio::test]
2117 async fn retries_server_error_without_native_error_envelope() {
2118 let (address, request_count) =
2119 create_quote_server("503 Service Unavailable", "<html>upstream unavailable</html>")
2120 .await;
2121 let client = create_test_client(format!("http://{address}"));
2122
2123 let result = client
2124 .request_binding_quote(&create_test_quote_params())
2125 .await;
2126
2127 assert!(matches!(
2128 result,
2129 Err(RFQError::ConnectionError(message)) if message.contains("503 Service Unavailable")
2130 ));
2131 assert_eq!(request_count.load(Ordering::SeqCst), 3);
2132 }
2133
2134 #[tokio::test]
2135 async fn retries_malformed_success_response() {
2136 let (address, request_count) =
2137 create_quote_server("200 OK", r#"{"unexpected":true}"#).await;
2138 let client = create_test_client(format!("http://{address}"));
2139
2140 let result = client
2141 .request_binding_quote(&create_test_quote_params())
2142 .await;
2143
2144 assert!(matches!(result, Err(RFQError::ParsingError(_))));
2145 assert_eq!(request_count.load(Ordering::SeqCst), 3);
2146 }
2147
2148 #[tokio::test]
2149 async fn times_out_when_quote_response_stalls() {
2150 let address = create_hanging_quote_server().await;
2151 let mut client = create_test_client(format!("http://{address}"));
2152 client.quote_timeout = Duration::from_millis(50);
2153
2154 let result = tokio::time::timeout(
2155 Duration::from_secs(1),
2156 client.request_binding_quote(&create_test_quote_params()),
2157 )
2158 .await
2159 .expect("Native quote timeout did not terminate the request");
2160
2161 assert!(matches!(
2162 result,
2163 Err(RFQError::ConnectionError(message)) if message.contains("timed out after 50ms")
2164 ));
2165 }
2166
2167 #[tokio::test]
2168 async fn shares_quote_timeout_across_retries() {
2169 let listener = TcpListener::bind("127.0.0.1:0")
2170 .await
2171 .unwrap();
2172 let address = listener.local_addr().unwrap();
2173 let request_count = Arc::new(AtomicUsize::new(0));
2174 let server_request_count = request_count.clone();
2175 let params = create_test_quote_params();
2176 let success_body = successful_quote_json(¶ms.amount_in.to_string()).to_string();
2177 let quote_timeout = NATIVE_API_RETRY_DELAY * 2;
2178
2179 tokio::spawn(async move {
2180 let retry_body =
2181 r#"{"code":301016,"message":"quote invalid, risk management checks failed"}"#
2182 .to_string();
2183 for body in [retry_body, success_body] {
2184 let (mut stream, _) = listener.accept().await.unwrap();
2185 let attempt = server_request_count.fetch_add(1, Ordering::SeqCst);
2186 if attempt == 1 {
2187 tokio::time::sleep(quote_timeout * 3 / 4).await;
2189 }
2190 let response = format!(
2191 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2192 body.len()
2193 );
2194 let _ = stream
2195 .write_all(response.as_bytes())
2196 .await;
2197 let _ = stream.shutdown().await;
2198 }
2199 });
2200
2201 let mut client = create_test_client(format!("http://{address}"));
2202 client.quote_timeout = quote_timeout;
2203
2204 let result = timeout(Duration::from_secs(5), client.request_binding_quote(¶ms))
2205 .await
2206 .expect("Native quote timeout did not terminate the retries");
2207
2208 assert!(matches!(
2209 result,
2210 Err(RFQError::QuoteNotFound(message)) if message.contains("301016")
2211 ));
2212 assert_eq!(request_count.load(Ordering::SeqCst), 2);
2213 }
2214
2215 #[tokio::test]
2216 async fn does_not_retry_documented_authentication_error() {
2217 let (address, request_count) = create_quote_server(
2218 "200 OK",
2219 r#"{"code":201001,"message":"auth get api key is invalid"}"#,
2220 )
2221 .await;
2222 let client = create_test_client(format!("http://{address}"));
2223
2224 let result = client
2225 .request_binding_quote(&create_test_quote_params())
2226 .await;
2227
2228 assert!(matches!(result, Err(RFQError::FatalError(_))));
2229 assert_eq!(request_count.load(Ordering::SeqCst), 1);
2230 }
2231}