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