Skip to main content

tycho_client/
stream.rs

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};
13use tracing::{info, warn};
14use tycho_common::{
15    dto::{PaginationLimits, ProtocolSystemsRequestBody},
16    models::{
17        chain_config::{init_chain_registry, ChainConfigRegistry},
18        Chain, ExtractorIdentity,
19    },
20};
21
22use crate::{
23    client_metadata::serialize_client_metadata,
24    deltas::{DeltasClient, DEFAULT_RECONNECTING_SUBSCRIPTION_BUFFER_SIZE},
25    feed::{
26        component_tracker::ComponentFilter, synchronizer::ProtocolStateSynchronizer, BlockHeader,
27        BlockSynchronizer, BlockSynchronizerError, FeedMessage,
28    },
29    rpc::{HttpRPCClientOptions, ProtocolSystemsParams, RPCClient},
30    HttpRPCClient, WsDeltasClient,
31};
32
33#[derive(Error, Debug)]
34pub enum StreamError {
35    #[error("Error during stream set up: {0}")]
36    SetUpError(String),
37
38    #[error("WebSocket client connection error: {0}")]
39    WebSocketConnectionError(String),
40
41    #[error("BlockSynchronizer error: {0}")]
42    BlockSynchronizerError(String),
43}
44
45#[non_exhaustive]
46#[derive(Clone, Debug)]
47pub enum RetryConfiguration {
48    Constant(ConstantRetryConfiguration),
49}
50
51impl RetryConfiguration {
52    pub fn constant(max_attempts: u64, cooldown: Duration) -> Self {
53        RetryConfiguration::Constant(ConstantRetryConfiguration { max_attempts, cooldown })
54    }
55}
56
57#[derive(Clone, Debug)]
58pub struct ConstantRetryConfiguration {
59    max_attempts: u64,
60    cooldown: Duration,
61}
62
63/// Loads and validates the custom-chain config once, before the stream starts. A missing or
64/// malformed `TYCHO_CHAINS_CONFIG` fails here with an actionable error, rather than degrading to
65/// an empty registry and resurfacing later as a confusing unknown-chain error mid-stream. An unset
66/// env var yields an empty registry (Ok), so built-in-only consumers are unaffected. The validated
67/// registry is best-effort installed as the process-wide one; a prior lazy access may already have
68/// initialised it, in which case the install is a no-op.
69fn validate_chain_config() -> Result<(), StreamError> {
70    let registry = ChainConfigRegistry::load_default()
71        .map_err(|e| StreamError::SetUpError(format!("failed to load custom chain config: {e}")))?;
72    let _ = init_chain_registry(registry);
73    Ok(())
74}
75
76fn validate_subscription_buffer_size(subscription_buffer_size: usize) -> Result<(), StreamError> {
77    if subscription_buffer_size == 0 {
78        return Err(StreamError::SetUpError(
79            "subscription buffer size must be greater than zero".to_string(),
80        ));
81    }
82
83    if subscription_buffer_size > Semaphore::MAX_PERMITS {
84        return Err(StreamError::SetUpError(format!(
85            "subscription buffer size must not exceed {} (Tokio's maximum channel capacity); \
86             choose a value between 1 and {}",
87            Semaphore::MAX_PERMITS,
88            Semaphore::MAX_PERMITS,
89        )));
90    }
91
92    Ok(())
93}
94
95pub struct TychoStreamBuilder {
96    tycho_url: String,
97    chain: Chain,
98    exchanges: HashMap<String, ComponentFilter>,
99    blocklisted_ids: HashSet<String>,
100    block_time: u64,
101    timeout: u64,
102    startup_timeout: Duration,
103    max_missed_blocks: u64,
104    state_sync_retry_config: RetryConfiguration,
105    websockets_retry_config: RetryConfiguration,
106    no_state: bool,
107    auth_key: Option<String>,
108    no_tls: bool,
109    include_tvl: bool,
110    compression: bool,
111    partial_blocks: bool,
112    max_messages: Option<usize>,
113    client_metadata: HashMap<String, String>,
114    subscription_buffer_size: usize,
115}
116
117impl TychoStreamBuilder {
118    /// Creates a new `TychoStreamBuilder` with the given Tycho URL and blockchain network.
119    /// Initializes the builder with default values for block time and timeout based on the chain.
120    pub fn new(tycho_url: &str, chain: Chain) -> Self {
121        let (block_time, timeout, max_missed_blocks) = Self::default_timing(&chain);
122        Self {
123            tycho_url: tycho_url.to_string(),
124            chain,
125            exchanges: HashMap::new(),
126            blocklisted_ids: HashSet::new(),
127            block_time,
128            timeout,
129            startup_timeout: Duration::from_secs(block_time * max_missed_blocks),
130            max_missed_blocks,
131            state_sync_retry_config: RetryConfiguration::constant(
132                32,
133                Duration::from_secs(max(block_time / 4, 2)),
134            ),
135            websockets_retry_config: RetryConfiguration::constant(
136                128,
137                Duration::from_secs(max(block_time / 6, 1)),
138            ),
139            no_state: false,
140            auth_key: None,
141            no_tls: true,
142            include_tvl: false,
143            compression: true,
144            partial_blocks: false,
145            max_messages: None,
146            client_metadata: HashMap::new(),
147            subscription_buffer_size: DEFAULT_RECONNECTING_SUBSCRIPTION_BUFFER_SIZE,
148        }
149    }
150
151    /// Returns the default block_time, timeout and max_missed_blocks values for the given
152    /// blockchain network.
153    fn default_timing(chain: &Chain) -> (u64, u64, u64) {
154        match chain {
155            Chain::Ethereum => (12, 36, 50),
156            Chain::Starknet => (2, 8, 50),
157            Chain::ZkSync => (3, 12, 50),
158            Chain::Arbitrum => (1, 2, 100), // Typically closer to 0.25s
159            Chain::Base => (2, 12, 50),
160            Chain::Bsc => (1, 12, 50),
161            Chain::Unichain => (1, 10, 100),
162            Chain::Polygon => (2, 12, 50),   // ~2s block time
163            Chain::Plasma => (1, 10, 100),   // ~1s block time
164            Chain::Robinhood => (1, 5, 100), // Arbitrum Orbit, typically closer to 0.25s
165            Chain::Arc => (1, 5, 100),       // Typically closer to 0.5s
166            _ => {
167                let block_time = chain.block_time_secs();
168                (block_time, block_time * 3, 50)
169            }
170        }
171    }
172
173    /// Adds an exchange and its corresponding filter to the Tycho client.
174    pub fn exchange(mut self, name: &str, filter: ComponentFilter) -> Self {
175        self.exchanges
176            .insert(name.to_string(), filter);
177        self
178    }
179
180    /// Sets the block time for the Tycho client.
181    pub fn block_time(mut self, block_time: u64) -> Self {
182        self.block_time = block_time;
183        self
184    }
185
186    /// Sets the timeout duration for network operations.
187    pub fn timeout(mut self, timeout: u64) -> Self {
188        self.timeout = timeout;
189        self
190    }
191
192    pub fn startup_timeout(mut self, timeout: Duration) -> Self {
193        self.startup_timeout = timeout;
194        self
195    }
196
197    pub fn max_missed_blocks(mut self, max_missed_blocks: u64) -> Self {
198        self.max_missed_blocks = max_missed_blocks;
199        self
200    }
201
202    pub fn websockets_retry_config(mut self, retry_config: &RetryConfiguration) -> Self {
203        self.websockets_retry_config = retry_config.clone();
204        self.warn_on_potential_timing_issues();
205        self
206    }
207
208    pub fn state_synchronizer_retry_config(mut self, retry_config: &RetryConfiguration) -> Self {
209        self.state_sync_retry_config = retry_config.clone();
210        self.warn_on_potential_timing_issues();
211        self
212    }
213
214    fn warn_on_potential_timing_issues(&self) {
215        let (RetryConfiguration::Constant(state_config), RetryConfiguration::Constant(ws_config)) =
216            (&self.state_sync_retry_config, &self.websockets_retry_config);
217
218        if ws_config.cooldown >= state_config.cooldown {
219            warn!(
220                "Websocket cooldown should be < than state syncronizer cooldown \
221                to avoid spending retries due to disconnected websocket."
222            )
223        }
224    }
225
226    /// Configures the client to exclude state updates from the stream.
227    pub fn no_state(mut self, no_state: bool) -> Self {
228        self.no_state = no_state;
229        self
230    }
231
232    /// Sets the API key for authenticating with the Tycho server.
233    ///
234    /// Optionally you can set the TYCHO_AUTH_TOKEN env var instead. Make sure to set no_tsl
235    /// to false if you do this.
236    pub fn auth_key(mut self, auth_key: Option<String>) -> Self {
237        self.auth_key = auth_key;
238        self.no_tls = false;
239        self
240    }
241
242    /// Adds client-metadata entries sent to the server in the `X-Tycho-Client-Metadata` header.
243    ///
244    /// The metadata is opaque to tycho-client; consumers supply their own keys. Accepts any
245    /// iterator of key/value pairs and inserts each entry, keeping previously set metadata. An
246    /// empty map sends no header. Invalid keys/values and oversized metadata are dropped with a
247    /// warning at `build()` time, sending no header.
248    ///
249    /// Values are self-reported and may surface in the server's metrics and logs. Do not include
250    /// secrets or personally identifiable information.
251    pub fn add_client_metadata<I, K, V>(mut self, metadata: I) -> Self
252    where
253        I: IntoIterator<Item = (K, V)>,
254        K: Into<String>,
255        V: Into<String>,
256    {
257        self.client_metadata.extend(
258            metadata
259                .into_iter()
260                .map(|(k, v)| (k.into(), v.into())),
261        );
262        self
263    }
264
265    /// Disables TLS/SSL for the connection, using `http` and `ws` protocols.
266    pub fn no_tls(mut self, no_tls: bool) -> Self {
267        self.no_tls = no_tls;
268        self
269    }
270
271    /// Configures the client to include TVL in the stream.
272    ///
273    /// If set to true, this will increase start-up time due to additional requests.
274    pub fn include_tvl(mut self, include_tvl: bool) -> Self {
275        self.include_tvl = include_tvl;
276        self
277    }
278
279    /// Disables compression for RPC and WebSocket communication.
280    /// By default, messages are compressed using zstd.
281    pub fn disable_compression(mut self) -> Self {
282        self.compression = false;
283        self
284    }
285
286    /// Enables the client to receive partial block updates (flashblocks).
287    pub fn enable_partial_blocks(mut self) -> Self {
288        self.partial_blocks = true;
289        self
290    }
291
292    /// Sets the number of deltas buffered for each WebSocket subscription.
293    ///
294    /// The default is 128. Values outside Tokio's supported channel range are rejected as setup
295    /// errors when [`build`](Self::build) is called, before any network I/O begins.
296    pub fn subscription_buffer_size(mut self, subscription_buffer_size: usize) -> Self {
297        self.subscription_buffer_size = subscription_buffer_size;
298        self
299    }
300
301    /// Stops the stream after emitting this many messages. Useful for testing or
302    /// triggering a periodic restart after a fixed number of blocks.
303    pub fn max_messages(mut self, n: usize) -> Self {
304        self.max_messages = Some(n);
305        self
306    }
307
308    /// Overrides the maximum number of retry attempts for state synchronizer startup.
309    /// The retry cooldown is derived from the chain's block time and is not affected.
310    pub fn max_retries(mut self, max_retries: u64) -> Self {
311        let cooldown = match &self.state_sync_retry_config {
312            RetryConfiguration::Constant(c) => c.cooldown,
313        };
314        self.state_sync_retry_config = RetryConfiguration::constant(max_retries, cooldown);
315        self
316    }
317
318    /// Blocklist specific component IDs across all registered exchanges.
319    ///
320    /// Blocklisted components are never tracked, regardless of TVL or other
321    /// filter criteria.
322    pub fn blocklisted_ids(mut self, ids: impl IntoIterator<Item = String>) -> Self {
323        self.blocklisted_ids.extend(ids);
324        self
325    }
326
327    /// Constructs the WebSocket delta client from this builder's retry, buffer, and metadata
328    /// configuration.
329    pub(crate) fn build_ws_deltas_client(
330        &self,
331        ws_uri: &str,
332        auth_key: Option<&str>,
333        client_metadata_header: Option<String>,
334    ) -> Result<WsDeltasClient, StreamError> {
335        validate_subscription_buffer_size(self.subscription_buffer_size)?;
336
337        let ws_client = match &self.websockets_retry_config {
338            RetryConfiguration::Constant(config) => WsDeltasClient::new_with_reconnects(
339                ws_uri,
340                auth_key,
341                config.max_attempts,
342                config.cooldown,
343            ),
344        }
345        .map_err(|e| StreamError::SetUpError(e.to_string()))?
346        .with_subscription_buffer_size(self.subscription_buffer_size)
347        .with_client_metadata_header(client_metadata_header);
348
349        Ok(ws_client)
350    }
351
352    /// Builds and starts the Tycho client, connecting to the Tycho server and
353    /// setting up the synchronization of exchange components.
354    pub async fn build(
355        self,
356    ) -> Result<
357        (JoinHandle<()>, Receiver<Result<FeedMessage<BlockHeader>, BlockSynchronizerError>>),
358        StreamError,
359    > {
360        validate_subscription_buffer_size(self.subscription_buffer_size)?;
361
362        if self.exchanges.is_empty() {
363            return Err(StreamError::SetUpError(
364                "At least one exchange must be registered.".to_string(),
365            ));
366        }
367
368        // Serialize client metadata once, before any network I/O. Metadata is best-effort
369        // telemetry, so invalid input is dropped with a warning rather than failing the stream.
370        let metadata_header =
371            serialize_client_metadata(&self.client_metadata).unwrap_or_else(|e| {
372                warn!("Ignoring invalid client metadata: {e}");
373                None
374            });
375
376        // Fail fast on a broken custom-chain config, before any network I/O.
377        validate_chain_config()?;
378
379        // Attempt to read the authentication key from the environment variable if not provided
380        let auth_key = self
381            .auth_key
382            .clone()
383            .or_else(|| env::var("TYCHO_AUTH_TOKEN").ok());
384
385        info!("Running with version: {}", option_env!("CARGO_PKG_VERSION").unwrap_or("unknown"));
386
387        // Determine the URLs based on the TLS setting
388        let (tycho_ws_url, tycho_rpc_url) = if self.no_tls {
389            info!("Using non-secure connection: ws:// and http://");
390            let tycho_ws_url = format!("ws://{}", self.tycho_url);
391            let tycho_rpc_url = format!("http://{}", self.tycho_url);
392            (tycho_ws_url, tycho_rpc_url)
393        } else {
394            info!("Using secure connection: wss:// and https://");
395            let tycho_ws_url = format!("wss://{}", self.tycho_url);
396            let tycho_rpc_url = format!("https://{}", self.tycho_url);
397            (tycho_ws_url, tycho_rpc_url)
398        };
399
400        let ws_client = self.build_ws_deltas_client(
401            &tycho_ws_url,
402            auth_key.as_deref(),
403            metadata_header.clone(),
404        )?;
405        let rpc_client = HttpRPCClient::new(
406            &tycho_rpc_url,
407            HttpRPCClientOptions::new()
408                .with_auth_key(auth_key)
409                .with_compression(self.compression)
410                .with_client_metadata_header(metadata_header),
411        )
412        .map_err(|e| StreamError::SetUpError(e.to_string()))?;
413        let ws_jh = ws_client
414            .connect()
415            .await
416            .map_err(|e| StreamError::WebSocketConnectionError(e.to_string()))?;
417
418        // Create and configure the BlockSynchronizer
419        let mut block_sync = BlockSynchronizer::new(
420            Duration::from_secs(self.block_time),
421            Duration::from_secs(self.timeout),
422            self.max_missed_blocks,
423        );
424        if let Some(n) = self.max_messages {
425            block_sync.max_messages(n);
426        }
427
428        let requested: HashSet<_> = self.exchanges.keys().cloned().collect();
429        let info = ProtocolSystemsInfo::fetch(&rpc_client, self.chain, &requested).await;
430        info.log_other_available();
431        let dci_protocols = info.dci_protocols;
432
433        // Register each exchange with the BlockSynchronizer
434        for (name, filter) in self
435            .exchanges
436            .into_iter()
437            .map(|(name, filter)| {
438                let filter = if self.blocklisted_ids.is_empty() {
439                    filter
440                } else {
441                    filter.blocklist(self.blocklisted_ids.iter().cloned())
442                };
443                (name, filter)
444            })
445        {
446            info!("Registering exchange: {}", name);
447            let id = ExtractorIdentity { chain: self.chain, name: name.clone() };
448            let uses_dci = dci_protocols.contains(&name);
449            let sync = match &self.state_sync_retry_config {
450                RetryConfiguration::Constant(retry_config) => ProtocolStateSynchronizer::new(
451                    id.clone(),
452                    true,
453                    filter,
454                    retry_config.max_attempts,
455                    retry_config.cooldown,
456                    !self.no_state,
457                    self.include_tvl,
458                    self.compression,
459                    rpc_client.clone(),
460                    ws_client.clone(),
461                    self.block_time + self.timeout,
462                )
463                .with_dci(uses_dci)
464                .with_partial_blocks(self.partial_blocks),
465            };
466            block_sync = block_sync.register_synchronizer(id, sync);
467        }
468
469        // Start the BlockSynchronizer and monitor for disconnections
470        let (sync_jh, rx) = block_sync
471            .run()
472            .await
473            .map_err(|e| StreamError::BlockSynchronizerError(e.to_string()))?;
474
475        // Monitor WebSocket and BlockSynchronizer futures
476        let handle = tokio::spawn(async move {
477            tokio::select! {
478                res = ws_jh => {
479                    let _ = res.map_err(|e| StreamError::WebSocketConnectionError(e.to_string()));
480                }
481                res = sync_jh => {
482                    res.map_err(|e| StreamError::BlockSynchronizerError(e.to_string())).unwrap();
483                }
484            }
485            if let Err(e) = ws_client.close().await {
486                warn!(?e, "Failed to close WebSocket client");
487            }
488        });
489
490        Ok((handle, rx))
491    }
492}
493
494/// Result of fetching protocol systems: which protocols use DCI, and which
495/// available protocols on the server were not requested by the client.
496pub struct ProtocolSystemsInfo {
497    pub dci_protocols: HashSet<String>,
498    pub other_available: HashSet<String>,
499}
500
501impl ProtocolSystemsInfo {
502    /// Fetches protocol systems from the server and classifies them: which use DCI,
503    /// and which are available but not in `requested_exchanges`.
504    pub async fn fetch(
505        rpc_client: &HttpRPCClient,
506        chain: Chain,
507        requested_exchanges: &HashSet<String>,
508    ) -> Self {
509        let page_size =
510            ProtocolSystemsRequestBody::effective_max_page_size(rpc_client.compression());
511        let params = ProtocolSystemsParams::new(chain).with_pagination(0, page_size);
512        let response = rpc_client
513            .get_protocol_systems(params)
514            .await
515            .map_err(|e| {
516                warn!(
517                    "Failed to fetch protocol systems: {e}. Skipping protocol availability check."
518                );
519                e
520            })
521            .ok();
522
523        let Some(response) = response else {
524            return Self { dci_protocols: HashSet::new(), other_available: HashSet::new() };
525        };
526
527        if response.total() > page_size {
528            warn!(
529                "Server has {} protocol systems but only {} were fetched (page_size={page_size}). \
530                 Availability info may be incomplete.",
531                response.total(),
532                response.data().protocol_systems().len(),
533            );
534        }
535
536        let available: HashSet<_> = response
537            .data()
538            .protocol_systems()
539            .iter()
540            .cloned()
541            .collect();
542        let other_available = available
543            .difference(requested_exchanges)
544            .cloned()
545            .collect();
546        let mut dci_protocols: HashSet<String> = response
547            .data()
548            .dci_protocols()
549            .iter()
550            .cloned()
551            .collect();
552
553        // TODO(ENG-5302): Remove this fallback once all environments serve
554        // the `dci_protocols` field. Old servers omit the field, which
555        // deserialises as empty — causing clients to skip entrypoint
556        // fetches for DCI protocols.
557        if dci_protocols.is_empty() {
558            const LEGACY_DCI: &[&str] = &[
559                "uniswap_v4_hooks",
560                "vm:curve",
561                "vm:balancer_v2",
562                "vm:balancer_v3",
563                "fluid_v1",
564                "erc4626",
565            ];
566            for name in requested_exchanges {
567                if LEGACY_DCI.contains(&name.as_str()) {
568                    dci_protocols.insert(name.clone());
569                }
570            }
571        }
572
573        Self { dci_protocols, other_available }
574    }
575
576    /// Logs the protocols available on the server that the client didn't subscribe to.
577    pub fn log_other_available(&self) {
578        if !self.other_available.is_empty() {
579            let names: Vec<_> = self
580                .other_available
581                .iter()
582                .cloned()
583                .collect();
584            info!("Other available protocols: {}", names.join(", "));
585        }
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    #[test]
594    fn test_validate_chain_config_errors_on_broken_file() {
595        // Relies on nextest process isolation: this mutates the process-global env var.
596        std::env::set_var("TYCHO_CHAINS_CONFIG", "/nonexistent/does-not-exist.yaml");
597        let result = validate_chain_config();
598        std::env::remove_var("TYCHO_CHAINS_CONFIG");
599
600        let err = result.expect_err("a missing config file must fail validation");
601        assert!(matches!(err, StreamError::SetUpError(_)));
602        assert!(
603            err.to_string()
604                .contains("custom chain config"),
605            "error should name the custom chain config: {err}"
606        );
607    }
608
609    #[test]
610    fn test_validate_chain_config_ok_when_env_unset() {
611        std::env::remove_var("TYCHO_CHAINS_CONFIG");
612        assert!(
613            validate_chain_config().is_ok(),
614            "an unset env var means no custom chains, which is valid"
615        );
616    }
617
618    #[test]
619    fn test_retry_configuration_constant() {
620        let config = RetryConfiguration::constant(5, Duration::from_secs(10));
621        match config {
622            RetryConfiguration::Constant(c) => {
623                assert_eq!(c.max_attempts, 5);
624                assert_eq!(c.cooldown, Duration::from_secs(10));
625            }
626        }
627    }
628
629    #[test]
630    fn test_stream_builder_retry_configs() {
631        let mut builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum);
632        let ws_config = RetryConfiguration::constant(10, Duration::from_secs(2));
633        let state_config = RetryConfiguration::constant(20, Duration::from_secs(5));
634
635        builder = builder
636            .websockets_retry_config(&ws_config)
637            .state_synchronizer_retry_config(&state_config);
638
639        // Verify configs are stored correctly by checking they match expected values
640        match (&builder.websockets_retry_config, &builder.state_sync_retry_config) {
641            (RetryConfiguration::Constant(ws), RetryConfiguration::Constant(state)) => {
642                assert_eq!(ws.max_attempts, 10);
643                assert_eq!(ws.cooldown, Duration::from_secs(2));
644                assert_eq!(state.max_attempts, 20);
645                assert_eq!(state.cooldown, Duration::from_secs(5));
646            }
647        }
648    }
649
650    #[test]
651    fn test_default_stream_builder() {
652        let builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum);
653        assert!(builder.compression, "Compression should be enabled by default.");
654        assert!(!builder.partial_blocks, "partial_blocks should be disabled by default.");
655    }
656
657    #[test]
658    fn arc_uses_fast_chain_default_timing() {
659        assert_eq!(TychoStreamBuilder::default_timing(&Chain::Arc), (1, 5, 100));
660    }
661
662    #[tokio::test]
663    async fn test_no_exchanges() {
664        let receiver = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum)
665            .auth_key(Some("my_api_key".into()))
666            .build()
667            .await;
668        assert!(receiver.is_err(), "Client should fail to build when no exchanges are registered.");
669    }
670
671    #[tokio::test]
672    async fn test_zero_subscription_buffer_size_fails_before_network_io() {
673        let error = TychoStreamBuilder::new("not a valid endpoint", Chain::Ethereum)
674            .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
675            .subscription_buffer_size(0)
676            .build()
677            .await
678            .expect_err("a zero subscription buffer size must be rejected during setup");
679
680        assert!(matches!(error, StreamError::SetUpError(_)));
681        assert!(
682            error
683                .to_string()
684                .contains("subscription buffer size must be greater than zero"),
685            "error should explain how to correct the configuration: {error}"
686        );
687    }
688
689    #[tokio::test]
690    async fn test_too_large_subscription_buffer_size_fails_before_network_io() {
691        let error = TychoStreamBuilder::new("not a valid endpoint", Chain::Ethereum)
692            .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
693            .subscription_buffer_size(usize::MAX)
694            .build()
695            .await
696            .expect_err("an oversized subscription buffer size must be rejected during setup");
697
698        assert!(matches!(error, StreamError::SetUpError(_)));
699        assert!(
700            error
701                .to_string()
702                .contains("subscription buffer size must not exceed"),
703            "error should name the maximum supported capacity: {error}"
704        );
705    }
706
707    #[test]
708    fn test_add_client_metadata_accumulates() {
709        let builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum)
710            .add_client_metadata([("fynd_version", "0.57.0")])
711            .add_client_metadata([("preset", "best")]);
712        assert_eq!(
713            builder
714                .client_metadata
715                .get("fynd_version")
716                .map(String::as_str),
717            Some("0.57.0")
718        );
719        assert_eq!(
720            builder
721                .client_metadata
722                .get("preset")
723                .map(String::as_str),
724            Some("best")
725        );
726    }
727
728    #[ignore = "require tycho gateway"]
729    #[tokio::test]
730    async fn test_simple_build() {
731        let token = env::var("TYCHO_AUTH_TOKEN").unwrap();
732        let receiver = TychoStreamBuilder::new("tycho-beta.propellerheads.xyz", Chain::Ethereum)
733            .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
734            .auth_key(Some(token))
735            .build()
736            .await;
737
738        dbg!(&receiver);
739
740        assert!(receiver.is_ok(), "Client should build successfully with exchanges registered.");
741    }
742}