Skip to main content

tycho_simulation/rfq/protocols/native/
client.rs

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);
44// V6 tradeRFQT: a dynamic quote tuple and two uint256 overrides, i.e. three ABI head words.
45const 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    // Maximum atomic minimum_in_base among the Native entries contributing these levels.
67    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    // Native API error codes:
97    // <https://docs.native.org/native-dev/build-with-native/swap-aggregators/firmquote-swap-apis/miscellaneous/error-handling#error-codes>
98    fn classify_api_error(error: &NativeApiErrorResponse) -> QuoteAttemptError {
99        let message = format!("Native API error {}: {}", error.code, error.message);
100        match error.code {
101            // Native documents these as temporary risk/rate-limit failures.
102            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            // The requested quote is unavailable for the current orderbook/liquidity.
111            // 171055 is not in the public table, but Native returns it when the seller amount is
112            // below the current book minimum.
113            101010 | 171037 | 171011 | 171015 | 171055 | 101007 => {
114                QuoteAttemptError::Fatal(RFQError::QuoteNotFound(message))
115            }
116            // The request itself must be corrected before another attempt can succeed.
117            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    /// The name the client's stream tags its messages with. The two layouts use different names,
162    /// so one stream builder can carry a client of each.
163    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                // `group_orderbook` keeps the configured quote token on the quote side, so every
179                // matching conversion book is valued in comparable approved-token units.
180                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                    // Prefer the smaller token address when liquidity is equal so the result does
193                    // not depend on HashMap or HashSet iteration order.
194                    .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    /// Every grouped book whose tokens the client requested and whose TVL clears the threshold,
238    /// each with its id and TVL. Books of unrequested tokens still serve TVL conversion.
239    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            // Keep unrequested books available for TVL conversion, but only emit requested
247            // markets as components.
248            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                // TVL thresholds are applied in approved quote-token units. If Native quotes this
263                // market against another token, normalize through the most liquid available
264                // approved quote-token market before filtering.
265                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    /// One component per book in `books_above_tvl_threshold`.
295    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    /// The all-pairs component for one poll: every book in `books_above_tvl_threshold` that has
314    /// bids or asks. `None` when there is none. A book's bids serve its base token in, its asks
315    /// its quote token in.
316    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            // `showNative` is not boolean: its value selects the address used for native-token
362            // books. Request address(0) so the response matches Tycho's internal representation.
363            .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        // Native can return an API error envelope with HTTP 200.
377        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            // Keep the book direction stable even when Native publishes only one direction of a
416            // pair.
417            // Prefer exactly one configured quote token; if both or neither are configured, use
418            // the sorted pair order established by the grouping key above.
419            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            // Native's minimum_in_base is always denominated in entry.base_address. For a bid the
432            // taker sells base, so it is an input minimum; for an ask the taker receives base, so
433            // it is an output minimum. Mirroring swaps bid/ask and remaps that minimum into the
434            // canonical direction.
435            for entry in entries {
436                // A zero-only direct entry must not suppress usable mirrored liquidity for the
437                // same side. NativeState also filters zero quantities as a defensive measure for
438                // deserialized states that do not pass through this grouping path.
439                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            // Use mirrored levels only when direct ones are absent to avoid double-counting. Keep
473            // the minimum from the selected representation so discarded levels cannot constrain
474            // the surviving side.
475            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            // Use the sorted pair key so the component ID remains stable if Native returns the
486            // opposite book direction in a later poll.
487            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        // 1. Check API-level success
512        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        // Ensure we actually got an order
527        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        // Prevents silently accepting a mismatched/malicious quote.
538        let seller_token = bytes_to_address(&params.token_in)?;
539        let buyer_token = bytes_to_address(&params.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(&params.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        // Security: reject already-expired quotes before we bother building a SignedQuote
570        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        // Bind Native's top-level amountIn and signed sellerTokenAmount to the requested quote
583        // baseline. The encoder stores this baseline as signedAmountIn; the executor supplies
584        // actualSellerAmount when execution receives a different amount from the preceding hop.
585        let quoted_amount_in = BigUint::from_str(&quote_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        // effectiveSellerTokenAmount may differ from the requested gross input for
612        // fee-on-transfer tokens, so it is not an equality invariant here. amountIn and
613        // sellerTokenAmount still bind the quote to Tycho's requested input.
614        // Native requires txRequest.value for native-token quotes. Validate the quoted value here,
615        // while it still describes the original signed amount. During execution, the preceding hop
616        // may deliver either less or more; the executor handles that through actualSellerAmount.
617        let quoted_value = BigUint::from_str(&quote_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        // Decode calldata (pre-built by Native Relay, ready to submit as-is)
642        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        // These offsets are fixed by V6's tradeRFQT ABI (a dynamic quote tuple and
667        // two uint256 overrides). Rejecting any other value catches an incompatible or malformed
668        // API response before it reaches the encoder; the executor independently
669        // hardcodes the same positions rather than trusting route data.
670        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(&quote_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(&quote_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        // Native returns documented API error codes in the body, including with HTTP 200.
763        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                // Native Relay publishes a complete RFQ orderbook once per request. Polling the
816                // full book keeps component creation/removal deterministic and avoids per-pair REST
817                // fan-out.
818                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(&params.receiver)?;
851        let token_in = bytes_to_address(&params.token_in)?;
852        let token_out = bytes_to_address(&params.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        // Bind the timeout result before inspecting last_error so the attempts future—and its
898        // mutable borrow—has been dropped.
899        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        // Native returned only the direction opposite to the sorted fallback.
1508        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(&params.amount_in.to_string());
1541
1542        let quote = NativeClient::process_quote_response(response, &params).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(&params.amount_in.to_string());
1559        // Keep sellerTokenAmount correct so only the top-level amountIn check can reject this.
1560        response.amount_in = "2".to_string();
1561
1562        let result = NativeClient::process_quote_response(response, &params);
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(&params.amount_in.to_string());
1574        response.orders[0].seller_token_amount = "2".to_string();
1575
1576        let result = NativeClient::process_quote_response(response, &params);
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(&params.amount_in.to_string());
1589        response.orders[0].effective_seller_token_amount = "99".to_string();
1590
1591        let quote = NativeClient::process_quote_response(response, &params).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(&params.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, &params);
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(&params.amount_in.to_string());
1621        response.orders[0].recipient = "0x5555555555555555555555555555555555555555".to_string();
1622
1623        let result = NativeClient::process_quote_response(response, &params);
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(&params.amount_in.to_string());
1635        response.tx_request.calldata.clear();
1636
1637        let result = NativeClient::process_quote_response(response, &params);
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(&params.amount_in.to_string());
1649        response.tx_request.calldata = format!("0x7083527c{}", "00".repeat(95));
1650
1651        let result = NativeClient::process_quote_response(response, &params);
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(&params.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        // V4 also exposes tradeRFQT, but its quote tuple has a different ABI.
1671        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, &params);
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(&params.amount_in.to_string());
1688        response.router_version = version.to_string();
1689
1690        assert!(matches!(
1691            NativeClient::process_quote_response(response, &params),
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(&params.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, &params);
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(&params.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, &params);
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(&params.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, &params).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(&params.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, &params);
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(&params.amount_in.to_string());
1778        response.tx_request.value = "not-a-number".to_string();
1779
1780        let result = NativeClient::process_quote_response(response, &params);
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(&params.amount_in.to_string());
1792        response.tx_request.value = "1".to_string();
1793
1794        let result = NativeClient::process_quote_response(response, &params);
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(&params)
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            // Use a reversed helper with a non-unit price so the test distinguishes the market's
1957            // 200 USDT of liquidity from its normalized value of 400 USDC.
1958            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(&params.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                    // This fits a fresh timeout, but not the time left after the retry backoff.
2188                    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(&params))
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}