1use std::{
2 cmp::max,
3 collections::{HashMap, HashSet},
4 env,
5 time::Duration,
6};
7
8use thiserror::Error;
9use tokio::{
10 sync::{mpsc::Receiver, Semaphore},
11 task::JoinHandle,
12 time::timeout,
13};
14use tracing::{info, warn};
15use tycho_common::{
16 dto::{PaginationLimits, ProtocolSystemsRequestBody},
17 models::{
18 chain_config::{init_chain_registry, ChainConfigRegistry},
19 Chain, ExtractorIdentity,
20 },
21};
22
23use crate::{
24 client_metadata::serialize_client_metadata,
25 deltas::{DeltasClient, DeltasError, DEFAULT_RECONNECTING_SUBSCRIPTION_BUFFER_SIZE},
26 feed::{
27 component_tracker::ComponentFilter,
28 synchronizer::{ProtocolStateSynchronizer, StateSynchronizer},
29 BlockHeader, BlockSynchronizer, BlockSynchronizerError, FeedMessage,
30 },
31 rpc::{HttpRPCClientOptions, ProtocolSystemsParams, RPCClient},
32 HttpRPCClient, WsDeltasClient,
33};
34
35#[derive(Error, Debug)]
36pub enum StreamError {
37 #[error("Error during stream set up: {0}")]
38 SetUpError(String),
39
40 #[error("WebSocket client connection error: {0}")]
41 WebSocketConnectionError(String),
42
43 #[error("BlockSynchronizer error: {0}")]
44 BlockSynchronizerError(String),
45}
46
47#[non_exhaustive]
48#[derive(Clone, Debug)]
49pub enum RetryConfiguration {
50 Constant(ConstantRetryConfiguration),
51}
52
53impl RetryConfiguration {
54 pub fn constant(max_attempts: u64, cooldown: Duration) -> Self {
55 RetryConfiguration::Constant(ConstantRetryConfiguration { max_attempts, cooldown })
56 }
57}
58
59#[derive(Clone, Debug)]
60pub struct ConstantRetryConfiguration {
61 max_attempts: u64,
62 cooldown: Duration,
63}
64
65fn validate_chain_config() -> Result<(), StreamError> {
72 let registry = ChainConfigRegistry::load_default()
73 .map_err(|e| StreamError::SetUpError(format!("failed to load custom chain config: {e}")))?;
74 let _ = init_chain_registry(registry);
75 Ok(())
76}
77
78fn validate_subscription_buffer_size(subscription_buffer_size: usize) -> Result<(), StreamError> {
79 if subscription_buffer_size == 0 {
80 return Err(StreamError::SetUpError(
81 "subscription buffer size must be greater than zero".to_string(),
82 ));
83 }
84
85 if subscription_buffer_size > Semaphore::MAX_PERMITS {
86 return Err(StreamError::SetUpError(format!(
87 "subscription buffer size must not exceed {} (Tokio's maximum channel capacity); \
88 choose a value between 1 and {}",
89 Semaphore::MAX_PERMITS,
90 Semaphore::MAX_PERMITS,
91 )));
92 }
93
94 Ok(())
95}
96
97pub struct TychoStreamBuilder {
98 tycho_url: String,
99 chain: Chain,
100 exchanges: HashMap<String, ComponentFilter>,
101 blocklisted_ids: HashSet<String>,
102 block_time: u64,
103 timeout: u64,
104 startup_timeout: Duration,
105 max_missed_blocks: u64,
106 state_sync_retry_config: RetryConfiguration,
107 websockets_retry_config: RetryConfiguration,
108 no_state: bool,
109 auth_key: Option<String>,
110 no_tls: bool,
111 include_tvl: bool,
112 compression: bool,
113 partial_blocks: bool,
114 max_messages: Option<usize>,
115 client_metadata: HashMap<String, String>,
116 subscription_buffer_size: usize,
117}
118
119impl TychoStreamBuilder {
120 pub fn new(tycho_url: &str, chain: Chain) -> Self {
123 let (block_time, timeout, max_missed_blocks) = Self::default_timing(&chain);
124 Self {
125 tycho_url: tycho_url.to_string(),
126 chain,
127 exchanges: HashMap::new(),
128 blocklisted_ids: HashSet::new(),
129 block_time,
130 timeout,
131 startup_timeout: Duration::from_secs(block_time * max_missed_blocks),
132 max_missed_blocks,
133 state_sync_retry_config: RetryConfiguration::constant(
134 32,
135 Duration::from_secs(max(block_time / 4, 2)),
136 ),
137 websockets_retry_config: RetryConfiguration::constant(
138 128,
139 Duration::from_secs(max(block_time / 6, 1)),
140 ),
141 no_state: false,
142 auth_key: None,
143 no_tls: true,
144 include_tvl: false,
145 compression: true,
146 partial_blocks: false,
147 max_messages: None,
148 client_metadata: HashMap::new(),
149 subscription_buffer_size: DEFAULT_RECONNECTING_SUBSCRIPTION_BUFFER_SIZE,
150 }
151 }
152
153 fn default_timing(chain: &Chain) -> (u64, u64, u64) {
156 match chain {
157 Chain::Ethereum => (12, 36, 50),
158 Chain::Starknet => (2, 8, 50),
159 Chain::ZkSync => (3, 12, 50),
160 Chain::Arbitrum => (1, 2, 100), Chain::Base => (2, 12, 50),
162 Chain::Bsc => (1, 12, 50),
163 Chain::Unichain => (1, 10, 100),
164 Chain::Polygon => (2, 12, 50), Chain::Plasma => (1, 10, 100), Chain::Robinhood => (1, 5, 100), Chain::Arc => (1, 5, 100), _ => {
169 let block_time = chain.block_time_secs();
170 (block_time, block_time * 3, 50)
171 }
172 }
173 }
174
175 pub fn exchange(mut self, name: &str, filter: ComponentFilter) -> Self {
177 self.exchanges
178 .insert(name.to_string(), filter);
179 self
180 }
181
182 pub fn block_time(mut self, block_time: u64) -> Self {
184 self.block_time = block_time;
185 self
186 }
187
188 pub fn timeout(mut self, timeout: u64) -> Self {
190 self.timeout = timeout;
191 self
192 }
193
194 pub fn startup_timeout(mut self, timeout: Duration) -> Self {
195 self.startup_timeout = timeout;
196 self
197 }
198
199 pub fn max_missed_blocks(mut self, max_missed_blocks: u64) -> Self {
200 self.max_missed_blocks = max_missed_blocks;
201 self
202 }
203
204 pub fn websockets_retry_config(mut self, retry_config: &RetryConfiguration) -> Self {
205 self.websockets_retry_config = retry_config.clone();
206 self.warn_on_potential_timing_issues();
207 self
208 }
209
210 pub fn state_synchronizer_retry_config(mut self, retry_config: &RetryConfiguration) -> Self {
211 self.state_sync_retry_config = retry_config.clone();
212 self.warn_on_potential_timing_issues();
213 self
214 }
215
216 fn warn_on_potential_timing_issues(&self) {
217 let (RetryConfiguration::Constant(state_config), RetryConfiguration::Constant(ws_config)) =
218 (&self.state_sync_retry_config, &self.websockets_retry_config);
219
220 if ws_config.cooldown >= state_config.cooldown {
221 warn!(
222 "Websocket cooldown should be < than state syncronizer cooldown \
223 to avoid spending retries due to disconnected websocket."
224 )
225 }
226 }
227
228 pub fn no_state(mut self, no_state: bool) -> Self {
230 self.no_state = no_state;
231 self
232 }
233
234 pub fn auth_key(mut self, auth_key: Option<String>) -> Self {
239 self.auth_key = auth_key;
240 self.no_tls = false;
241 self
242 }
243
244 pub fn add_client_metadata<I, K, V>(mut self, metadata: I) -> Self
254 where
255 I: IntoIterator<Item = (K, V)>,
256 K: Into<String>,
257 V: Into<String>,
258 {
259 self.client_metadata.extend(
260 metadata
261 .into_iter()
262 .map(|(k, v)| (k.into(), v.into())),
263 );
264 self
265 }
266
267 pub fn no_tls(mut self, no_tls: bool) -> Self {
269 self.no_tls = no_tls;
270 self
271 }
272
273 pub fn include_tvl(mut self, include_tvl: bool) -> Self {
277 self.include_tvl = include_tvl;
278 self
279 }
280
281 pub fn disable_compression(mut self) -> Self {
284 self.compression = false;
285 self
286 }
287
288 pub fn enable_partial_blocks(mut self) -> Self {
290 self.partial_blocks = true;
291 self
292 }
293
294 pub fn subscription_buffer_size(mut self, subscription_buffer_size: usize) -> Self {
299 self.subscription_buffer_size = subscription_buffer_size;
300 self
301 }
302
303 pub fn max_messages(mut self, n: usize) -> Self {
306 self.max_messages = Some(n);
307 self
308 }
309
310 pub fn max_retries(mut self, max_retries: u64) -> Self {
313 let cooldown = match &self.state_sync_retry_config {
314 RetryConfiguration::Constant(c) => c.cooldown,
315 };
316 self.state_sync_retry_config = RetryConfiguration::constant(max_retries, cooldown);
317 self
318 }
319
320 pub fn blocklisted_ids(mut self, ids: impl IntoIterator<Item = String>) -> Self {
325 self.blocklisted_ids.extend(ids);
326 self
327 }
328
329 pub(crate) fn build_ws_deltas_client(
332 &self,
333 ws_uri: &str,
334 auth_key: Option<&str>,
335 client_metadata_header: Option<String>,
336 ) -> Result<WsDeltasClient, StreamError> {
337 validate_subscription_buffer_size(self.subscription_buffer_size)?;
338
339 let ws_client = match &self.websockets_retry_config {
340 RetryConfiguration::Constant(config) => WsDeltasClient::new_with_reconnects(
341 ws_uri,
342 auth_key,
343 config.max_attempts,
344 config.cooldown,
345 ),
346 }
347 .map_err(|e| StreamError::SetUpError(e.to_string()))?
348 .with_subscription_buffer_size(self.subscription_buffer_size)
349 .with_client_metadata_header(client_metadata_header);
350
351 Ok(ws_client)
352 }
353
354 pub async fn build(
357 self,
358 ) -> Result<
359 (JoinHandle<()>, Receiver<Result<FeedMessage<BlockHeader>, BlockSynchronizerError>>),
360 StreamError,
361 > {
362 validate_subscription_buffer_size(self.subscription_buffer_size)?;
363
364 if self.exchanges.is_empty() {
365 return Err(StreamError::SetUpError(
366 "At least one exchange must be registered.".to_string(),
367 ));
368 }
369
370 let metadata_header =
373 serialize_client_metadata(&self.client_metadata).unwrap_or_else(|e| {
374 warn!("Ignoring invalid client metadata: {e}");
375 None
376 });
377
378 validate_chain_config()?;
380
381 let auth_key = self
383 .auth_key
384 .clone()
385 .or_else(|| env::var("TYCHO_AUTH_TOKEN").ok());
386
387 info!("Running with version: {}", option_env!("CARGO_PKG_VERSION").unwrap_or("unknown"));
388
389 let (tycho_ws_url, tycho_rpc_url) = if self.no_tls {
391 info!("Using non-secure connection: ws:// and http://");
392 let tycho_ws_url = format!("ws://{}", self.tycho_url);
393 let tycho_rpc_url = format!("http://{}", self.tycho_url);
394 (tycho_ws_url, tycho_rpc_url)
395 } else {
396 info!("Using secure connection: wss:// and https://");
397 let tycho_ws_url = format!("wss://{}", self.tycho_url);
398 let tycho_rpc_url = format!("https://{}", self.tycho_url);
399 (tycho_ws_url, tycho_rpc_url)
400 };
401
402 let ws_client = self.build_ws_deltas_client(
403 &tycho_ws_url,
404 auth_key.as_deref(),
405 metadata_header.clone(),
406 )?;
407 let rpc_client = HttpRPCClient::new(
408 &tycho_rpc_url,
409 HttpRPCClientOptions::new()
410 .with_auth_key(auth_key)
411 .with_compression(self.compression)
412 .with_client_metadata_header(metadata_header),
413 )
414 .map_err(|e| StreamError::SetUpError(e.to_string()))?;
415
416 let mut block_sync = BlockSynchronizer::new(
418 Duration::from_secs(self.block_time),
419 Duration::from_secs(self.timeout),
420 self.max_missed_blocks,
421 );
422 if let Some(n) = self.max_messages {
423 block_sync.max_messages(n);
424 }
425
426 let requested: HashSet<_> = self.exchanges.keys().cloned().collect();
427 let info = ProtocolSystemsInfo::fetch(&rpc_client, self.chain, &requested).await;
428 info.log_other_available();
429 let dci_protocols = info.dci_protocols;
430
431 for (name, filter) in self
433 .exchanges
434 .into_iter()
435 .map(|(name, filter)| {
436 let filter = if self.blocklisted_ids.is_empty() {
437 filter
438 } else {
439 filter.blocklist(self.blocklisted_ids.iter().cloned())
440 };
441 (name, filter)
442 })
443 {
444 info!("Registering exchange: {}", name);
445 let id = ExtractorIdentity { chain: self.chain, name: name.clone() };
446 let uses_dci = dci_protocols.contains(&name);
447 let sync = match &self.state_sync_retry_config {
448 RetryConfiguration::Constant(retry_config) => ProtocolStateSynchronizer::new(
449 id.clone(),
450 true,
451 filter,
452 retry_config.max_attempts,
453 retry_config.cooldown,
454 !self.no_state,
455 self.include_tvl,
456 self.compression,
457 rpc_client.clone(),
458 ws_client.clone(),
459 self.block_time + self.timeout,
460 )
461 .with_dci(uses_dci)
462 .with_partial_blocks(self.partial_blocks),
463 };
464 block_sync = block_sync.register_synchronizer(id, sync);
465 }
466
467 Self::start_stream(ws_client, block_sync).await
468 }
469
470 async fn start_stream<S: StateSynchronizer>(
476 ws_client: WsDeltasClient,
477 block_sync: BlockSynchronizer<S>,
478 ) -> Result<
479 (JoinHandle<()>, Receiver<Result<FeedMessage<BlockHeader>, BlockSynchronizerError>>),
480 StreamError,
481 > {
482 let mut ws_jh = ws_client
483 .connect()
484 .await
485 .map_err(|e| StreamError::WebSocketConnectionError(e.to_string()))?;
486
487 let (sync_jh, rx) = match block_sync.run().await {
490 Ok(started) => started,
491 Err(e) => {
492 Self::close_websocket(&ws_client, ws_jh).await;
493 return Err(StreamError::BlockSynchronizerError(e.to_string()));
494 }
495 };
496
497 let handle = tokio::spawn(async move {
498 tokio::select! {
499 res = &mut ws_jh => {
500 let _ = res.map_err(|e| StreamError::WebSocketConnectionError(e.to_string()));
501 if let Err(e) = ws_client.close().await {
503 warn!(?e, "Failed to close WebSocket client");
504 }
505 }
506 res = sync_jh => {
507 Self::close_websocket(&ws_client, ws_jh).await;
508 res.map_err(|e| StreamError::BlockSynchronizerError(e.to_string())).unwrap();
509 }
510 }
511 });
512
513 Ok((handle, rx))
514 }
515
516 async fn close_websocket(
522 ws_client: &WsDeltasClient,
523 mut ws_jh: JoinHandle<Result<(), DeltasError>>,
524 ) {
525 if let Err(e) = ws_client.close().await {
526 warn!(?e, "Failed to close WebSocket client");
527 }
528 match timeout(Duration::from_secs(1), &mut ws_jh).await {
529 Ok(Ok(Ok(()))) => {}
530 Ok(Ok(Err(e))) => warn!(?e, "WebSocket task ended with an error"),
531 Ok(Err(e)) => warn!(?e, "WebSocket task panicked"),
532 Err(_) => {
533 warn!("WebSocket task did not stop after close; aborting it");
534 ws_jh.abort();
535 }
536 }
537 }
538}
539
540pub struct ProtocolSystemsInfo {
543 pub dci_protocols: HashSet<String>,
544 pub other_available: HashSet<String>,
545}
546
547impl ProtocolSystemsInfo {
548 pub async fn fetch(
551 rpc_client: &HttpRPCClient,
552 chain: Chain,
553 requested_exchanges: &HashSet<String>,
554 ) -> Self {
555 let page_size =
556 ProtocolSystemsRequestBody::effective_max_page_size(rpc_client.compression());
557 let params = ProtocolSystemsParams::new(chain).with_pagination(0, page_size);
558 let response = rpc_client
559 .get_protocol_systems(params)
560 .await
561 .map_err(|e| {
562 warn!(
563 "Failed to fetch protocol systems: {e}. Skipping protocol availability check."
564 );
565 e
566 })
567 .ok();
568
569 let Some(response) = response else {
570 return Self { dci_protocols: HashSet::new(), other_available: HashSet::new() };
571 };
572
573 if response.total() > page_size {
574 warn!(
575 "Server has {} protocol systems but only {} were fetched (page_size={page_size}). \
576 Availability info may be incomplete.",
577 response.total(),
578 response.data().protocol_systems().len(),
579 );
580 }
581
582 let available: HashSet<_> = response
583 .data()
584 .protocol_systems()
585 .iter()
586 .cloned()
587 .collect();
588 let other_available = available
589 .difference(requested_exchanges)
590 .cloned()
591 .collect();
592 let mut dci_protocols: HashSet<String> = response
593 .data()
594 .dci_protocols()
595 .iter()
596 .cloned()
597 .collect();
598
599 if dci_protocols.is_empty() {
604 const LEGACY_DCI: &[&str] = &[
605 "uniswap_v4_hooks",
606 "vm:curve",
607 "vm:balancer_v2",
608 "vm:balancer_v3",
609 "fluid_v1",
610 "erc4626",
611 ];
612 for name in requested_exchanges {
613 if LEGACY_DCI.contains(&name.as_str()) {
614 dci_protocols.insert(name.clone());
615 }
616 }
617 }
618
619 Self { dci_protocols, other_available }
620 }
621
622 pub fn log_other_available(&self) {
624 if !self.other_available.is_empty() {
625 let names: Vec<_> = self
626 .other_available
627 .iter()
628 .cloned()
629 .collect();
630 info!("Other available protocols: {}", names.join(", "));
631 }
632 }
633}
634
635#[cfg(test)]
636mod tests {
637 use std::net::SocketAddr;
638
639 use futures03::StreamExt;
640 use tokio::{net::TcpListener, sync::oneshot};
641
642 use super::*;
643
644 async fn mock_ws_reporting_close() -> (SocketAddr, oneshot::Receiver<()>) {
646 let server = TcpListener::bind("127.0.0.1:0")
647 .await
648 .expect("localhost bind failed");
649 let addr = server.local_addr().unwrap();
650 let (closed_tx, closed_rx) = oneshot::channel();
651
652 tokio::spawn(async move {
653 let (stream, _) = server
654 .accept()
655 .await
656 .expect("accept failed");
657 let mut websocket = tokio_tungstenite::accept_async(stream)
658 .await
659 .expect("websocket handshake failed");
660 while let Some(Ok(msg)) = websocket.next().await {
661 if msg.is_close() {
662 break;
663 }
664 }
665 let _ = closed_tx.send(());
666 });
667 (addr, closed_rx)
668 }
669
670 #[tokio::test]
673 async fn test_start_stream_run_failure() {
674 let (addr, closed_rx) = mock_ws_reporting_close().await;
675 let ws_client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
676 let block_sync: BlockSynchronizer<
678 ProtocolStateSynchronizer<HttpRPCClient, WsDeltasClient>,
679 > = BlockSynchronizer::new(Duration::from_secs(1), Duration::from_secs(1), 1);
680
681 let start = tokio::spawn(TychoStreamBuilder::start_stream(ws_client, block_sync));
682
683 timeout(Duration::from_millis(500), closed_rx)
686 .await
687 .expect("server should observe the websocket closing")
688 .expect("mock server exited without reporting");
689 let res = start.await.unwrap();
690 assert!(matches!(res, Err(StreamError::BlockSynchronizerError(_))), "got {res:?}");
691 }
692
693 #[tokio::test(start_paused = true)]
696 async fn test_close_websocket_aborts_unresponsive_task() {
697 let ws_client = WsDeltasClient::new("ws://127.0.0.1:1", None).unwrap();
698 let (alive_tx, alive_rx) = oneshot::channel::<()>();
699 let ws_jh = tokio::spawn(async move {
700 let _alive_tx = alive_tx;
701 std::future::pending::<Result<(), DeltasError>>().await
702 });
703
704 TychoStreamBuilder::close_websocket(&ws_client, ws_jh).await;
705
706 let res = timeout(Duration::from_secs(5), alive_rx)
707 .await
708 .expect("the task should be aborted");
709 assert!(res.is_err(), "the aborted task should drop its sender");
710 }
711
712 #[test]
713 fn test_validate_chain_config_errors_on_broken_file() {
714 std::env::set_var("TYCHO_CHAINS_CONFIG", "/nonexistent/does-not-exist.yaml");
716 let result = validate_chain_config();
717 std::env::remove_var("TYCHO_CHAINS_CONFIG");
718
719 let err = result.expect_err("a missing config file must fail validation");
720 assert!(matches!(err, StreamError::SetUpError(_)));
721 assert!(
722 err.to_string()
723 .contains("custom chain config"),
724 "error should name the custom chain config: {err}"
725 );
726 }
727
728 #[test]
729 fn test_validate_chain_config_ok_when_env_unset() {
730 std::env::remove_var("TYCHO_CHAINS_CONFIG");
731 assert!(
732 validate_chain_config().is_ok(),
733 "an unset env var means no custom chains, which is valid"
734 );
735 }
736
737 #[test]
738 fn test_retry_configuration_constant() {
739 let config = RetryConfiguration::constant(5, Duration::from_secs(10));
740 match config {
741 RetryConfiguration::Constant(c) => {
742 assert_eq!(c.max_attempts, 5);
743 assert_eq!(c.cooldown, Duration::from_secs(10));
744 }
745 }
746 }
747
748 #[test]
749 fn test_stream_builder_retry_configs() {
750 let mut builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum);
751 let ws_config = RetryConfiguration::constant(10, Duration::from_secs(2));
752 let state_config = RetryConfiguration::constant(20, Duration::from_secs(5));
753
754 builder = builder
755 .websockets_retry_config(&ws_config)
756 .state_synchronizer_retry_config(&state_config);
757
758 match (&builder.websockets_retry_config, &builder.state_sync_retry_config) {
760 (RetryConfiguration::Constant(ws), RetryConfiguration::Constant(state)) => {
761 assert_eq!(ws.max_attempts, 10);
762 assert_eq!(ws.cooldown, Duration::from_secs(2));
763 assert_eq!(state.max_attempts, 20);
764 assert_eq!(state.cooldown, Duration::from_secs(5));
765 }
766 }
767 }
768
769 #[test]
770 fn test_default_stream_builder() {
771 let builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum);
772 assert!(builder.compression, "Compression should be enabled by default.");
773 assert!(!builder.partial_blocks, "partial_blocks should be disabled by default.");
774 }
775
776 #[test]
777 fn arc_uses_fast_chain_default_timing() {
778 assert_eq!(TychoStreamBuilder::default_timing(&Chain::Arc), (1, 5, 100));
779 }
780
781 #[tokio::test]
782 async fn test_no_exchanges() {
783 let receiver = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum)
784 .auth_key(Some("my_api_key".into()))
785 .build()
786 .await;
787 assert!(receiver.is_err(), "Client should fail to build when no exchanges are registered.");
788 }
789
790 #[tokio::test]
791 async fn test_zero_subscription_buffer_size_fails_before_network_io() {
792 let error = TychoStreamBuilder::new("not a valid endpoint", Chain::Ethereum)
793 .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
794 .subscription_buffer_size(0)
795 .build()
796 .await
797 .expect_err("a zero subscription buffer size must be rejected during setup");
798
799 assert!(matches!(error, StreamError::SetUpError(_)));
800 assert!(
801 error
802 .to_string()
803 .contains("subscription buffer size must be greater than zero"),
804 "error should explain how to correct the configuration: {error}"
805 );
806 }
807
808 #[tokio::test]
809 async fn test_too_large_subscription_buffer_size_fails_before_network_io() {
810 let error = TychoStreamBuilder::new("not a valid endpoint", Chain::Ethereum)
811 .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
812 .subscription_buffer_size(usize::MAX)
813 .build()
814 .await
815 .expect_err("an oversized subscription buffer size must be rejected during setup");
816
817 assert!(matches!(error, StreamError::SetUpError(_)));
818 assert!(
819 error
820 .to_string()
821 .contains("subscription buffer size must not exceed"),
822 "error should name the maximum supported capacity: {error}"
823 );
824 }
825
826 #[test]
827 fn test_add_client_metadata_accumulates() {
828 let builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum)
829 .add_client_metadata([("fynd_version", "0.57.0")])
830 .add_client_metadata([("preset", "best")]);
831 assert_eq!(
832 builder
833 .client_metadata
834 .get("fynd_version")
835 .map(String::as_str),
836 Some("0.57.0")
837 );
838 assert_eq!(
839 builder
840 .client_metadata
841 .get("preset")
842 .map(String::as_str),
843 Some("best")
844 );
845 }
846
847 #[ignore = "require tycho gateway"]
848 #[tokio::test]
849 async fn test_simple_build() {
850 let token = env::var("TYCHO_AUTH_TOKEN").unwrap();
851 let receiver = TychoStreamBuilder::new("tycho-beta.propellerheads.xyz", Chain::Ethereum)
852 .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
853 .auth_key(Some(token))
854 .build()
855 .await;
856
857 dbg!(&receiver);
858
859 assert!(receiver.is_ok(), "Client should build successfully with exchanges registered.");
860 }
861}