Skip to main content

tycho_simulation/rfq/protocols/bebop/
client.rs

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