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, DEFAULT_SNAPSHOT_JITTER},
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 snapshot_jitter: Duration,
109 no_state: bool,
110 auth_key: Option<String>,
111 no_tls: bool,
112 include_tvl: bool,
113 compression: bool,
114 partial_blocks: bool,
115 max_messages: Option<usize>,
116 client_metadata: HashMap<String, String>,
117 subscription_buffer_size: usize,
118}
119
120impl TychoStreamBuilder {
121 pub fn new(tycho_url: &str, chain: Chain) -> Self {
124 let (block_time, timeout, max_missed_blocks) = Self::default_timing(&chain);
125 Self {
126 tycho_url: tycho_url.to_string(),
127 chain,
128 exchanges: HashMap::new(),
129 blocklisted_ids: HashSet::new(),
130 block_time,
131 timeout,
132 startup_timeout: Duration::from_secs(block_time * max_missed_blocks),
133 max_missed_blocks,
134 state_sync_retry_config: RetryConfiguration::constant(
135 32,
136 Duration::from_secs(max(block_time / 4, 2)),
137 ),
138 websockets_retry_config: RetryConfiguration::constant(
139 128,
140 Duration::from_secs(max(block_time / 6, 1)),
141 ),
142 snapshot_jitter: DEFAULT_SNAPSHOT_JITTER,
143 no_state: false,
144 auth_key: None,
145 no_tls: true,
146 include_tvl: false,
147 compression: true,
148 partial_blocks: false,
149 max_messages: None,
150 client_metadata: HashMap::new(),
151 subscription_buffer_size: DEFAULT_RECONNECTING_SUBSCRIPTION_BUFFER_SIZE,
152 }
153 }
154
155 fn default_timing(chain: &Chain) -> (u64, u64, u64) {
158 match chain {
159 Chain::Ethereum => (12, 36, 50),
160 Chain::Starknet => (2, 8, 50),
161 Chain::ZkSync => (3, 12, 50),
162 Chain::Arbitrum => (1, 2, 100), Chain::Base => (2, 12, 50),
164 Chain::Bsc => (1, 12, 50),
165 Chain::Unichain => (1, 10, 100),
166 Chain::Polygon => (2, 12, 50), Chain::Plasma => (1, 10, 100), Chain::Robinhood => (1, 5, 100), Chain::Arc => (1, 5, 100), _ => {
171 let block_time = chain.block_time_secs();
172 (block_time, block_time * 3, 50)
173 }
174 }
175 }
176
177 pub fn exchange(mut self, name: &str, filter: ComponentFilter) -> Self {
179 self.exchanges
180 .insert(name.to_string(), filter);
181 self
182 }
183
184 pub fn block_time(mut self, block_time: u64) -> Self {
186 self.block_time = block_time;
187 self
188 }
189
190 pub fn timeout(mut self, timeout: u64) -> Self {
192 self.timeout = timeout;
193 self
194 }
195
196 pub fn startup_timeout(mut self, timeout: Duration) -> Self {
197 self.startup_timeout = timeout;
198 self
199 }
200
201 pub fn max_missed_blocks(mut self, max_missed_blocks: u64) -> Self {
202 self.max_missed_blocks = max_missed_blocks;
203 self
204 }
205
206 pub fn websockets_retry_config(mut self, retry_config: &RetryConfiguration) -> Self {
207 self.websockets_retry_config = retry_config.clone();
208 self.warn_on_potential_timing_issues();
209 self
210 }
211
212 pub fn state_synchronizer_retry_config(mut self, retry_config: &RetryConfiguration) -> Self {
213 self.state_sync_retry_config = retry_config.clone();
214 self.warn_on_potential_timing_issues();
215 self
216 }
217
218 pub fn snapshot_jitter(mut self, jitter: Duration) -> Self {
224 self.snapshot_jitter = jitter;
225 self
226 }
227
228 fn warn_on_potential_timing_issues(&self) {
229 let (RetryConfiguration::Constant(state_config), RetryConfiguration::Constant(ws_config)) =
230 (&self.state_sync_retry_config, &self.websockets_retry_config);
231
232 if ws_config.cooldown >= state_config.cooldown {
233 warn!(
234 "Websocket cooldown should be < than state syncronizer cooldown \
235 to avoid spending retries due to disconnected websocket."
236 )
237 }
238 }
239
240 pub fn no_state(mut self, no_state: bool) -> Self {
242 self.no_state = no_state;
243 self
244 }
245
246 pub fn auth_key(mut self, auth_key: Option<String>) -> Self {
251 self.auth_key = auth_key;
252 self.no_tls = false;
253 self
254 }
255
256 pub fn add_client_metadata<I, K, V>(mut self, metadata: I) -> Self
266 where
267 I: IntoIterator<Item = (K, V)>,
268 K: Into<String>,
269 V: Into<String>,
270 {
271 self.client_metadata.extend(
272 metadata
273 .into_iter()
274 .map(|(k, v)| (k.into(), v.into())),
275 );
276 self
277 }
278
279 pub fn no_tls(mut self, no_tls: bool) -> Self {
281 self.no_tls = no_tls;
282 self
283 }
284
285 pub fn include_tvl(mut self, include_tvl: bool) -> Self {
289 self.include_tvl = include_tvl;
290 self
291 }
292
293 pub fn disable_compression(mut self) -> Self {
296 self.compression = false;
297 self
298 }
299
300 pub fn enable_partial_blocks(mut self) -> Self {
302 self.partial_blocks = true;
303 self
304 }
305
306 pub fn subscription_buffer_size(mut self, subscription_buffer_size: usize) -> Self {
311 self.subscription_buffer_size = subscription_buffer_size;
312 self
313 }
314
315 pub fn max_messages(mut self, n: usize) -> Self {
318 self.max_messages = Some(n);
319 self
320 }
321
322 pub fn max_retries(mut self, max_retries: u64) -> Self {
325 let cooldown = match &self.state_sync_retry_config {
326 RetryConfiguration::Constant(c) => c.cooldown,
327 };
328 self.state_sync_retry_config = RetryConfiguration::constant(max_retries, cooldown);
329 self
330 }
331
332 pub fn blocklisted_ids(mut self, ids: impl IntoIterator<Item = String>) -> Self {
337 self.blocklisted_ids.extend(ids);
338 self
339 }
340
341 pub(crate) fn build_ws_deltas_client(
344 &self,
345 ws_uri: &str,
346 auth_key: Option<&str>,
347 client_metadata_header: Option<String>,
348 ) -> Result<WsDeltasClient, StreamError> {
349 validate_subscription_buffer_size(self.subscription_buffer_size)?;
350
351 let ws_client = match &self.websockets_retry_config {
352 RetryConfiguration::Constant(config) => WsDeltasClient::new_with_reconnects(
353 ws_uri,
354 auth_key,
355 config.max_attempts,
356 config.cooldown,
357 ),
358 }
359 .map_err(|e| StreamError::SetUpError(e.to_string()))?
360 .with_subscription_buffer_size(self.subscription_buffer_size)
361 .with_client_metadata_header(client_metadata_header);
362
363 Ok(ws_client)
364 }
365
366 pub async fn build(
369 self,
370 ) -> Result<
371 (JoinHandle<()>, Receiver<Result<FeedMessage<BlockHeader>, BlockSynchronizerError>>),
372 StreamError,
373 > {
374 validate_subscription_buffer_size(self.subscription_buffer_size)?;
375
376 if self.exchanges.is_empty() {
377 return Err(StreamError::SetUpError(
378 "At least one exchange must be registered.".to_string(),
379 ));
380 }
381
382 let metadata_header =
385 serialize_client_metadata(&self.client_metadata).unwrap_or_else(|e| {
386 warn!("Ignoring invalid client metadata: {e}");
387 None
388 });
389
390 validate_chain_config()?;
392
393 let auth_key = self
395 .auth_key
396 .clone()
397 .or_else(|| env::var("TYCHO_AUTH_TOKEN").ok());
398
399 info!("Running with version: {}", option_env!("CARGO_PKG_VERSION").unwrap_or("unknown"));
400
401 let (tycho_ws_url, tycho_rpc_url) = if self.no_tls {
403 info!("Using non-secure connection: ws:// and http://");
404 let tycho_ws_url = format!("ws://{}", self.tycho_url);
405 let tycho_rpc_url = format!("http://{}", self.tycho_url);
406 (tycho_ws_url, tycho_rpc_url)
407 } else {
408 info!("Using secure connection: wss:// and https://");
409 let tycho_ws_url = format!("wss://{}", self.tycho_url);
410 let tycho_rpc_url = format!("https://{}", self.tycho_url);
411 (tycho_ws_url, tycho_rpc_url)
412 };
413
414 let ws_client = self.build_ws_deltas_client(
415 &tycho_ws_url,
416 auth_key.as_deref(),
417 metadata_header.clone(),
418 )?;
419 let rpc_client = HttpRPCClient::new(
420 &tycho_rpc_url,
421 HttpRPCClientOptions::new()
422 .with_auth_key(auth_key)
423 .with_compression(self.compression)
424 .with_client_metadata_header(metadata_header),
425 )
426 .map_err(|e| StreamError::SetUpError(e.to_string()))?;
427
428 let mut block_sync = BlockSynchronizer::new(
430 Duration::from_secs(self.block_time),
431 Duration::from_secs(self.timeout),
432 self.max_missed_blocks,
433 );
434 if let Some(n) = self.max_messages {
435 block_sync.max_messages(n);
436 }
437
438 let requested: HashSet<_> = self.exchanges.keys().cloned().collect();
439 let info = ProtocolSystemsInfo::fetch(&rpc_client, self.chain, &requested).await;
440 info.log_other_available();
441 let dci_protocols = info.dci_protocols;
442
443 for (name, filter) in self
445 .exchanges
446 .into_iter()
447 .map(|(name, filter)| {
448 let filter = if self.blocklisted_ids.is_empty() {
449 filter
450 } else {
451 filter.blocklist(self.blocklisted_ids.iter().cloned())
452 };
453 (name, filter)
454 })
455 {
456 info!("Registering exchange: {}", name);
457 let id = ExtractorIdentity { chain: self.chain, name: name.clone() };
458 let uses_dci = dci_protocols.contains(&name);
459 let sync = match &self.state_sync_retry_config {
460 RetryConfiguration::Constant(retry_config) => ProtocolStateSynchronizer::new(
461 id.clone(),
462 true,
463 filter,
464 retry_config.max_attempts,
465 retry_config.cooldown,
466 !self.no_state,
467 self.include_tvl,
468 self.compression,
469 rpc_client.clone(),
470 ws_client.clone(),
471 self.block_time + self.timeout,
472 )
473 .with_dci(uses_dci)
474 .with_partial_blocks(self.partial_blocks)
475 .with_snapshot_jitter(self.snapshot_jitter),
476 };
477 block_sync = block_sync.register_synchronizer(id, sync);
478 }
479
480 Self::start_stream(ws_client, block_sync).await
481 }
482
483 async fn start_stream<S: StateSynchronizer>(
489 ws_client: WsDeltasClient,
490 block_sync: BlockSynchronizer<S>,
491 ) -> Result<
492 (JoinHandle<()>, Receiver<Result<FeedMessage<BlockHeader>, BlockSynchronizerError>>),
493 StreamError,
494 > {
495 let mut ws_jh = ws_client
496 .connect()
497 .await
498 .map_err(|e| StreamError::WebSocketConnectionError(e.to_string()))?;
499
500 let (sync_jh, rx) = match block_sync.run().await {
503 Ok(started) => started,
504 Err(e) => {
505 Self::close_websocket(&ws_client, ws_jh).await;
506 return Err(StreamError::BlockSynchronizerError(e.to_string()));
507 }
508 };
509
510 let handle = tokio::spawn(async move {
511 tokio::select! {
512 res = &mut ws_jh => {
513 let _ = res.map_err(|e| StreamError::WebSocketConnectionError(e.to_string()));
514 if let Err(e) = ws_client.close().await {
516 warn!(?e, "Failed to close WebSocket client");
517 }
518 }
519 res = sync_jh => {
520 Self::close_websocket(&ws_client, ws_jh).await;
521 res.map_err(|e| StreamError::BlockSynchronizerError(e.to_string())).unwrap();
522 }
523 }
524 });
525
526 Ok((handle, rx))
527 }
528
529 async fn close_websocket(
535 ws_client: &WsDeltasClient,
536 mut ws_jh: JoinHandle<Result<(), DeltasError>>,
537 ) {
538 if let Err(e) = ws_client.close().await {
539 warn!(?e, "Failed to close WebSocket client");
540 }
541 match timeout(Duration::from_secs(1), &mut ws_jh).await {
542 Ok(Ok(Ok(()))) => {}
543 Ok(Ok(Err(e))) => warn!(?e, "WebSocket task ended with an error"),
544 Ok(Err(e)) => warn!(?e, "WebSocket task panicked"),
545 Err(_) => {
546 warn!("WebSocket task did not stop after close; aborting it");
547 ws_jh.abort();
548 }
549 }
550 }
551}
552
553pub struct ProtocolSystemsInfo {
556 pub dci_protocols: HashSet<String>,
557 pub other_available: HashSet<String>,
558}
559
560impl ProtocolSystemsInfo {
561 pub async fn fetch(
564 rpc_client: &HttpRPCClient,
565 chain: Chain,
566 requested_exchanges: &HashSet<String>,
567 ) -> Self {
568 let page_size =
569 ProtocolSystemsRequestBody::effective_max_page_size(rpc_client.compression());
570 let params = ProtocolSystemsParams::new(chain).with_pagination(0, page_size);
571 let response = rpc_client
572 .get_protocol_systems(params)
573 .await
574 .map_err(|e| {
575 warn!(
576 "Failed to fetch protocol systems: {e}. Skipping protocol availability check."
577 );
578 e
579 })
580 .ok();
581
582 let Some(response) = response else {
583 return Self { dci_protocols: HashSet::new(), other_available: HashSet::new() };
584 };
585
586 if response.total() > page_size {
587 warn!(
588 "Server has {} protocol systems but only {} were fetched (page_size={page_size}). \
589 Availability info may be incomplete.",
590 response.total(),
591 response.data().protocol_systems().len(),
592 );
593 }
594
595 let available: HashSet<_> = response
596 .data()
597 .protocol_systems()
598 .iter()
599 .cloned()
600 .collect();
601 let other_available = available
602 .difference(requested_exchanges)
603 .cloned()
604 .collect();
605 let mut dci_protocols: HashSet<String> = response
606 .data()
607 .dci_protocols()
608 .iter()
609 .cloned()
610 .collect();
611
612 if dci_protocols.is_empty() {
617 const LEGACY_DCI: &[&str] = &[
618 "uniswap_v4_hooks",
619 "vm:curve",
620 "vm:balancer_v2",
621 "vm:balancer_v3",
622 "fluid_v1",
623 "erc4626",
624 ];
625 for name in requested_exchanges {
626 if LEGACY_DCI.contains(&name.as_str()) {
627 dci_protocols.insert(name.clone());
628 }
629 }
630 }
631
632 Self { dci_protocols, other_available }
633 }
634
635 pub fn log_other_available(&self) {
637 if !self.other_available.is_empty() {
638 let names: Vec<_> = self
639 .other_available
640 .iter()
641 .cloned()
642 .collect();
643 info!("Other available protocols: {}", names.join(", "));
644 }
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use std::net::SocketAddr;
651
652 use futures03::StreamExt;
653 use tokio::{net::TcpListener, sync::oneshot};
654
655 use super::*;
656
657 async fn mock_ws_reporting_close() -> (SocketAddr, oneshot::Receiver<()>) {
659 let server = TcpListener::bind("127.0.0.1:0")
660 .await
661 .expect("localhost bind failed");
662 let addr = server.local_addr().unwrap();
663 let (closed_tx, closed_rx) = oneshot::channel();
664
665 tokio::spawn(async move {
666 let (stream, _) = server
667 .accept()
668 .await
669 .expect("accept failed");
670 let mut websocket = tokio_tungstenite::accept_async(stream)
671 .await
672 .expect("websocket handshake failed");
673 while let Some(Ok(msg)) = websocket.next().await {
674 if msg.is_close() {
675 break;
676 }
677 }
678 let _ = closed_tx.send(());
679 });
680 (addr, closed_rx)
681 }
682
683 #[tokio::test]
686 async fn test_start_stream_run_failure() {
687 let (addr, closed_rx) = mock_ws_reporting_close().await;
688 let ws_client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
689 let block_sync: BlockSynchronizer<
691 ProtocolStateSynchronizer<HttpRPCClient, WsDeltasClient>,
692 > = BlockSynchronizer::new(Duration::from_secs(1), Duration::from_secs(1), 1);
693
694 let start = tokio::spawn(TychoStreamBuilder::start_stream(ws_client, block_sync));
695
696 timeout(Duration::from_millis(500), closed_rx)
699 .await
700 .expect("server should observe the websocket closing")
701 .expect("mock server exited without reporting");
702 let res = start.await.unwrap();
703 assert!(matches!(res, Err(StreamError::BlockSynchronizerError(_))), "got {res:?}");
704 }
705
706 #[tokio::test(start_paused = true)]
709 async fn test_close_websocket_aborts_unresponsive_task() {
710 let ws_client = WsDeltasClient::new("ws://127.0.0.1:1", None).unwrap();
711 let (alive_tx, alive_rx) = oneshot::channel::<()>();
712 let ws_jh = tokio::spawn(async move {
713 let _alive_tx = alive_tx;
714 std::future::pending::<Result<(), DeltasError>>().await
715 });
716
717 TychoStreamBuilder::close_websocket(&ws_client, ws_jh).await;
718
719 let res = timeout(Duration::from_secs(5), alive_rx)
720 .await
721 .expect("the task should be aborted");
722 assert!(res.is_err(), "the aborted task should drop its sender");
723 }
724
725 #[test]
726 fn test_validate_chain_config_errors_on_broken_file() {
727 std::env::set_var("TYCHO_CHAINS_CONFIG", "/nonexistent/does-not-exist.yaml");
729 let result = validate_chain_config();
730 std::env::remove_var("TYCHO_CHAINS_CONFIG");
731
732 let err = result.expect_err("a missing config file must fail validation");
733 assert!(matches!(err, StreamError::SetUpError(_)));
734 assert!(
735 err.to_string()
736 .contains("custom chain config"),
737 "error should name the custom chain config: {err}"
738 );
739 }
740
741 #[test]
742 fn test_validate_chain_config_ok_when_env_unset() {
743 std::env::remove_var("TYCHO_CHAINS_CONFIG");
744 assert!(
745 validate_chain_config().is_ok(),
746 "an unset env var means no custom chains, which is valid"
747 );
748 }
749
750 #[test]
751 fn test_retry_configuration_constant() {
752 let config = RetryConfiguration::constant(5, Duration::from_secs(10));
753 match config {
754 RetryConfiguration::Constant(c) => {
755 assert_eq!(c.max_attempts, 5);
756 assert_eq!(c.cooldown, Duration::from_secs(10));
757 }
758 }
759 }
760
761 #[test]
762 fn test_stream_builder_retry_configs() {
763 let mut builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum);
764 let ws_config = RetryConfiguration::constant(10, Duration::from_secs(2));
765 let state_config = RetryConfiguration::constant(20, Duration::from_secs(5));
766
767 builder = builder
768 .websockets_retry_config(&ws_config)
769 .state_synchronizer_retry_config(&state_config);
770
771 match (&builder.websockets_retry_config, &builder.state_sync_retry_config) {
773 (RetryConfiguration::Constant(ws), RetryConfiguration::Constant(state)) => {
774 assert_eq!(ws.max_attempts, 10);
775 assert_eq!(ws.cooldown, Duration::from_secs(2));
776 assert_eq!(state.max_attempts, 20);
777 assert_eq!(state.cooldown, Duration::from_secs(5));
778 }
779 }
780 }
781
782 #[test]
783 fn test_stream_builder_snapshot_jitter() {
784 let builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum);
785 assert_eq!(builder.snapshot_jitter, Duration::from_secs(60));
786
787 let builder = builder.snapshot_jitter(Duration::from_secs(10));
788 assert_eq!(builder.snapshot_jitter, Duration::from_secs(10));
789 }
790
791 #[test]
792 fn test_default_stream_builder() {
793 let builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum);
794 assert!(builder.compression, "Compression should be enabled by default.");
795 assert!(!builder.partial_blocks, "partial_blocks should be disabled by default.");
796 }
797
798 #[test]
799 fn arc_uses_fast_chain_default_timing() {
800 assert_eq!(TychoStreamBuilder::default_timing(&Chain::Arc), (1, 5, 100));
801 }
802
803 #[tokio::test]
804 async fn test_no_exchanges() {
805 let receiver = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum)
806 .auth_key(Some("my_api_key".into()))
807 .build()
808 .await;
809 assert!(receiver.is_err(), "Client should fail to build when no exchanges are registered.");
810 }
811
812 #[tokio::test]
813 async fn test_zero_subscription_buffer_size_fails_before_network_io() {
814 let error = TychoStreamBuilder::new("not a valid endpoint", Chain::Ethereum)
815 .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
816 .subscription_buffer_size(0)
817 .build()
818 .await
819 .expect_err("a zero subscription buffer size must be rejected during setup");
820
821 assert!(matches!(error, StreamError::SetUpError(_)));
822 assert!(
823 error
824 .to_string()
825 .contains("subscription buffer size must be greater than zero"),
826 "error should explain how to correct the configuration: {error}"
827 );
828 }
829
830 #[tokio::test]
831 async fn test_too_large_subscription_buffer_size_fails_before_network_io() {
832 let error = TychoStreamBuilder::new("not a valid endpoint", Chain::Ethereum)
833 .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
834 .subscription_buffer_size(usize::MAX)
835 .build()
836 .await
837 .expect_err("an oversized subscription buffer size must be rejected during setup");
838
839 assert!(matches!(error, StreamError::SetUpError(_)));
840 assert!(
841 error
842 .to_string()
843 .contains("subscription buffer size must not exceed"),
844 "error should name the maximum supported capacity: {error}"
845 );
846 }
847
848 #[test]
849 fn test_add_client_metadata_accumulates() {
850 let builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum)
851 .add_client_metadata([("fynd_version", "0.57.0")])
852 .add_client_metadata([("preset", "best")]);
853 assert_eq!(
854 builder
855 .client_metadata
856 .get("fynd_version")
857 .map(String::as_str),
858 Some("0.57.0")
859 );
860 assert_eq!(
861 builder
862 .client_metadata
863 .get("preset")
864 .map(String::as_str),
865 Some("best")
866 );
867 }
868
869 #[ignore = "require tycho gateway"]
870 #[tokio::test]
871 async fn test_simple_build() {
872 let token = env::var("TYCHO_AUTH_TOKEN").unwrap();
873 let receiver = TychoStreamBuilder::new("tycho-beta.propellerheads.xyz", Chain::Ethereum)
874 .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
875 .auth_key(Some(token))
876 .build()
877 .await;
878
879 dbg!(&receiver);
880
881 assert!(receiver.is_ok(), "Client should build successfully with exchanges registered.");
882 }
883}