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