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    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
65/// Loads and validates the custom-chain config once, before the stream starts. A missing or
66/// malformed `TYCHO_CHAINS_CONFIG` fails here with an actionable error, rather than degrading to
67/// an empty registry and resurfacing later as a confusing unknown-chain error mid-stream. An unset
68/// env var yields an empty registry (Ok), so built-in-only consumers are unaffected. The validated
69/// registry is best-effort installed as the process-wide one; a prior lazy access may already have
70/// initialised it, in which case the install is a no-op.
71fn 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    /// Creates a new `TychoStreamBuilder` with the given Tycho URL and blockchain network.
122    /// Initializes the builder with default values for block time and timeout based on the chain.
123    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    /// Returns the default block_time, timeout and max_missed_blocks values for the given
156    /// blockchain network.
157    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), // Typically closer to 0.25s
163            Chain::Base => (2, 12, 50),
164            Chain::Bsc => (1, 12, 50),
165            Chain::Unichain => (1, 10, 100),
166            Chain::Polygon => (2, 12, 50),   // ~2s block time
167            Chain::Plasma => (1, 10, 100),   // ~1s block time
168            Chain::Robinhood => (1, 5, 100), // Arbitrum Orbit, typically closer to 0.25s
169            Chain::Arc => (1, 5, 100),       // Typically closer to 0.5s
170            _ => {
171                let block_time = chain.block_time_secs();
172                (block_time, block_time * 3, 50)
173            }
174        }
175    }
176
177    /// Adds an exchange and its corresponding filter to the Tycho client.
178    pub fn exchange(mut self, name: &str, filter: ComponentFilter) -> Self {
179        self.exchanges
180            .insert(name.to_string(), filter);
181        self
182    }
183
184    /// Sets the block time for the Tycho client.
185    pub fn block_time(mut self, block_time: u64) -> Self {
186        self.block_time = block_time;
187        self
188    }
189
190    /// Sets the timeout duration for network operations.
191    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    /// Sets the maximum random delay before a restarted synchronizer requests a snapshot.
219    ///
220    /// After a restart that needs a snapshot, each synchronizer waits a random time in
221    /// `[0, jitter]` before it requests the snapshot. The first start has no delay. Defaults to
222    /// 60 seconds. A `jitter` below 5 seconds is raised to 5 seconds.
223    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    /// Configures the client to exclude state updates from the stream.
241    pub fn no_state(mut self, no_state: bool) -> Self {
242        self.no_state = no_state;
243        self
244    }
245
246    /// Sets the API key for authenticating with the Tycho server.
247    ///
248    /// Optionally you can set the TYCHO_AUTH_TOKEN env var instead. Make sure to set no_tsl
249    /// to false if you do this.
250    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    /// Adds client-metadata entries sent to the server in the `X-Tycho-Client-Metadata` header.
257    ///
258    /// The metadata is opaque to tycho-client; consumers supply their own keys. Accepts any
259    /// iterator of key/value pairs and inserts each entry, keeping previously set metadata. An
260    /// empty map sends no header. Invalid keys/values and oversized metadata are dropped with a
261    /// warning at `build()` time, sending no header.
262    ///
263    /// Values are self-reported and may surface in the server's metrics and logs. Do not include
264    /// secrets or personally identifiable information.
265    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    /// Disables TLS/SSL for the connection, using `http` and `ws` protocols.
280    pub fn no_tls(mut self, no_tls: bool) -> Self {
281        self.no_tls = no_tls;
282        self
283    }
284
285    /// Configures the client to include TVL in the stream.
286    ///
287    /// If set to true, this will increase start-up time due to additional requests.
288    pub fn include_tvl(mut self, include_tvl: bool) -> Self {
289        self.include_tvl = include_tvl;
290        self
291    }
292
293    /// Disables compression for RPC and WebSocket communication.
294    /// By default, messages are compressed using zstd.
295    pub fn disable_compression(mut self) -> Self {
296        self.compression = false;
297        self
298    }
299
300    /// Enables the client to receive partial block updates (flashblocks).
301    pub fn enable_partial_blocks(mut self) -> Self {
302        self.partial_blocks = true;
303        self
304    }
305
306    /// Sets the number of deltas buffered for each WebSocket subscription.
307    ///
308    /// The default is 128. Values outside Tokio's supported channel range are rejected as setup
309    /// errors when [`build`](Self::build) is called, before any network I/O begins.
310    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    /// Stops the stream after emitting this many messages. Useful for testing or
316    /// triggering a periodic restart after a fixed number of blocks.
317    pub fn max_messages(mut self, n: usize) -> Self {
318        self.max_messages = Some(n);
319        self
320    }
321
322    /// Overrides the maximum number of retry attempts for state synchronizer startup.
323    /// The retry cooldown is derived from the chain's block time and is not affected.
324    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    /// Blocklist specific component IDs across all registered exchanges.
333    ///
334    /// Blocklisted components are never tracked, regardless of TVL or other
335    /// filter criteria.
336    pub fn blocklisted_ids(mut self, ids: impl IntoIterator<Item = String>) -> Self {
337        self.blocklisted_ids.extend(ids);
338        self
339    }
340
341    /// Constructs the WebSocket delta client from this builder's retry, buffer, and metadata
342    /// configuration.
343    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    /// Builds and starts the Tycho client, connecting to the Tycho server and
367    /// setting up the synchronization of exchange components.
368    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        // Serialize client metadata once, before any network I/O. Metadata is best-effort
383        // telemetry, so invalid input is dropped with a warning rather than failing the stream.
384        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        // Fail fast on a broken custom-chain config, before any network I/O.
391        validate_chain_config()?;
392
393        // Attempt to read the authentication key from the environment variable if not provided
394        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        // Determine the URLs based on the TLS setting
402        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        // Create and configure the BlockSynchronizer
429        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        // Register each exchange with the BlockSynchronizer
444        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    /// Connects `ws_client`, starts `block_sync` over it, and spawns a task that closes the
484    /// websocket once it or the block synchronizer ends.
485    ///
486    /// Returns the monitor task and the feed receiver. When the block synchronizer fails to
487    /// start, the websocket is closed before the error is returned.
488    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        // Only the `Err` arm closes the websocket. A caller that drops this future while `run`
501        // is pending, for example under `tokio::time::timeout`, still leaks the connection.
502        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                    // The task has ended, so its handle must not be polled again.
515                    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    /// Closes the websocket and waits up to one second for its task to end, then aborts it.
530    ///
531    /// The close command only reaches the task while it is connected; one between reconnection
532    /// attempts is aborted instead, so it cannot open another connection after the caller has
533    /// given up on it.
534    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
553/// Result of fetching protocol systems: which protocols use DCI, and which
554/// available protocols on the server were not requested by the client.
555pub struct ProtocolSystemsInfo {
556    pub dci_protocols: HashSet<String>,
557    pub other_available: HashSet<String>,
558}
559
560impl ProtocolSystemsInfo {
561    /// Fetches protocol systems from the server and classifies them: which use DCI,
562    /// and which are available but not in `requested_exchanges`.
563    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        // TODO(ENG-5302): Remove this fallback once all environments serve
613        // the `dci_protocols` field. Old servers omit the field, which
614        // deserialises as empty — causing clients to skip entrypoint
615        // fetches for DCI protocols.
616        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    /// Logs the protocols available on the server that the client didn't subscribe to.
636    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    /// Accepts one websocket connection and reports when the client closes it.
658    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    /// A failed `start_stream` must close the websocket it opened. Nothing else can reach it
684    /// once `build` has returned.
685    #[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        // No synchronizers registered, so `run` fails with `NoSynchronizers`.
690        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        // Timed from the start of `start_stream` and shorter than the one-second abort window, so
697        // only a graceful close passes.
698        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    /// A websocket task that ignores the close command, as one between reconnection attempts
707    /// does, is aborted.
708    #[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        // Relies on nextest process isolation: this mutates the process-global env var.
728        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        // Verify configs are stored correctly by checking they match expected values
772        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}