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