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