Skip to main content

tycho_simulation/rfq/protocols/bebop/
client.rs

1use std::{
2    collections::{BTreeSet, HashMap, HashSet},
3    str::FromStr,
4};
5
6use alloy::primitives::{utils::keccak256, Address};
7use async_trait::async_trait;
8use futures::{stream::BoxStream, StreamExt};
9use http::Request;
10use num_bigint::BigUint;
11use prost::Message as ProstMessage;
12use reqwest::Client;
13use serde::{Deserialize, Serialize};
14use tokio::time::{sleep, timeout, Duration};
15use tokio_tungstenite::{
16    connect_async_with_config,
17    tungstenite::{handshake::client::generate_key, Message},
18};
19use tracing::{error, info, warn};
20use tycho_common::{
21    models::{protocol::GetAmountOutParams, Chain},
22    simulation::indicatively_priced::SignedQuote,
23    Bytes,
24};
25
26use crate::{
27    rfq::{
28        client::RFQClient,
29        errors::RFQError,
30        models::{ComponentLayout, QuoteRule, TimestampHeader},
31        protocols::{
32            bebop::models::{
33                BebopOrderToSign, BebopPriceData, BebopPricingUpdate, BebopQuoteResponse,
34            },
35            component,
36        },
37    },
38    tycho_client::feed::synchronizer::{ComponentWithState, StateSyncMessage},
39    tycho_common::models::protocol::{ProtocolComponent, ProtocolComponentState},
40};
41
42fn bytes_to_address(address: &Bytes) -> Result<Address, RFQError> {
43    if address.len() == 20 {
44        Ok(Address::from_slice(address))
45    } else {
46        Err(RFQError::InvalidInput(format!("Invalid ERC20 token address: {address:?}")))
47    }
48}
49
50/// Maps a Chain to its corresponding Bebop WebSocket URL
51fn chain_to_bebop_url(chain: Chain) -> Result<String, RFQError> {
52    let chain_path = match chain {
53        Chain::Ethereum => "ethereum",
54        Chain::Base => "base",
55        _ => return Err(RFQError::FatalError(format!("Unsupported chain: {chain:?}"))),
56    };
57    let url = format!("api.bebop.xyz/pmm/{chain_path}/v3");
58    Ok(url)
59}
60
61#[derive(Clone, Debug, Serialize, Deserialize)]
62pub struct BebopClient {
63    chain: Chain,
64    price_ws: String,
65    quote_endpoint: String,
66    // Tokens that we want prices for
67    tokens: HashSet<Bytes>,
68    // Min tvl value in the quote token.
69    tvl: f64,
70    // key header for authentication
71    #[serde(skip_serializing, default)]
72    ws_key: String,
73    // quote tokens to normalize to for TVL purposes. Should have the same prices.
74    quote_tokens: HashSet<Bytes>,
75    quote_timeout: Duration,
76    /// The real end-user's EOA when the taker is not the end-user's own wallet.
77    origin_address: Option<Bytes>,
78    /// The `to` address of the resulting transaction when a contract executes the swap.
79    origin_target: Option<Bytes>,
80    /// Stable identifier for the upstream flow source when aggregating multiple sources.
81    origin_source: Option<String>,
82    #[serde(default = "default_protocol_system")]
83    protocol_system: String,
84    #[serde(default)]
85    component_layout: ComponentLayout,
86}
87
88fn default_protocol_system() -> String {
89    BebopClient::PROTOCOL_SYSTEM.to_string()
90}
91
92impl BebopClient {
93    pub const PROTOCOL_SYSTEM: &'static str = "rfq:bebop";
94    /// Components executed through Tycho's `BebopFallbackRouter`.
95    pub const FALLBACK_PROTOCOL_SYSTEM: &'static str = "fallback:rfq:bebop";
96
97    pub(super) fn via_fallback_router(mut self) -> Self {
98        self.protocol_system = Self::FALLBACK_PROTOCOL_SYSTEM.to_string();
99        self
100    }
101
102    /// Creates a fully configured client. Prefer constructing through
103    /// [`BebopClientBuilder`](super::client_builder::BebopClientBuilder).
104    #[allow(clippy::too_many_arguments)]
105    pub fn new(
106        chain: Chain,
107        tokens: HashSet<Bytes>,
108        tvl: f64,
109        ws_key: String,
110        quote_tokens: HashSet<Bytes>,
111        quote_timeout: Duration,
112        origin_address: Option<Bytes>,
113        origin_target: Option<Bytes>,
114        origin_source: Option<String>,
115    ) -> Result<Self, RFQError> {
116        let url = chain_to_bebop_url(chain)?;
117        Ok(Self {
118            price_ws: "wss://".to_string() + &url + "/pricing?format=protobuf",
119            quote_endpoint: "https://".to_string() + &url + "/quote",
120            tokens,
121            chain,
122            tvl,
123            ws_key,
124            quote_tokens,
125            quote_timeout,
126            origin_address,
127            origin_target,
128            origin_source,
129            protocol_system: Self::PROTOCOL_SYSTEM.to_string(),
130            component_layout: ComponentLayout::PerPair,
131        })
132    }
133
134    pub(crate) fn with_component_layout(mut self, component_layout: ComponentLayout) -> Self {
135        self.component_layout = component_layout;
136        self
137    }
138
139    /// The name the client's stream tags its messages with. The two layouts use different names,
140    /// so one stream builder can carry a client of each.
141    fn stream_name(&self) -> &'static str {
142        match self.component_layout {
143            ComponentLayout::PerPair => "bebop",
144            ComponentLayout::AllPairs => "bebop_all_pairs",
145        }
146    }
147
148    fn create_component_with_state(
149        &self,
150        component_id: String,
151        tokens: Vec<tycho_common::Bytes>,
152        price_data: &BebopPriceData,
153        tvl: f64,
154    ) -> ComponentWithState {
155        let protocol_component = ProtocolComponent {
156            id: component_id.clone(),
157            protocol_system: self.protocol_system.clone(),
158            protocol_type_name: "bebop_pool".to_string(),
159            chain: self.chain,
160            tokens,
161            contract_addresses: vec![], // empty for RFQ
162            static_attributes: Default::default(),
163            change: Default::default(),
164            creation_tx: Default::default(),
165            created_at: Default::default(),
166        };
167
168        let mut attributes = HashMap::new();
169
170        // Store all bids and asks as JSON strings, since we cannot store arrays
171        // Convert flat arrays [price1, size1, price2, size2, ...] to pairs [(price1, size1),
172        // (price2, size2), ...]
173        if !price_data.bids.is_empty() {
174            let bids_pairs: Vec<(f32, f32)> = price_data
175                .bids
176                .as_chunks::<2>()
177                .0
178                .iter()
179                .map(|chunk| (chunk[0], chunk[1]))
180                .collect();
181            let bids_json = serde_json::to_string(&bids_pairs).unwrap_or_default();
182            attributes.insert("bids".to_string(), bids_json.as_bytes().to_vec().into());
183        }
184        if !price_data.asks.is_empty() {
185            let asks_pairs: Vec<(f32, f32)> = price_data
186                .asks
187                .as_chunks::<2>()
188                .0
189                .iter()
190                .map(|chunk| (chunk[0], chunk[1]))
191                .collect();
192            let asks_json = serde_json::to_string(&asks_pairs).unwrap_or_default();
193            attributes.insert("asks".to_string(), asks_json.as_bytes().to_vec().into());
194        }
195
196        ComponentWithState {
197            state: ProtocolComponentState::new(&component_id, attributes, HashMap::new()),
198            component: protocol_component,
199            component_tvl: Some(tvl),
200            entrypoints: vec![],
201        }
202    }
203
204    /// Every book whose tokens the client requested and whose TVL clears the threshold, with its
205    /// TVL. A quote token outside the approved set is priced through a pair that holds one of
206    /// them, so every book's TVL is in the same unit.
207    fn books_above_tvl_threshold<'a>(
208        &self,
209        update: &'a BebopPricingUpdate,
210    ) -> Vec<(&'a BebopPriceData, f64)> {
211        let mut kept = Vec::new();
212        for price_data in &update.pairs {
213            let base_bytes = Bytes::from(price_data.base.clone());
214            let quote_bytes = Bytes::from(price_data.quote.clone());
215            if !(self.tokens.contains(&base_bytes) && self.tokens.contains(&quote_bytes)) {
216                continue;
217            }
218
219            let mut quote_price_data: Option<&BebopPriceData> = None;
220            if !self.quote_tokens.contains(&quote_bytes) {
221                for approved_quote_token in &self.quote_tokens {
222                    // Either QUOTE/APPROVED or APPROVED/QUOTE
223                    if let Some(quote_data) =
224                        update.pairs.iter().find(|p| {
225                            (p.base == quote_bytes.as_ref() &&
226                                p.quote == approved_quote_token.as_ref()) ||
227                                (p.quote == quote_bytes.as_ref() &&
228                                    p.base == approved_quote_token.as_ref())
229                        })
230                    {
231                        quote_price_data = Some(quote_data);
232                        break;
233                    }
234                }
235                if quote_price_data.is_none() {
236                    warn!("Quote token {} does not have price levels in approved quote token. Skipping.", hex::encode(&quote_bytes));
237                    continue;
238                }
239            }
240
241            let tvl = price_data.calculate_tvl(quote_price_data);
242            if tvl < self.tvl {
243                continue;
244            }
245            kept.push((price_data, tvl));
246        }
247        kept
248    }
249
250    /// One component per book in `books_above_tvl_threshold`.
251    fn pair_components(&self, update: &BebopPricingUpdate) -> HashMap<String, ComponentWithState> {
252        let mut new_components = HashMap::new();
253        for (price_data, tvl) in self.books_above_tvl_threshold(update) {
254            let base_bytes = Bytes::from(price_data.base.clone());
255            let quote_bytes = Bytes::from(price_data.quote.clone());
256            let pair_str =
257                format!("bebop_{}/{}", hex::encode(&base_bytes), hex::encode(&quote_bytes));
258            let component_id = format!("{}", keccak256(pair_str.as_bytes()));
259            let component_with_state = self.create_component_with_state(
260                component_id.clone(),
261                vec![base_bytes, quote_bytes],
262                price_data,
263                tvl,
264            );
265            new_components.insert(component_id, component_with_state);
266        }
267        new_components
268    }
269
270    /// The all-pairs component for one pricing update: every book in `books_above_tvl_threshold`
271    /// that has bids or asks. `None` when there is none. A book's bids serve its base token in,
272    /// its asks its quote token in.
273    fn all_pairs_component(
274        &self,
275        update: &BebopPricingUpdate,
276    ) -> Result<Option<ComponentWithState>, RFQError> {
277        let mut books = Vec::new();
278        let mut tvl = 0.0;
279        for (price_data, book_tvl) in self.books_above_tvl_threshold(update) {
280            if price_data.bids.is_empty() && price_data.asks.is_empty() {
281                continue;
282            }
283            tvl += book_tvl;
284            books.push(price_data.clone());
285        }
286        if books.is_empty() {
287            return Ok(None);
288        }
289        books.sort_by(|a, b| (&a.base, &a.quote).cmp(&(&b.base, &b.quote)));
290
291        let mut swap_directions = BTreeSet::new();
292        for book in &books {
293            let base = Bytes::from(book.base.clone());
294            let quote = Bytes::from(book.quote.clone());
295            if !book.bids.is_empty() {
296                swap_directions.insert((base.clone(), quote.clone()));
297            }
298            if !book.asks.is_empty() {
299                swap_directions.insert((quote, base));
300            }
301        }
302        let component = component::all_pairs_component(
303            Self::PROTOCOL_SYSTEM,
304            "bebop_pool",
305            self.chain,
306            &swap_directions,
307            &books,
308            tvl,
309            QuoteRule::OncePerVenue,
310        )?;
311        Ok(Some(component))
312    }
313
314    fn process_quote_response(
315        quote_response: BebopQuoteResponse,
316        params: &GetAmountOutParams,
317    ) -> Result<SignedQuote, RFQError> {
318        match quote_response {
319            BebopQuoteResponse::Success(quote) => {
320                quote.validate(params)?;
321
322                let mut quote_attributes: HashMap<String, Bytes> = HashMap::new();
323                // The contract the calldata targets: either the Bebop settlement
324                // or the Bebop router.
325                quote_attributes.insert("tx_to".into(), quote.tx.to);
326                quote_attributes.insert("calldata".into(), quote.tx.data);
327                quote_attributes.insert(
328                    "partial_fill_offset".into(),
329                    Bytes::from(
330                        quote
331                            .partial_fill_offset
332                            .to_be_bytes()
333                            .to_vec(),
334                    ),
335                );
336                let signed_quote = match quote.to_sign {
337                    BebopOrderToSign::Single(ref single) => SignedQuote {
338                        base_token: params.token_in.clone(),
339                        quote_token: params.token_out.clone(),
340                        amount_in: BigUint::from_str(&single.taker_amount).map_err(|_| {
341                            RFQError::ParsingError(format!(
342                                "Failed to parse amount in string: {}",
343                                single.taker_amount
344                            ))
345                        })?,
346                        amount_out: BigUint::from_str(&single.maker_amount).map_err(|_| {
347                            RFQError::ParsingError(format!(
348                                "Failed to parse amount out string: {}",
349                                single.maker_amount
350                            ))
351                        })?,
352                        quote_attributes,
353                    },
354                    BebopOrderToSign::Aggregate(aggregate) => {
355                        // Sum taker_amounts for taker_tokens matching the token_in
356                        let amount_in: BigUint = aggregate
357                            .taker_tokens
358                            .iter()
359                            .zip(&aggregate.taker_amounts)
360                            .flat_map(|(tokens, amounts)| {
361                                tokens
362                                    .iter()
363                                    .zip(amounts)
364                                    .filter_map(|(token, amount)| {
365                                        if token == &params.token_in {
366                                            BigUint::from_str(amount).ok()
367                                        } else {
368                                            None
369                                        }
370                                    })
371                            })
372                            .sum();
373
374                        // Sum maker_amounts for maker_tokens matching the token_out
375                        let amount_out: BigUint = aggregate
376                            .maker_tokens
377                            .iter()
378                            .zip(&aggregate.maker_amounts)
379                            .flat_map(|(tokens, amounts)| {
380                                tokens
381                                    .iter()
382                                    .zip(amounts)
383                                    .filter_map(|(token, amount)| {
384                                        if token == &params.token_out {
385                                            BigUint::from_str(amount).ok()
386                                        } else {
387                                            None
388                                        }
389                                    })
390                            })
391                            .sum();
392
393                        SignedQuote {
394                            base_token: params.token_in.clone(),
395                            quote_token: params.token_out.clone(),
396                            amount_in,
397                            amount_out,
398                            quote_attributes,
399                        }
400                    }
401                };
402
403                Ok(signed_quote)
404            }
405            BebopQuoteResponse::Error(err) => Err(RFQError::FatalError(format!(
406                "Bebop API error: code {} - {} (requestId: {})",
407                err.error.error_code, err.error.message, err.error.request_id
408            ))),
409        }
410    }
411}
412
413#[async_trait]
414impl RFQClient for BebopClient {
415    fn stream(
416        &self,
417    ) -> BoxStream<'static, Result<(String, StateSyncMessage<TimestampHeader>), RFQError>> {
418        let url = self.price_ws.clone();
419        let authorization = format!("Bearer {}", self.ws_key);
420        let client = self.clone();
421
422        Box::pin(async_stream::stream! {
423            let mut current_components: HashMap<String, ComponentWithState> = HashMap::new();
424            let mut consecutive_failures = 0;
425            const MAX_CONSECUTIVE_FAILURES: u32 = 10;
426
427            loop {
428                let request = Request::builder()
429                    .method("GET")
430                    .uri(&url)
431                    .header("Host", "api.bebop.xyz")
432                    .header("Upgrade", "websocket")
433                    .header("Connection", "Upgrade")
434                    .header("Sec-WebSocket-Key", generate_key())
435                    .header("Sec-WebSocket-Version", "13")
436                    .header("Authorization", &authorization)
437                    .body(())
438                    .map_err(|_| RFQError::FatalError("Failed to build request".into()))?;
439
440                // Connect to Bebop WebSocket with custom headers
441                let (ws_stream, _) = match connect_async_with_config(request, None, false).await {
442                    Ok(connection) => {
443                        info!("Successfully connected to Bebop WebSocket");
444                        connection
445                    },
446                    Err(e) => {
447                        consecutive_failures += 1;
448                        error!("Failed to connect to Bebop WebSocket (consecutive failure {}): {}", consecutive_failures, e);
449
450                        if consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
451                            yield Err(RFQError::ConnectionError(format!("Failed to connect after {MAX_CONSECUTIVE_FAILURES} consecutive failures: {e}")));
452                            return;
453                        }
454
455                        let backoff_duration = Duration::from_secs(2_u64.pow(consecutive_failures.min(5)));
456                        info!("Retrying connection in {} seconds...", backoff_duration.as_secs());
457                        sleep(backoff_duration).await;
458                        continue;
459                    }
460                };
461
462                let (_, mut ws_receiver) = ws_stream.split();
463
464                // Message processing loop
465                while let Some(msg) = ws_receiver.next().await {
466                    match msg {
467                        Ok(Message::Binary(data)) => {
468                            match BebopPricingUpdate::decode(&data[..]) {
469                                Ok(protobuf_update) => {
470                                    // A completed handshake says nothing about whether the
471                                    // connection works, so only pricing data clears the counter.
472                                    consecutive_failures = 0;
473
474                                    let components = match client.component_layout {
475                                        ComponentLayout::PerPair => client.pair_components(&protobuf_update),
476                                        ComponentLayout::AllPairs => match client.all_pairs_component(&protobuf_update) {
477                                            Ok(component) => component
478                                                .into_iter()
479                                                .map(|component| (component.component.id.clone(), component))
480                                                .collect(),
481                                            Err(e) => {
482                                                error!("Failed to build the Bebop component: {}", e);
483                                                continue;
484                                            }
485                                        },
486                                    };
487                                    let timestamp = component::unix_timestamp()?;
488                                    let msg = component::poll_message(&mut current_components, components, timestamp);
489                                    yield Ok((client.stream_name().to_string(), msg));
490                                },
491                                Err(e) => {
492                                    error!("Failed to parse protobuf message: {}", e);
493                                    break;
494                                }
495                            }
496                        }
497                        Ok(Message::Close(frame)) => {
498                            match frame {
499                                Some(frame) => warn!("WebSocket closed by server: {frame}"),
500                                None => warn!("WebSocket closed by server without a close frame"),
501                            }
502                            break;
503                        }
504                        Err(e) => {
505                            error!("WebSocket error: {}", e);
506                            break;
507                        }
508                        _ => {} // Ignore other message types
509                    }
510                }
511
512                // If we're here, the message loop exited - always attempt to reconnect.
513                // Pricing data resets this, so it only grows while the feed stays unusable.
514                consecutive_failures += 1;
515                if consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
516                    yield Err(RFQError::ConnectionError(format!("No pricing data received after {MAX_CONSECUTIVE_FAILURES} consecutive failures")));
517                    return;
518                }
519
520                let backoff_duration = Duration::from_secs(2_u64.pow(consecutive_failures.min(5)));
521                info!("Reconnecting in {} seconds (consecutive failure {})...", backoff_duration.as_secs(), consecutive_failures);
522                sleep(backoff_duration).await;
523                // Continue to the next iteration of the main loop
524            }
525        })
526    }
527
528    async fn request_binding_quote(
529        &self,
530        params: &GetAmountOutParams,
531    ) -> Result<SignedQuote, RFQError> {
532        let sell_token = bytes_to_address(&params.token_in)?.to_string();
533        let buy_token = bytes_to_address(&params.token_out)?.to_string();
534        let sell_amount = params.amount_in.to_string();
535        let sender = bytes_to_address(&params.sender)?.to_string();
536        let receiver = bytes_to_address(&params.receiver)?.to_string();
537
538        let url = self.quote_endpoint.clone();
539
540        let mut query = vec![
541            ("sell_tokens", sell_token),
542            ("buy_tokens", buy_token),
543            ("sell_amounts", sell_amount),
544            ("taker_address", sender),
545            ("receiver_address", receiver),
546            ("approval_type", "Standard".into()),
547            ("skip_validation", "true".into()),
548            ("skip_taker_checks", "true".into()),
549            ("gasless", "false".into()),
550            ("expiry_type", "standard".into()),
551            ("fee", "0".into()),
552            ("is_ui", "false".into()),
553        ];
554        if let Some(origin_address) = params
555            .origin
556            .as_ref()
557            .or(self.origin_address.as_ref())
558        {
559            query.push(("origin_address", bytes_to_address(origin_address)?.to_string()));
560        }
561        if let Some(origin_target) = &self.origin_target {
562            query.push(("origin_target", bytes_to_address(origin_target)?.to_string()));
563        }
564        if let Some(origin_source) = &self.origin_source {
565            query.push(("origin_source", origin_source.clone()));
566        }
567
568        let client = Client::new();
569
570        let start_time = std::time::Instant::now();
571        const MAX_RETRIES: u32 = 3;
572        let mut last_error = None;
573
574        for attempt in 0..MAX_RETRIES {
575            // Check if we have time remaining for this attempt
576            let elapsed = start_time.elapsed();
577            if elapsed >= self.quote_timeout {
578                return Err(last_error.unwrap_or_else(|| {
579                    RFQError::ConnectionError(format!(
580                        "Bebop quote request timed out after {} seconds",
581                        self.quote_timeout.as_secs()
582                    ))
583                }));
584            }
585
586            let remaining_time = self.quote_timeout - elapsed;
587
588            let request = client
589                .get(&url)
590                .query(&query)
591                .header("accept", "application/json")
592                .bearer_auth(&self.ws_key);
593
594            let response = match timeout(remaining_time, request.send()).await {
595                Ok(Ok(resp)) => resp,
596                Ok(Err(e)) => {
597                    warn!(
598                        "Bebop quote request failed (attempt {}/{}): {}",
599                        attempt + 1,
600                        MAX_RETRIES,
601                        e
602                    );
603                    last_error = Some(RFQError::ConnectionError(format!(
604                        "Failed to send Bebop quote request: {e}"
605                    )));
606                    if attempt < MAX_RETRIES - 1 {
607                        continue;
608                    } else {
609                        return Err(last_error.unwrap());
610                    }
611                }
612                Err(_) => {
613                    return Err(RFQError::ConnectionError(format!(
614                        "Bebop quote request timed out after {} seconds",
615                        self.quote_timeout.as_secs()
616                    )));
617                }
618            };
619
620            let quote_response = match response
621                .json::<BebopQuoteResponse>()
622                .await
623            {
624                Ok(resp) => resp,
625                Err(e) => {
626                    warn!(
627                        "Bebop quote response parsing failed (attempt {}/{}): {}",
628                        attempt + 1,
629                        MAX_RETRIES,
630                        e
631                    );
632                    last_error = Some(RFQError::ParsingError(format!(
633                        "Failed to parse Bebop quote response: {e}"
634                    )));
635                    if attempt < MAX_RETRIES - 1 {
636                        sleep(Duration::from_millis(100)).await;
637                        continue;
638                    } else {
639                        return Err(last_error.unwrap());
640                    }
641                }
642            };
643
644            return Self::process_quote_response(quote_response, params);
645        }
646
647        Err(last_error.unwrap_or_else(|| {
648            RFQError::ConnectionError("Bebop quote request failed after retries".to_string())
649        }))
650    }
651}
652
653#[cfg(test)]
654mod tests {
655    use std::{
656        sync::{Arc, Mutex},
657        time::Duration,
658    };
659
660    use dotenv::dotenv;
661    use futures::SinkExt;
662    use rstest::rstest;
663    use tokio::{net::TcpListener, time::timeout};
664    use tokio_tungstenite::accept_async;
665
666    use super::*;
667    use crate::rfq::{
668        constants::get_bebop_auth,
669        protocols::{
670            bebop::client_builder::BebopClientBuilder,
671            component::{BOOKS_ATTRIBUTE, SWAP_DIRECTIONS_ATTRIBUTE},
672        },
673    };
674
675    /// BebopSettlement.swapSingle
676    const SWAP_SINGLE_SELECTOR: [u8; 4] = [0x4d, 0xce, 0xbc, 0xba];
677    /// BebopSettlement.swapAggregate
678    const SWAP_AGGREGATE_SELECTOR: [u8; 4] = [0xa2, 0xf7, 0x48, 0x93];
679    /// BebopRouter.swap
680    const ROUTER_SWAP_SELECTOR: [u8; 4] = [0x95, 0x86, 0xd0, 0xe8];
681
682    #[test]
683    fn test_fallback_router_labels_components() {
684        let direct = BebopClientBuilder::new(Chain::Ethereum, String::new())
685            .build()
686            .unwrap();
687        let via_router = BebopClientBuilder::new(Chain::Ethereum, String::new())
688            .with_fallback_router()
689            .build()
690            .unwrap();
691        let price_data = BebopPriceData::default();
692
693        let component = |client: &BebopClient| {
694            client
695                .create_component_with_state(String::from("bebop"), vec![], &price_data, 0.0)
696                .component
697                .protocol_system
698        };
699
700        assert_eq!(component(&direct), BebopClient::PROTOCOL_SYSTEM);
701        assert_eq!(component(&via_router), BebopClient::FALLBACK_PROTOCOL_SYSTEM);
702        assert_eq!(
703            BebopClient::FALLBACK_PROTOCOL_SYSTEM,
704            tycho_execution::encoding::evm::BEBOP_FALLBACK_PROTOCOL_SYSTEM
705        );
706    }
707
708    #[rstest]
709    #[case::per_pair(ComponentLayout::PerPair)]
710    #[case::all_pairs(ComponentLayout::AllPairs)]
711    #[tokio::test]
712    #[ignore] // Requires network access and setting proper env vars
713    async fn test_bebop_websocket_connection(#[case] layout: ComponentLayout) {
714        // We test with quote tokens that are not USDC in order to ensure our normalization works
715        // fine
716        let wbtc = Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap();
717        let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
718
719        dotenv().expect("Missing .env file");
720        let auth = get_bebop_auth().expect("Failed to get Bebop authentication");
721
722        let quote_tokens = HashSet::from([
723            // Use addresses we forgot to checksum (to test checksumming)
724            Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(), // USDC
725            Bytes::from_str("0xdac17f958d2ee523a2206206994597c13d831ec7").unwrap(), // USDT
726        ]);
727
728        let client = BebopClient::new(
729            Chain::Ethereum,
730            HashSet::from_iter(vec![weth.clone(), wbtc.clone()]),
731            10.0, // $10 minimum TVL
732            auth.key,
733            quote_tokens,
734            Duration::from_secs(30),
735            None,
736            None,
737            None,
738        )
739        .unwrap()
740        .with_component_layout(layout);
741
742        let mut stream = client.stream();
743
744        // Test connection and message reception with timeout
745        // Receiving a single decodable pricing message is enough to prove the authenticated
746        // handshake and protobuf decoding work. Bebop only pushes on price changes, so
747        // requiring more messages makes the test flaky against market cadence.
748        let result = timeout(Duration::from_secs(10), async {
749            let mut message_count = 0;
750            let max_messages = 1;
751
752            while let Some(result) = stream.next().await {
753                match result {
754                    Ok((component_id, msg)) => {
755                        println!("Received message with ID: {component_id}");
756
757                        assert_eq!(component_id, client.stream_name());
758                        assert!(msg.header.timestamp > 0);
759
760                        let snapshot = &msg.snapshots;
761                        println!("Received {} components in this message", snapshot.states.len());
762                        match layout {
763                            ComponentLayout::PerPair => assert!(!snapshot.states.is_empty()),
764                            ComponentLayout::AllPairs => {
765                                assert_eq!(snapshot.states.len(), 1, "one all-pairs component")
766                            }
767                        }
768                        for (id, component_with_state) in &snapshot.states {
769                            assert_eq!(
770                                component_with_state
771                                    .component
772                                    .protocol_system,
773                                "rfq:bebop"
774                            );
775                            assert_eq!(component_with_state.component.chain, Chain::Ethereum);
776                            let attributes = &component_with_state.state.attributes;
777                            match layout {
778                                ComponentLayout::PerPair => {
779                                    assert!(
780                                        attributes.contains_key("bids") ||
781                                            attributes.contains_key("asks")
782                                    );
783                                }
784                                ComponentLayout::AllPairs => {
785                                    assert_eq!(
786                                        id,
787                                        &component::component_id(
788                                            BebopClient::PROTOCOL_SYSTEM,
789                                            client.chain
790                                        )
791                                    );
792                                    let books: Vec<BebopPriceData> =
793                                        serde_json::from_slice(&attributes[BOOKS_ATTRIBUTE])
794                                            .unwrap();
795                                    assert!(!books.is_empty());
796                                    let directions = &component_with_state
797                                        .component
798                                        .static_attributes[SWAP_DIRECTIONS_ATTRIBUTE];
799                                    assert_eq!(directions.len() % 40, 0);
800                                }
801                            }
802                            if let Some(tvl) = component_with_state.component_tvl {
803                                assert!(tvl >= 0.0);
804                                println!("Component {id} TVL: ${tvl:.2}");
805                            }
806                        }
807
808                        message_count += 1;
809                        if message_count >= max_messages {
810                            break;
811                        }
812                    }
813                    Err(e) => {
814                        panic!("Stream error: {e}");
815                    }
816                }
817            }
818
819            assert!(message_count > 0, "Should have received at least one message");
820            println!("Successfully received {message_count} messages");
821        })
822        .await;
823
824        match result {
825            Ok(_) => println!("Test completed successfully"),
826            Err(_) => panic!("Test timed out - no messages received within 10 seconds"),
827        }
828    }
829
830    #[tokio::test]
831    async fn test_websocket_reconnection() {
832        // Start a mock WebSocket server that will drop connections intermittently
833        let listener = TcpListener::bind("127.0.0.1:0")
834            .await
835            .unwrap();
836        let addr = listener.local_addr().unwrap();
837
838        // Creates a thread-safe counter.
839        let connection_count = Arc::new(Mutex::new(0u32));
840
841        // We must clone - since we want to read the original value at the end of the test.
842        let connection_count_clone = connection_count.clone();
843
844        tokio::spawn(async move {
845            while let Ok((stream, _)) = listener.accept().await {
846                *connection_count_clone.lock().unwrap() += 1;
847                let count = *connection_count_clone.lock().unwrap();
848                println!("Mock server: Connection #{count} established");
849
850                tokio::spawn(async move {
851                    if let Ok(ws_stream) = accept_async(stream).await {
852                        let (mut ws_sender, _ws_receiver) = ws_stream.split();
853
854                        // Create test protobuf message
855                        let weth_addr =
856                            hex::decode("C02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
857                        let usdc_addr =
858                            hex::decode("A0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap();
859
860                        let test_price_data = BebopPriceData {
861                            base: weth_addr,
862                            quote: usdc_addr,
863                            last_update_ts: 1752617378,
864                            bids: vec![3070.05f32, 0.325717f32],
865                            asks: vec![3070.527f32, 0.325717f32],
866                        };
867
868                        let pricing_update = BebopPricingUpdate { pairs: vec![test_price_data] };
869
870                        let test_message = pricing_update.encode_to_vec();
871
872                        if count == 1 {
873                            // First connection: Send message successfully, then drop
874                            println!("Mock server: Connection #1 - sending message then dropping.");
875                            let _ = ws_sender
876                                .send(Message::Binary(test_message.clone().into()))
877                                .await;
878
879                            // Give time for message to be processed, then drop the connection.
880                            tokio::time::sleep(Duration::from_millis(100)).await;
881                            println!("Mock server: Dropping connection #1");
882                            let _ = ws_sender.close().await;
883                        } else if count == 2 {
884                            // Second connection: Send message successfully and maintain connection
885                            println!("Mock server: Connection #2 - maintaining stable connection.");
886                            let _ = ws_sender
887                                .send(Message::Binary(test_message.clone().into()))
888                                .await;
889                        }
890                    }
891                });
892            }
893        });
894
895        // Wait a moment for the server to start
896        tokio::time::sleep(Duration::from_millis(50)).await;
897
898        let mut test_quote_tokens = HashSet::new();
899        test_quote_tokens
900            .insert(Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap());
901
902        let tokens_formatted = vec![
903            Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(),
904            Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(),
905        ];
906
907        // Bypass the new() constructor to mock the URL to point to our mock server.
908        let client = BebopClient {
909            chain: Chain::Ethereum,
910            price_ws: format!("ws://127.0.0.1:{}", addr.port()),
911            tokens: tokens_formatted.into_iter().collect(),
912            tvl: 1000.0,
913            ws_key: "test_key".to_string(),
914            quote_tokens: test_quote_tokens,
915            quote_endpoint: "".to_string(),
916            quote_timeout: Duration::from_secs(5),
917            origin_address: None,
918            origin_target: None,
919            origin_source: None,
920            protocol_system: BebopClient::PROTOCOL_SYSTEM.to_string(),
921            component_layout: ComponentLayout::PerPair,
922        };
923
924        let start_time = std::time::Instant::now();
925        let mut successful_messages = 0;
926        let mut connection_errors = 0;
927        let mut first_message_received = false;
928        let mut second_message_received = false;
929
930        // Expected flow:
931        // 1. Receive first message successfully
932        // 2. Connection drops
933        // 3. Client reconnects
934        // 4. Receive second message successfully
935        // Timeout if two messages are not received within 5 seconds.
936        while start_time.elapsed() < Duration::from_secs(5) && successful_messages < 2 {
937            match timeout(Duration::from_millis(1000), client.stream().next()).await {
938                Ok(Some(result)) => match result {
939                    Ok((_component_id, _message)) => {
940                        successful_messages += 1;
941                        println!("Received successful message {successful_messages}");
942
943                        if successful_messages == 1 {
944                            first_message_received = true;
945                            println!("First message received - connection should drop after this.");
946                        } else if successful_messages == 2 {
947                            second_message_received = true;
948                            println!("Second message received after reconnection.");
949                        }
950                    }
951                    Err(e) => {
952                        connection_errors += 1;
953                        println!("Connection error during reconnection: {e:?}");
954                    }
955                },
956                Ok(None) => {
957                    panic!("Stream ended unexpectedly");
958                }
959                Err(_) => {
960                    println!("Timeout waiting for message (normal during reconnections)");
961                    continue;
962                }
963            }
964        }
965
966        let final_connection_count = *connection_count.lock().unwrap();
967
968        // 1. Exactly 2 connection attempts (initial + reconnect)
969        // 2. Exactly 2 successful messages (one before drop, one after reconnect)
970
971        assert_eq!(final_connection_count, 2);
972        assert!(first_message_received);
973        assert!(second_message_received);
974        assert_eq!(connection_errors, 0);
975        assert_eq!(successful_messages, 2);
976    }
977
978    #[tokio::test]
979    #[ignore] // Requires network access and setting proper env vars
980    async fn test_bebop_quote_single_order() {
981        let token_in = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
982        let token_out = Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap();
983        dotenv().expect("Missing .env file");
984        let auth = get_bebop_auth().expect("Failed to get Bebop authentication");
985
986        let router = Bytes::from_str("0xfD0b31d2E955fA55e3fa641Fe90e08b677188d35").unwrap();
987
988        let client = BebopClient::new(
989            Chain::Ethereum,
990            HashSet::from_iter(vec![token_in.clone(), token_out.clone()]),
991            10.0, // $10 minimum TVL
992            auth.key,
993            HashSet::new(),
994            Duration::from_secs(30),
995            Some(Bytes::from_str("0x00000000219ab540356cBB839Cbe05303d7705Fa").unwrap()),
996            Some(router.clone()),
997            Some("tycho-test".to_string()),
998        )
999        .unwrap();
1000
1001        let params = GetAmountOutParams {
1002            amount_in: BigUint::from(1_000000000000000000u64),
1003            token_in: token_in.clone(),
1004            token_out: token_out.clone(),
1005            sender: router.clone(),
1006            receiver: router,
1007            origin: None,
1008        };
1009        let quote = client
1010            .request_binding_quote(&params)
1011            .await
1012            .unwrap();
1013
1014        assert_eq!(quote.base_token, token_in);
1015        assert_eq!(quote.quote_token, token_out);
1016        assert_eq!(quote.amount_in, BigUint::from(1_000000000000000000u64));
1017
1018        // Conservative sanity bound (0.01 WBTC for 1 WETH) — proves a real, non-dust quote came
1019        // back without depending closely on the live WETH/WBTC price.
1020        assert!(quote.amount_out > BigUint::from(1_000_000u64));
1021
1022        // The settlement mode depends on the API account configuration behind BEBOP_KEY:
1023        // settlement-mode accounts get BebopSettlement.swapSingle calldata, router-mode
1024        // accounts get BebopRouter.swap calldata.
1025        let selector = &quote
1026            .quote_attributes
1027            .get("calldata")
1028            .unwrap()[..4];
1029        if selector == SWAP_SINGLE_SELECTOR {
1030            let partial_fill_offset_slice = quote
1031                .quote_attributes
1032                .get("partial_fill_offset")
1033                .unwrap()
1034                .as_ref();
1035            let mut partial_fill_offset_array = [0u8; 8];
1036            partial_fill_offset_array.copy_from_slice(partial_fill_offset_slice);
1037
1038            assert_eq!(u64::from_be_bytes(partial_fill_offset_array), 12);
1039        } else {
1040            assert_eq!(selector, ROUTER_SWAP_SELECTOR);
1041        }
1042    }
1043
1044    #[tokio::test]
1045    #[ignore] // Requires network access and setting proper env vars
1046    async fn test_bebop_quote_aggregate_order() {
1047        // This will make a quote request similar to the previous test but with a very big amount
1048        // We expect the Bebop Quote to have an aggregate order (split between different mms)
1049        let token_in = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap();
1050        let token_out = Bytes::from_str("0xfAbA6f8e4a5E8Ab82F62fe7C39859FA577269BE3").unwrap();
1051        dotenv().expect("Missing .env file");
1052        let auth = get_bebop_auth().expect("Failed to get Bebop authentication");
1053
1054        let router = Bytes::from_str("0xfD0b31d2E955fA55e3fa641Fe90e08b677188d35").unwrap();
1055
1056        let client = BebopClient::new(
1057            Chain::Ethereum,
1058            HashSet::from_iter(vec![token_in.clone(), token_out.clone()]),
1059            10.0, // $10 minimum TVL
1060            auth.key,
1061            HashSet::new(),
1062            Duration::from_secs(30),
1063            Some(Bytes::from_str("0x00000000219ab540356cBB839Cbe05303d7705Fa").unwrap()),
1064            Some(router.clone()),
1065            Some("tycho-test".to_string()),
1066        )
1067        .unwrap();
1068
1069        let amount_in = BigUint::from_str("20_000_000_000").unwrap(); // 20k USDC
1070        let params = GetAmountOutParams {
1071            amount_in: amount_in.clone(),
1072            token_in: token_in.clone(),
1073            token_out: token_out.clone(),
1074            sender: router.clone(),
1075            receiver: router,
1076            origin: None,
1077        };
1078        let quote = client
1079            .request_binding_quote(&params)
1080            .await
1081            .unwrap();
1082
1083        assert_eq!(quote.base_token, token_in);
1084        assert_eq!(quote.quote_token, token_out);
1085        assert_eq!(quote.amount_in, amount_in);
1086
1087        // Assuming the USDC - ONDO price doesn't change too much at the time of running this
1088        assert!(quote.amount_out > BigUint::from_str("18000000000000000000000").unwrap()); // ~19k ONDO
1089
1090        // The settlement mode depends on the API account configuration behind BEBOP_KEY:
1091        // settlement-mode accounts get BebopSettlement.swapAggregate calldata, router-mode
1092        // accounts get BebopRouter.swap calldata.
1093        let selector = &quote
1094            .quote_attributes
1095            .get("calldata")
1096            .unwrap()[..4];
1097        if selector == SWAP_AGGREGATE_SELECTOR {
1098            let partial_fill_offset_slice = quote
1099                .quote_attributes
1100                .get("partial_fill_offset")
1101                .unwrap()
1102                .as_ref();
1103            let mut partial_fill_offset_array = [0u8; 8];
1104            partial_fill_offset_array.copy_from_slice(partial_fill_offset_slice);
1105
1106            // This is the only attribute that is significantly different for the Single and
1107            // Aggregate Order
1108            assert_eq!(u64::from_be_bytes(partial_fill_offset_array), 2);
1109        } else {
1110            assert_eq!(selector, ROUTER_SWAP_SELECTOR);
1111        }
1112    }
1113
1114    #[test]
1115    fn test_process_bebop_quote_response_aggregate_order() {
1116        let json =
1117            std::fs::read_to_string("src/rfq/protocols/bebop/test_responses/aggregate_order.json")
1118                .unwrap();
1119        let quote_response: BebopQuoteResponse = serde_json::from_str(&json).unwrap();
1120        let params = GetAmountOutParams {
1121            amount_in: BigUint::from_str("20000000000").unwrap(),
1122            token_in: Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap(),
1123            token_out: Bytes::from_str("0xfAbA6f8e4a5E8Ab82F62fe7C39859FA577269BE3").unwrap(),
1124            sender: Bytes::from_str("0xfd0b31d2e955fa55e3fa641fe90e08b677188d35").unwrap(),
1125            receiver: Bytes::from_str("0xfd0b31d2e955fa55e3fa641fe90e08b677188d35").unwrap(),
1126            origin: None,
1127        };
1128        let res = BebopClient::process_quote_response(quote_response, &params).unwrap();
1129        assert_eq!(res.amount_out, BigUint::from_str("52571055094221715780641").unwrap());
1130        assert_eq!(res.amount_in, BigUint::from_str("20000000000").unwrap());
1131        assert_eq!(res.base_token, params.token_in);
1132        assert_eq!(res.quote_token, params.token_out);
1133    }
1134
1135    #[test]
1136    fn test_process_bebop_quote_response_aggregate_order_with_multihop() {
1137        let json = std::fs::read_to_string(
1138            "src/rfq/protocols/bebop/test_responses/aggregate_order_with_multihop.json",
1139        )
1140        .unwrap();
1141        let quote_response: BebopQuoteResponse = serde_json::from_str(&json).unwrap();
1142        let params = GetAmountOutParams {
1143            amount_in: BigUint::from_str("43067495979235520920162").unwrap(),
1144            token_in: Bytes::from_str("0xDEf1CA1fb7FBcDC777520aa7f396b4E015F497aB").unwrap(),
1145            token_out: Bytes::from_str("0xdAC17F958D2ee523a2206206994597C13D831ec7").unwrap(),
1146            sender: Bytes::from_str("0x809305d724B6E79C71e10a097ABadd1274B9C279").unwrap(),
1147            receiver: Bytes::from_str("0x809305d724B6E79C71e10a097ABadd1274B9C279").unwrap(),
1148            origin: None,
1149        };
1150        let res = BebopClient::process_quote_response(quote_response, &params).unwrap();
1151        assert_eq!(res.amount_out, BigUint::from_str("11186653890").unwrap());
1152        assert_eq!(res.amount_in, BigUint::from_str("43067495979235520920162").unwrap());
1153        assert_eq!(res.base_token, params.token_in);
1154        assert_eq!(res.quote_token, params.token_out);
1155    }
1156
1157    #[test]
1158    fn test_process_bebop_quote_response_single_order() {
1159        // Captured from a settlement-mode API account: the signed order's taker and receiver
1160        // are the requested sender/receiver and the calldata targets the settlement contract.
1161        let json =
1162            std::fs::read_to_string("src/rfq/protocols/bebop/test_responses/single_order.json")
1163                .unwrap();
1164        let quote_response: BebopQuoteResponse = serde_json::from_str(&json).unwrap();
1165        let router = Bytes::from_str("0xfD0b31d2E955fA55e3fa641Fe90e08b677188d35").unwrap();
1166        let params = GetAmountOutParams {
1167            amount_in: BigUint::from_str("1000000000000000000").unwrap(),
1168            token_in: Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(),
1169            token_out: Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap(),
1170            sender: router.clone(),
1171            receiver: router,
1172            origin: None,
1173        };
1174        let res = BebopClient::process_quote_response(quote_response, &params).unwrap();
1175        assert_eq!(res.amount_in, BigUint::from_str("1000000000000000000").unwrap());
1176        assert_eq!(res.amount_out, BigUint::from_str("2915408").unwrap());
1177        let settlement = Bytes::from_str("0xbbbbbBB520d69a9775E85b458C58c648259FAD5F").unwrap();
1178        assert_eq!(
1179            res.quote_attributes
1180                .get("tx_to")
1181                .unwrap(),
1182            &settlement
1183        );
1184        assert_eq!(
1185            res.quote_attributes
1186                .get("calldata")
1187                .unwrap()[..4],
1188            SWAP_SINGLE_SELECTOR
1189        );
1190    }
1191
1192    #[test]
1193    fn test_process_bebop_quote_response_single_order_router_mode() {
1194        // Captured from an API account configured for router-mode settlement: the signed
1195        // order's taker and receiver are the Bebop router contract (= tx.to), not the
1196        // requested sender/receiver.
1197        let json = std::fs::read_to_string(
1198            "src/rfq/protocols/bebop/test_responses/single_order_router_mode.json",
1199        )
1200        .unwrap();
1201        let quote_response: BebopQuoteResponse = serde_json::from_str(&json).unwrap();
1202        let router = Bytes::from_str("0xfD0b31d2E955fA55e3fa641Fe90e08b677188d35").unwrap();
1203        let params = GetAmountOutParams {
1204            amount_in: BigUint::from_str("1000000000000000000").unwrap(),
1205            token_in: Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap(),
1206            token_out: Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap(),
1207            sender: router.clone(),
1208            receiver: router,
1209            origin: None,
1210        };
1211        let res = BebopClient::process_quote_response(quote_response, &params).unwrap();
1212        assert_eq!(res.amount_in, BigUint::from_str("1000000000000000000").unwrap());
1213        assert_eq!(res.amount_out, BigUint::from_str("2926296").unwrap());
1214        let bebop_router = Bytes::from_str("0xBeb0009ACa35087ce7cCF11637E24dd1Aad3bf2A").unwrap();
1215        assert_eq!(
1216            res.quote_attributes
1217                .get("tx_to")
1218                .unwrap(),
1219            &bebop_router
1220        );
1221        assert_eq!(
1222            res.quote_attributes
1223                .get("calldata")
1224                .unwrap()[..4],
1225            ROUTER_SWAP_SELECTOR
1226        );
1227    }
1228
1229    /// Helper function to create a mock server that responds after a delay
1230    async fn create_delayed_response_server(delay_ms: u64) -> std::net::SocketAddr {
1231        use tokio::io::AsyncWriteExt;
1232
1233        let listener = TcpListener::bind("127.0.0.1:0")
1234            .await
1235            .unwrap();
1236        let addr = listener.local_addr().unwrap();
1237
1238        let json_response =
1239            std::fs::read_to_string("src/rfq/protocols/bebop/test_responses/aggregate_order.json")
1240                .unwrap();
1241
1242        tokio::spawn(async move {
1243            while let Ok((mut stream, _)) = listener.accept().await {
1244                let json_response_clone = json_response.clone();
1245                tokio::spawn(async move {
1246                    sleep(Duration::from_millis(delay_ms)).await;
1247
1248                    let response = format!(
1249                        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1250                        json_response_clone.len(),
1251                        json_response_clone
1252                    );
1253                    let _ = stream
1254                        .write_all(response.as_bytes())
1255                        .await;
1256                    let _ = stream.flush().await;
1257                    let _ = stream.shutdown().await;
1258                });
1259            }
1260        });
1261
1262        addr
1263    }
1264
1265    fn create_test_bebop_client(quote_endpoint: String, quote_timeout: Duration) -> BebopClient {
1266        let token_in = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
1267        let token_out = Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap();
1268
1269        BebopClient {
1270            chain: Chain::Ethereum,
1271            price_ws: "ws://example.com".to_string(),
1272            quote_endpoint,
1273            tokens: HashSet::from([token_in, token_out]),
1274            tvl: 10.0,
1275            ws_key: "test_key".to_string(),
1276            quote_tokens: HashSet::new(),
1277            quote_timeout,
1278            origin_address: None,
1279            origin_target: None,
1280            origin_source: None,
1281            protocol_system: BebopClient::PROTOCOL_SYSTEM.to_string(),
1282            component_layout: ComponentLayout::PerPair,
1283        }
1284    }
1285
1286    /// Helper function to create test quote params matching aggregate_order.json
1287    fn create_test_quote_params() -> GetAmountOutParams {
1288        let token_in = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap();
1289        let token_out = Bytes::from_str("0xfAbA6f8e4a5E8Ab82F62fe7C39859FA577269BE3").unwrap();
1290        let router = Bytes::from_str("0xfD0b31d2E955fA55e3fa641Fe90e08b677188d35").unwrap();
1291
1292        GetAmountOutParams {
1293            amount_in: BigUint::from_str("20000000000").unwrap(),
1294            token_in,
1295            token_out,
1296            sender: router.clone(),
1297            receiver: router,
1298            origin: None,
1299        }
1300    }
1301
1302    fn price_data(base: &Bytes, quote: &Bytes, bids: &[f32], asks: &[f32]) -> BebopPriceData {
1303        BebopPriceData {
1304            base: base.to_vec(),
1305            quote: quote.to_vec(),
1306            last_update_ts: 1,
1307            bids: bids.to_vec(),
1308            asks: asks.to_vec(),
1309        }
1310    }
1311
1312    /// WETH and WBTC quoted against USDC, with USDC as the TVL quote token.
1313    fn all_pairs_test_client() -> (BebopClient, Bytes, Bytes, Bytes) {
1314        let weth = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
1315        let wbtc = Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap();
1316        let usdc = Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap();
1317        let mut client =
1318            create_test_bebop_client("http://unused/quote".to_string(), Duration::from_secs(1));
1319        client.tokens = HashSet::from([weth.clone(), wbtc.clone(), usdc.clone()]);
1320        client.quote_tokens = HashSet::from([usdc.clone()]);
1321        client.tvl = 100.0;
1322        (client, weth, wbtc, usdc)
1323    }
1324
1325    #[test]
1326    fn test_all_pairs_component_keeps_books_above_tvl_threshold() {
1327        let (client, weth, wbtc, usdc) = all_pairs_test_client();
1328        let update = BebopPricingUpdate {
1329            pairs: vec![
1330                price_data(&weth, &usdc, &[3000.0, 1.0], &[]),
1331                price_data(&wbtc, &usdc, &[65.0, 0.001], &[65.0, 0.001]),
1332            ],
1333        };
1334
1335        let component = client
1336            .all_pairs_component(&update)
1337            .unwrap()
1338            .expect("the WETH book clears the threshold");
1339
1340        assert_eq!(
1341            component.component.id,
1342            component::component_id(BebopClient::PROTOCOL_SYSTEM, client.chain)
1343        );
1344        // Bid TVL 3000, ask TVL 0, averaged. The WBTC book is below the threshold.
1345        assert_eq!(component.component_tvl, Some(1500.0));
1346        let mut expected_tokens = vec![weth.clone(), usdc.clone()];
1347        expected_tokens.sort();
1348        assert_eq!(component.component.tokens, expected_tokens);
1349        let mut expected_directions = weth.to_vec();
1350        expected_directions.extend_from_slice(&usdc);
1351        assert_eq!(
1352            component.component.static_attributes[SWAP_DIRECTIONS_ATTRIBUTE].to_vec(),
1353            expected_directions,
1354            "bids only, so WETH in and USDC out is the one direction"
1355        );
1356        assert_eq!(
1357            component.component.static_attributes[QuoteRule::ATTRIBUTE].as_ref(),
1358            b"once_per_venue"
1359        );
1360        let books: Vec<BebopPriceData> =
1361            serde_json::from_slice(&component.state.attributes[BOOKS_ATTRIBUTE]).unwrap();
1362        assert_eq!(books.len(), 1);
1363        assert_eq!(books[0].base, weth.to_vec());
1364    }
1365
1366    #[test]
1367    fn test_all_pairs_component_asks_quote_the_reverse_direction() {
1368        let (client, weth, _, usdc) = all_pairs_test_client();
1369        let update =
1370            BebopPricingUpdate { pairs: vec![price_data(&weth, &usdc, &[], &[3000.0, 1.0])] };
1371
1372        let component = client
1373            .all_pairs_component(&update)
1374            .unwrap()
1375            .unwrap();
1376
1377        let mut expected_directions = usdc.to_vec();
1378        expected_directions.extend_from_slice(&weth);
1379        assert_eq!(
1380            component.component.static_attributes[SWAP_DIRECTIONS_ATTRIBUTE].to_vec(),
1381            expected_directions
1382        );
1383    }
1384
1385    #[test]
1386    fn test_all_pairs_component_converts_tvl_through_approved_quote_token() {
1387        let (client, weth, wbtc, usdc) = all_pairs_test_client();
1388        let update = BebopPricingUpdate {
1389            pairs: vec![
1390                price_data(&weth, &wbtc, &[0.05, 1.0], &[]),
1391                price_data(&wbtc, &usdc, &[65000.0, 1.0], &[65000.0, 1.0]),
1392            ],
1393        };
1394
1395        let component = client
1396            .all_pairs_component(&update)
1397            .unwrap()
1398            .unwrap();
1399
1400        // TVL only: the WBTC/USDC book converts the WETH/WBTC book's WBTC value to USDC.
1401        // WETH/WBTC: 0.025 WBTC, worth 1625 USDC. WBTC/USDC: 65000 USDC.
1402        let tvl = component.component_tvl.unwrap();
1403        assert!((tvl - 66625.0).abs() < 1e-3, "{tvl}");
1404        let books: Vec<BebopPriceData> =
1405            serde_json::from_slice(&component.state.attributes[BOOKS_ATTRIBUTE]).unwrap();
1406        assert_eq!(books.len(), 2);
1407    }
1408
1409    #[test]
1410    fn test_all_pairs_component_drops_book_without_tvl_conversion() {
1411        let (client, weth, wbtc, _) = all_pairs_test_client();
1412        let update =
1413            BebopPricingUpdate { pairs: vec![price_data(&weth, &wbtc, &[0.05, 1.0], &[])] };
1414        assert!(client
1415            .all_pairs_component(&update)
1416            .unwrap()
1417            .is_none());
1418    }
1419
1420    /// Each component carries the id and attributes the per-pair state decodes.
1421    #[test]
1422    fn test_pair_components_keep_pairs_above_tvl_threshold() {
1423        let (client, weth, wbtc, usdc) = all_pairs_test_client();
1424        let update = BebopPricingUpdate {
1425            pairs: vec![
1426                price_data(&weth, &usdc, &[3000.0, 1.0], &[3001.0, 1.0]),
1427                price_data(&wbtc, &usdc, &[65.0, 0.001], &[]),
1428            ],
1429        };
1430
1431        let components = client.pair_components(&update);
1432
1433        let pair_str = format!("bebop_{}/{}", hex::encode(&weth), hex::encode(&usdc));
1434        let id = keccak256(pair_str.as_bytes()).to_string();
1435        assert_eq!(
1436            components.keys().collect::<Vec<_>>(),
1437            [&id],
1438            "the WBTC pair is below the threshold"
1439        );
1440        let component = &components[&id];
1441        assert_eq!(component.component.tokens, vec![weth, usdc]);
1442        assert_eq!(component.state.attributes["bids"].as_ref(), b"[[3000.0,1.0]]");
1443        assert_eq!(component.state.attributes["asks"].as_ref(), b"[[3001.0,1.0]]");
1444        assert!(component
1445            .component
1446            .static_attributes
1447            .is_empty());
1448    }
1449
1450    #[tokio::test]
1451    async fn test_bebop_quote_timeout() {
1452        let addr = create_delayed_response_server(500).await;
1453
1454        // Test 1: Client with short timeout (200ms) - should timeout
1455        let client_short_timeout = create_test_bebop_client(
1456            format!("http://127.0.0.1:{}/quote", addr.port()),
1457            Duration::from_millis(200),
1458        );
1459        let params = create_test_quote_params();
1460
1461        let start = std::time::Instant::now();
1462        let result = client_short_timeout
1463            .request_binding_quote(&params)
1464            .await;
1465        let elapsed = start.elapsed();
1466
1467        assert!(result.is_err());
1468        let err = result.unwrap_err();
1469        match err {
1470            RFQError::ConnectionError(msg) => {
1471                assert!(msg.contains("timed out"), "Expected timeout error, got: {}", msg);
1472            }
1473            _ => panic!("Expected ConnectionError, got: {:?}", err),
1474        }
1475        assert!(
1476            elapsed.as_millis() >= 200 && elapsed.as_millis() < 400,
1477            "Expected timeout around 200ms, got: {:?}",
1478            elapsed
1479        );
1480
1481        // Test 2: Client with long timeout (1 seconds) - should wait and receive response
1482        // Note: With retry logic, we may need multiple attempts if the response is malformed,
1483        // so we need a longer timeout to account for retries
1484        let client_long_timeout = create_test_bebop_client(
1485            format!("http://127.0.0.1:{}/quote", addr.port()),
1486            Duration::from_secs(1),
1487        );
1488
1489        let result = client_long_timeout
1490            .request_binding_quote(&params)
1491            .await;
1492
1493        // Should succeed - the server waits 500ms which is within the 1s timeout
1494        assert!(result.is_ok(), "Expected success, got: {:?}", result);
1495        let quote = result.unwrap();
1496
1497        // Verify the quote matches what we expect from aggregate_order.json
1498        assert_eq!(quote.base_token, params.token_in);
1499        assert_eq!(quote.quote_token, params.token_out);
1500    }
1501
1502    /// Helper function to create a mock server that fails twice, then succeeds with
1503    /// aggregate_order.json
1504    async fn create_retry_server() -> (std::net::SocketAddr, Arc<Mutex<u32>>) {
1505        use std::sync::{Arc, Mutex};
1506
1507        use tokio::io::AsyncWriteExt;
1508
1509        let request_count = Arc::new(Mutex::new(0u32));
1510        let request_count_clone = request_count.clone();
1511
1512        let listener = TcpListener::bind("127.0.0.1:0")
1513            .await
1514            .unwrap();
1515        let addr = listener.local_addr().unwrap();
1516
1517        let json_response =
1518            std::fs::read_to_string("src/rfq/protocols/bebop/test_responses/aggregate_order.json")
1519                .unwrap();
1520
1521        tokio::spawn(async move {
1522            while let Ok((mut stream, _)) = listener.accept().await {
1523                let count_clone = request_count_clone.clone();
1524                let json_response_clone = json_response.clone();
1525                tokio::spawn(async move {
1526                    *count_clone.lock().unwrap() += 1;
1527                    let count = *count_clone.lock().unwrap();
1528                    println!("Mock server: Received request #{count}");
1529
1530                    if count <= 2 {
1531                        let response = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 21\r\n\r\nInternal Server Error";
1532                        let _ = stream
1533                            .write_all(response.as_bytes())
1534                            .await;
1535                    } else {
1536                        let response = format!(
1537                            "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{}",
1538                            json_response_clone.len(),
1539                            json_response_clone
1540                        );
1541                        let _ = stream
1542                            .write_all(response.as_bytes())
1543                            .await;
1544                    }
1545                    let _ = stream.flush().await;
1546                    let _ = stream.shutdown().await;
1547                });
1548            }
1549        });
1550        (addr, request_count)
1551    }
1552
1553    #[tokio::test]
1554    async fn test_bebop_quote_retry_on_bad_response() {
1555        let (addr, request_count) = create_retry_server().await;
1556
1557        let client = create_test_bebop_client(
1558            format!("http://127.0.0.1:{}/quote", addr.port()),
1559            Duration::from_secs(5),
1560        );
1561        let params = create_test_quote_params();
1562        let result = client
1563            .request_binding_quote(&params)
1564            .await;
1565
1566        assert!(result.is_ok(), "Expected success after retries, got: {:?}", result);
1567        let quote = result.unwrap();
1568
1569        // Verify the quote (amounts from aggregate_order.json)
1570        assert_eq!(quote.amount_in, BigUint::from_str("20000000000").unwrap());
1571        assert_eq!(quote.amount_out, BigUint::from_str("52571055094221715780641").unwrap());
1572
1573        // Verify exactly 3 requests were made (2 failures + 1 success)
1574        let final_count = *request_count.lock().unwrap();
1575        assert_eq!(final_count, 3, "Expected 3 requests, got {}", final_count);
1576    }
1577
1578    /// Sends one binding quote request and asserts the server received `expected` as its only
1579    /// `origin_address`.
1580    async fn assert_origin_sent(
1581        client_origin: Option<Bytes>,
1582        request_origin: Option<Bytes>,
1583        expected: &Bytes,
1584    ) {
1585        let mut server = mockito::Server::new_async().await;
1586        let mock = server
1587            .mock("GET", mockito::Matcher::Any)
1588            .match_query(mockito::Matcher::UrlEncoded(
1589                "origin_address".into(),
1590                bytes_to_address(expected)
1591                    .unwrap()
1592                    .to_string(),
1593            ))
1594            .with_body(include_str!("test_responses/aggregate_order.json"))
1595            .create_async()
1596            .await;
1597        let mut client = create_test_bebop_client(server.url(), Duration::from_secs(5));
1598        client.origin_address = client_origin;
1599        let mut params = create_test_quote_params();
1600        params.origin = request_origin;
1601
1602        client
1603            .request_binding_quote(&params)
1604            .await
1605            .unwrap();
1606
1607        mock.assert_async().await;
1608    }
1609
1610    #[tokio::test]
1611    async fn test_binding_quote_request_origin_overrides_client_default() {
1612        let request_origin = Bytes::from_str("0x1111111111111111111111111111111111111111").unwrap();
1613        let client_origin = Bytes::from_str("0x2222222222222222222222222222222222222222").unwrap();
1614
1615        assert_origin_sent(Some(client_origin), Some(request_origin.clone()), &request_origin)
1616            .await;
1617    }
1618
1619    #[tokio::test]
1620    async fn test_binding_quote_uses_client_default_origin_without_request_origin() {
1621        let client_origin = Bytes::from_str("0x2222222222222222222222222222222222222222").unwrap();
1622
1623        assert_origin_sent(Some(client_origin.clone()), None, &client_origin).await;
1624    }
1625
1626    #[test]
1627    fn test_bebop_client_serialize_deserialize_roundtrip() {
1628        let token_in = Bytes::from_str("0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2").unwrap();
1629        let token_out = Bytes::from_str("0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599").unwrap();
1630        let quote_token = Bytes::from_str("0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap();
1631
1632        let original = BebopClient {
1633            chain: Chain::Ethereum,
1634            price_ws: "wss://api.bebop.xyz/pricing".to_string(),
1635            quote_endpoint: "https://api.bebop.xyz/quote".to_string(),
1636            tokens: HashSet::from([token_in.clone(), token_out.clone()]),
1637            tvl: 50.5,
1638            ws_key: "secret_key".to_string(),
1639            quote_tokens: HashSet::from([quote_token.clone()]),
1640            quote_timeout: Duration::from_millis(5500),
1641            origin_address: Some(
1642                Bytes::from_str("0x00000000219ab540356cBB839Cbe05303d7705Fa").unwrap(),
1643            ),
1644            origin_target: Some(
1645                Bytes::from_str("0xdA892C989d07A18B5DD3F392d949f00dF15C5736").unwrap(),
1646            ),
1647            origin_source: Some("tycho".to_string()),
1648            protocol_system: BebopClient::PROTOCOL_SYSTEM.to_string(),
1649            component_layout: ComponentLayout::AllPairs,
1650        };
1651
1652        let serialized = serde_json::to_string(&original).unwrap();
1653        let deserialized: BebopClient = serde_json::from_str(&serialized).unwrap();
1654
1655        // Fields that should round-trip correctly
1656        assert_eq!(deserialized.chain, original.chain);
1657        assert_eq!(deserialized.price_ws, original.price_ws);
1658        assert_eq!(deserialized.quote_endpoint, original.quote_endpoint);
1659        assert_eq!(deserialized.tokens, original.tokens);
1660        assert_eq!(deserialized.tvl, original.tvl);
1661        assert_eq!(deserialized.quote_tokens, original.quote_tokens);
1662        assert_eq!(deserialized.quote_timeout, original.quote_timeout);
1663        assert_eq!(deserialized.origin_address, original.origin_address);
1664        assert_eq!(deserialized.origin_target, original.origin_target);
1665        assert_eq!(deserialized.origin_source, original.origin_source);
1666
1667        // ws_key should NOT round-trip (skip_serializing + default)
1668        assert_eq!(deserialized.ws_key, "");
1669        assert_ne!(deserialized.ws_key, original.ws_key);
1670    }
1671
1672    #[test]
1673    fn test_bebop_client_deserialize_with_credentials() {
1674        // When ws_key is provided in JSON, it should be deserialized
1675        // (skip_serializing only affects serialization, not deserialization)
1676        let json = r#"{
1677            "chain": "ethereum",
1678            "price_ws": "wss://api.bebop.xyz/pricing",
1679            "quote_endpoint": "https://api.bebop.xyz/quote",
1680            "tokens": [],
1681            "tvl": 10.0,
1682            "ws_key": "provided_key",
1683            "quote_tokens": [],
1684            "quote_timeout": {"secs": 30, "nanos": 0}
1685        }"#;
1686
1687        let client: BebopClient = serde_json::from_str(json).unwrap();
1688
1689        // Credentials should be deserialized from JSON
1690        assert_eq!(client.ws_key, "provided_key");
1691    }
1692
1693    #[test]
1694    fn test_process_bebop_quote_response_aggregate_order_router_mode() {
1695        // Captured from a router-mode API account: an aggregate order split across three
1696        // makers where the signed order's taker and receiver are the Bebop router (= tx.to).
1697        let json = std::fs::read_to_string(
1698            "src/rfq/protocols/bebop/test_responses/aggregate_order_router_mode.json",
1699        )
1700        .unwrap();
1701        let quote_response: BebopQuoteResponse = serde_json::from_str(&json).unwrap();
1702        let router = Bytes::from_str("0xfD0b31d2E955fA55e3fa641Fe90e08b677188d35").unwrap();
1703        let params = GetAmountOutParams {
1704            amount_in: BigUint::from_str("20000000000").unwrap(),
1705            token_in: Bytes::from_str("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(),
1706            token_out: Bytes::from_str("0xfAbA6f8e4a5E8Ab82F62fe7C39859FA577269BE3").unwrap(),
1707            sender: router.clone(),
1708            receiver: router,
1709            origin: None,
1710        };
1711        let res = BebopClient::process_quote_response(quote_response, &params).unwrap();
1712        assert_eq!(res.amount_in, BigUint::from_str("20000000000").unwrap());
1713        assert_eq!(res.amount_out, BigUint::from_str("52577858553072299423490").unwrap());
1714        let bebop_router = Bytes::from_str("0xBeb0009ACa35087ce7cCF11637E24dd1Aad3bf2A").unwrap();
1715        assert_eq!(
1716            res.quote_attributes
1717                .get("tx_to")
1718                .unwrap(),
1719            &bebop_router
1720        );
1721        assert_eq!(
1722            res.quote_attributes
1723                .get("calldata")
1724                .unwrap()[..4],
1725            ROUTER_SWAP_SELECTOR
1726        );
1727    }
1728}