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},
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    no_state: bool,
109    auth_key: Option<String>,
110    no_tls: bool,
111    include_tvl: bool,
112    compression: bool,
113    partial_blocks: bool,
114    max_messages: Option<usize>,
115    client_metadata: HashMap<String, String>,
116    subscription_buffer_size: usize,
117}
118
119impl TychoStreamBuilder {
120    /// Creates a new `TychoStreamBuilder` with the given Tycho URL and blockchain network.
121    /// Initializes the builder with default values for block time and timeout based on the chain.
122    pub fn new(tycho_url: &str, chain: Chain) -> Self {
123        let (block_time, timeout, max_missed_blocks) = Self::default_timing(&chain);
124        Self {
125            tycho_url: tycho_url.to_string(),
126            chain,
127            exchanges: HashMap::new(),
128            blocklisted_ids: HashSet::new(),
129            block_time,
130            timeout,
131            startup_timeout: Duration::from_secs(block_time * max_missed_blocks),
132            max_missed_blocks,
133            state_sync_retry_config: RetryConfiguration::constant(
134                32,
135                Duration::from_secs(max(block_time / 4, 2)),
136            ),
137            websockets_retry_config: RetryConfiguration::constant(
138                128,
139                Duration::from_secs(max(block_time / 6, 1)),
140            ),
141            no_state: false,
142            auth_key: None,
143            no_tls: true,
144            include_tvl: false,
145            compression: true,
146            partial_blocks: false,
147            max_messages: None,
148            client_metadata: HashMap::new(),
149            subscription_buffer_size: DEFAULT_RECONNECTING_SUBSCRIPTION_BUFFER_SIZE,
150        }
151    }
152
153    /// Returns the default block_time, timeout and max_missed_blocks values for the given
154    /// blockchain network.
155    fn default_timing(chain: &Chain) -> (u64, u64, u64) {
156        match chain {
157            Chain::Ethereum => (12, 36, 50),
158            Chain::Starknet => (2, 8, 50),
159            Chain::ZkSync => (3, 12, 50),
160            Chain::Arbitrum => (1, 2, 100), // Typically closer to 0.25s
161            Chain::Base => (2, 12, 50),
162            Chain::Bsc => (1, 12, 50),
163            Chain::Unichain => (1, 10, 100),
164            Chain::Polygon => (2, 12, 50),   // ~2s block time
165            Chain::Plasma => (1, 10, 100),   // ~1s block time
166            Chain::Robinhood => (1, 5, 100), // Arbitrum Orbit, typically closer to 0.25s
167            Chain::Arc => (1, 5, 100),       // Typically closer to 0.5s
168            _ => {
169                let block_time = chain.block_time_secs();
170                (block_time, block_time * 3, 50)
171            }
172        }
173    }
174
175    /// Adds an exchange and its corresponding filter to the Tycho client.
176    pub fn exchange(mut self, name: &str, filter: ComponentFilter) -> Self {
177        self.exchanges
178            .insert(name.to_string(), filter);
179        self
180    }
181
182    /// Sets the block time for the Tycho client.
183    pub fn block_time(mut self, block_time: u64) -> Self {
184        self.block_time = block_time;
185        self
186    }
187
188    /// Sets the timeout duration for network operations.
189    pub fn timeout(mut self, timeout: u64) -> Self {
190        self.timeout = timeout;
191        self
192    }
193
194    pub fn startup_timeout(mut self, timeout: Duration) -> Self {
195        self.startup_timeout = timeout;
196        self
197    }
198
199    pub fn max_missed_blocks(mut self, max_missed_blocks: u64) -> Self {
200        self.max_missed_blocks = max_missed_blocks;
201        self
202    }
203
204    pub fn websockets_retry_config(mut self, retry_config: &RetryConfiguration) -> Self {
205        self.websockets_retry_config = retry_config.clone();
206        self.warn_on_potential_timing_issues();
207        self
208    }
209
210    pub fn state_synchronizer_retry_config(mut self, retry_config: &RetryConfiguration) -> Self {
211        self.state_sync_retry_config = retry_config.clone();
212        self.warn_on_potential_timing_issues();
213        self
214    }
215
216    fn warn_on_potential_timing_issues(&self) {
217        let (RetryConfiguration::Constant(state_config), RetryConfiguration::Constant(ws_config)) =
218            (&self.state_sync_retry_config, &self.websockets_retry_config);
219
220        if ws_config.cooldown >= state_config.cooldown {
221            warn!(
222                "Websocket cooldown should be < than state syncronizer cooldown \
223                to avoid spending retries due to disconnected websocket."
224            )
225        }
226    }
227
228    /// Configures the client to exclude state updates from the stream.
229    pub fn no_state(mut self, no_state: bool) -> Self {
230        self.no_state = no_state;
231        self
232    }
233
234    /// Sets the API key for authenticating with the Tycho server.
235    ///
236    /// Optionally you can set the TYCHO_AUTH_TOKEN env var instead. Make sure to set no_tsl
237    /// to false if you do this.
238    pub fn auth_key(mut self, auth_key: Option<String>) -> Self {
239        self.auth_key = auth_key;
240        self.no_tls = false;
241        self
242    }
243
244    /// Adds client-metadata entries sent to the server in the `X-Tycho-Client-Metadata` header.
245    ///
246    /// The metadata is opaque to tycho-client; consumers supply their own keys. Accepts any
247    /// iterator of key/value pairs and inserts each entry, keeping previously set metadata. An
248    /// empty map sends no header. Invalid keys/values and oversized metadata are dropped with a
249    /// warning at `build()` time, sending no header.
250    ///
251    /// Values are self-reported and may surface in the server's metrics and logs. Do not include
252    /// secrets or personally identifiable information.
253    pub fn add_client_metadata<I, K, V>(mut self, metadata: I) -> Self
254    where
255        I: IntoIterator<Item = (K, V)>,
256        K: Into<String>,
257        V: Into<String>,
258    {
259        self.client_metadata.extend(
260            metadata
261                .into_iter()
262                .map(|(k, v)| (k.into(), v.into())),
263        );
264        self
265    }
266
267    /// Disables TLS/SSL for the connection, using `http` and `ws` protocols.
268    pub fn no_tls(mut self, no_tls: bool) -> Self {
269        self.no_tls = no_tls;
270        self
271    }
272
273    /// Configures the client to include TVL in the stream.
274    ///
275    /// If set to true, this will increase start-up time due to additional requests.
276    pub fn include_tvl(mut self, include_tvl: bool) -> Self {
277        self.include_tvl = include_tvl;
278        self
279    }
280
281    /// Disables compression for RPC and WebSocket communication.
282    /// By default, messages are compressed using zstd.
283    pub fn disable_compression(mut self) -> Self {
284        self.compression = false;
285        self
286    }
287
288    /// Enables the client to receive partial block updates (flashblocks).
289    pub fn enable_partial_blocks(mut self) -> Self {
290        self.partial_blocks = true;
291        self
292    }
293
294    /// Sets the number of deltas buffered for each WebSocket subscription.
295    ///
296    /// The default is 128. Values outside Tokio's supported channel range are rejected as setup
297    /// errors when [`build`](Self::build) is called, before any network I/O begins.
298    pub fn subscription_buffer_size(mut self, subscription_buffer_size: usize) -> Self {
299        self.subscription_buffer_size = subscription_buffer_size;
300        self
301    }
302
303    /// Stops the stream after emitting this many messages. Useful for testing or
304    /// triggering a periodic restart after a fixed number of blocks.
305    pub fn max_messages(mut self, n: usize) -> Self {
306        self.max_messages = Some(n);
307        self
308    }
309
310    /// Overrides the maximum number of retry attempts for state synchronizer startup.
311    /// The retry cooldown is derived from the chain's block time and is not affected.
312    pub fn max_retries(mut self, max_retries: u64) -> Self {
313        let cooldown = match &self.state_sync_retry_config {
314            RetryConfiguration::Constant(c) => c.cooldown,
315        };
316        self.state_sync_retry_config = RetryConfiguration::constant(max_retries, cooldown);
317        self
318    }
319
320    /// Blocklist specific component IDs across all registered exchanges.
321    ///
322    /// Blocklisted components are never tracked, regardless of TVL or other
323    /// filter criteria.
324    pub fn blocklisted_ids(mut self, ids: impl IntoIterator<Item = String>) -> Self {
325        self.blocklisted_ids.extend(ids);
326        self
327    }
328
329    /// Constructs the WebSocket delta client from this builder's retry, buffer, and metadata
330    /// configuration.
331    pub(crate) fn build_ws_deltas_client(
332        &self,
333        ws_uri: &str,
334        auth_key: Option<&str>,
335        client_metadata_header: Option<String>,
336    ) -> Result<WsDeltasClient, StreamError> {
337        validate_subscription_buffer_size(self.subscription_buffer_size)?;
338
339        let ws_client = match &self.websockets_retry_config {
340            RetryConfiguration::Constant(config) => WsDeltasClient::new_with_reconnects(
341                ws_uri,
342                auth_key,
343                config.max_attempts,
344                config.cooldown,
345            ),
346        }
347        .map_err(|e| StreamError::SetUpError(e.to_string()))?
348        .with_subscription_buffer_size(self.subscription_buffer_size)
349        .with_client_metadata_header(client_metadata_header);
350
351        Ok(ws_client)
352    }
353
354    /// Builds and starts the Tycho client, connecting to the Tycho server and
355    /// setting up the synchronization of exchange components.
356    pub async fn build(
357        self,
358    ) -> Result<
359        (JoinHandle<()>, Receiver<Result<FeedMessage<BlockHeader>, BlockSynchronizerError>>),
360        StreamError,
361    > {
362        validate_subscription_buffer_size(self.subscription_buffer_size)?;
363
364        if self.exchanges.is_empty() {
365            return Err(StreamError::SetUpError(
366                "At least one exchange must be registered.".to_string(),
367            ));
368        }
369
370        // Serialize client metadata once, before any network I/O. Metadata is best-effort
371        // telemetry, so invalid input is dropped with a warning rather than failing the stream.
372        let metadata_header =
373            serialize_client_metadata(&self.client_metadata).unwrap_or_else(|e| {
374                warn!("Ignoring invalid client metadata: {e}");
375                None
376            });
377
378        // Fail fast on a broken custom-chain config, before any network I/O.
379        validate_chain_config()?;
380
381        // Attempt to read the authentication key from the environment variable if not provided
382        let auth_key = self
383            .auth_key
384            .clone()
385            .or_else(|| env::var("TYCHO_AUTH_TOKEN").ok());
386
387        info!("Running with version: {}", option_env!("CARGO_PKG_VERSION").unwrap_or("unknown"));
388
389        // Determine the URLs based on the TLS setting
390        let (tycho_ws_url, tycho_rpc_url) = if self.no_tls {
391            info!("Using non-secure connection: ws:// and http://");
392            let tycho_ws_url = format!("ws://{}", self.tycho_url);
393            let tycho_rpc_url = format!("http://{}", self.tycho_url);
394            (tycho_ws_url, tycho_rpc_url)
395        } else {
396            info!("Using secure connection: wss:// and https://");
397            let tycho_ws_url = format!("wss://{}", self.tycho_url);
398            let tycho_rpc_url = format!("https://{}", self.tycho_url);
399            (tycho_ws_url, tycho_rpc_url)
400        };
401
402        let ws_client = self.build_ws_deltas_client(
403            &tycho_ws_url,
404            auth_key.as_deref(),
405            metadata_header.clone(),
406        )?;
407        let rpc_client = HttpRPCClient::new(
408            &tycho_rpc_url,
409            HttpRPCClientOptions::new()
410                .with_auth_key(auth_key)
411                .with_compression(self.compression)
412                .with_client_metadata_header(metadata_header),
413        )
414        .map_err(|e| StreamError::SetUpError(e.to_string()))?;
415
416        // Create and configure the BlockSynchronizer
417        let mut block_sync = BlockSynchronizer::new(
418            Duration::from_secs(self.block_time),
419            Duration::from_secs(self.timeout),
420            self.max_missed_blocks,
421        );
422        if let Some(n) = self.max_messages {
423            block_sync.max_messages(n);
424        }
425
426        let requested: HashSet<_> = self.exchanges.keys().cloned().collect();
427        let info = ProtocolSystemsInfo::fetch(&rpc_client, self.chain, &requested).await;
428        info.log_other_available();
429        let dci_protocols = info.dci_protocols;
430
431        // Register each exchange with the BlockSynchronizer
432        for (name, filter) in self
433            .exchanges
434            .into_iter()
435            .map(|(name, filter)| {
436                let filter = if self.blocklisted_ids.is_empty() {
437                    filter
438                } else {
439                    filter.blocklist(self.blocklisted_ids.iter().cloned())
440                };
441                (name, filter)
442            })
443        {
444            info!("Registering exchange: {}", name);
445            let id = ExtractorIdentity { chain: self.chain, name: name.clone() };
446            let uses_dci = dci_protocols.contains(&name);
447            let sync = match &self.state_sync_retry_config {
448                RetryConfiguration::Constant(retry_config) => ProtocolStateSynchronizer::new(
449                    id.clone(),
450                    true,
451                    filter,
452                    retry_config.max_attempts,
453                    retry_config.cooldown,
454                    !self.no_state,
455                    self.include_tvl,
456                    self.compression,
457                    rpc_client.clone(),
458                    ws_client.clone(),
459                    self.block_time + self.timeout,
460                )
461                .with_dci(uses_dci)
462                .with_partial_blocks(self.partial_blocks),
463            };
464            block_sync = block_sync.register_synchronizer(id, sync);
465        }
466
467        Self::start_stream(ws_client, block_sync).await
468    }
469
470    /// Connects `ws_client`, starts `block_sync` over it, and spawns a task that closes the
471    /// websocket once it or the block synchronizer ends.
472    ///
473    /// Returns the monitor task and the feed receiver. When the block synchronizer fails to
474    /// start, the websocket is closed before the error is returned.
475    async fn start_stream<S: StateSynchronizer>(
476        ws_client: WsDeltasClient,
477        block_sync: BlockSynchronizer<S>,
478    ) -> Result<
479        (JoinHandle<()>, Receiver<Result<FeedMessage<BlockHeader>, BlockSynchronizerError>>),
480        StreamError,
481    > {
482        let mut ws_jh = ws_client
483            .connect()
484            .await
485            .map_err(|e| StreamError::WebSocketConnectionError(e.to_string()))?;
486
487        // Only the `Err` arm closes the websocket. A caller that drops this future while `run`
488        // is pending, for example under `tokio::time::timeout`, still leaks the connection.
489        let (sync_jh, rx) = match block_sync.run().await {
490            Ok(started) => started,
491            Err(e) => {
492                Self::close_websocket(&ws_client, ws_jh).await;
493                return Err(StreamError::BlockSynchronizerError(e.to_string()));
494            }
495        };
496
497        let handle = tokio::spawn(async move {
498            tokio::select! {
499                res = &mut ws_jh => {
500                    let _ = res.map_err(|e| StreamError::WebSocketConnectionError(e.to_string()));
501                    // The task has ended, so its handle must not be polled again.
502                    if let Err(e) = ws_client.close().await {
503                        warn!(?e, "Failed to close WebSocket client");
504                    }
505                }
506                res = sync_jh => {
507                    Self::close_websocket(&ws_client, ws_jh).await;
508                    res.map_err(|e| StreamError::BlockSynchronizerError(e.to_string())).unwrap();
509                }
510            }
511        });
512
513        Ok((handle, rx))
514    }
515
516    /// Closes the websocket and waits up to one second for its task to end, then aborts it.
517    ///
518    /// The close command only reaches the task while it is connected; one between reconnection
519    /// attempts is aborted instead, so it cannot open another connection after the caller has
520    /// given up on it.
521    async fn close_websocket(
522        ws_client: &WsDeltasClient,
523        mut ws_jh: JoinHandle<Result<(), DeltasError>>,
524    ) {
525        if let Err(e) = ws_client.close().await {
526            warn!(?e, "Failed to close WebSocket client");
527        }
528        match timeout(Duration::from_secs(1), &mut ws_jh).await {
529            Ok(Ok(Ok(()))) => {}
530            Ok(Ok(Err(e))) => warn!(?e, "WebSocket task ended with an error"),
531            Ok(Err(e)) => warn!(?e, "WebSocket task panicked"),
532            Err(_) => {
533                warn!("WebSocket task did not stop after close; aborting it");
534                ws_jh.abort();
535            }
536        }
537    }
538}
539
540/// Result of fetching protocol systems: which protocols use DCI, and which
541/// available protocols on the server were not requested by the client.
542pub struct ProtocolSystemsInfo {
543    pub dci_protocols: HashSet<String>,
544    pub other_available: HashSet<String>,
545}
546
547impl ProtocolSystemsInfo {
548    /// Fetches protocol systems from the server and classifies them: which use DCI,
549    /// and which are available but not in `requested_exchanges`.
550    pub async fn fetch(
551        rpc_client: &HttpRPCClient,
552        chain: Chain,
553        requested_exchanges: &HashSet<String>,
554    ) -> Self {
555        let page_size =
556            ProtocolSystemsRequestBody::effective_max_page_size(rpc_client.compression());
557        let params = ProtocolSystemsParams::new(chain).with_pagination(0, page_size);
558        let response = rpc_client
559            .get_protocol_systems(params)
560            .await
561            .map_err(|e| {
562                warn!(
563                    "Failed to fetch protocol systems: {e}. Skipping protocol availability check."
564                );
565                e
566            })
567            .ok();
568
569        let Some(response) = response else {
570            return Self { dci_protocols: HashSet::new(), other_available: HashSet::new() };
571        };
572
573        if response.total() > page_size {
574            warn!(
575                "Server has {} protocol systems but only {} were fetched (page_size={page_size}). \
576                 Availability info may be incomplete.",
577                response.total(),
578                response.data().protocol_systems().len(),
579            );
580        }
581
582        let available: HashSet<_> = response
583            .data()
584            .protocol_systems()
585            .iter()
586            .cloned()
587            .collect();
588        let other_available = available
589            .difference(requested_exchanges)
590            .cloned()
591            .collect();
592        let mut dci_protocols: HashSet<String> = response
593            .data()
594            .dci_protocols()
595            .iter()
596            .cloned()
597            .collect();
598
599        // TODO(ENG-5302): Remove this fallback once all environments serve
600        // the `dci_protocols` field. Old servers omit the field, which
601        // deserialises as empty — causing clients to skip entrypoint
602        // fetches for DCI protocols.
603        if dci_protocols.is_empty() {
604            const LEGACY_DCI: &[&str] = &[
605                "uniswap_v4_hooks",
606                "vm:curve",
607                "vm:balancer_v2",
608                "vm:balancer_v3",
609                "fluid_v1",
610                "erc4626",
611            ];
612            for name in requested_exchanges {
613                if LEGACY_DCI.contains(&name.as_str()) {
614                    dci_protocols.insert(name.clone());
615                }
616            }
617        }
618
619        Self { dci_protocols, other_available }
620    }
621
622    /// Logs the protocols available on the server that the client didn't subscribe to.
623    pub fn log_other_available(&self) {
624        if !self.other_available.is_empty() {
625            let names: Vec<_> = self
626                .other_available
627                .iter()
628                .cloned()
629                .collect();
630            info!("Other available protocols: {}", names.join(", "));
631        }
632    }
633}
634
635#[cfg(test)]
636mod tests {
637    use std::net::SocketAddr;
638
639    use futures03::StreamExt;
640    use tokio::{net::TcpListener, sync::oneshot};
641
642    use super::*;
643
644    /// Accepts one websocket connection and reports when the client closes it.
645    async fn mock_ws_reporting_close() -> (SocketAddr, oneshot::Receiver<()>) {
646        let server = TcpListener::bind("127.0.0.1:0")
647            .await
648            .expect("localhost bind failed");
649        let addr = server.local_addr().unwrap();
650        let (closed_tx, closed_rx) = oneshot::channel();
651
652        tokio::spawn(async move {
653            let (stream, _) = server
654                .accept()
655                .await
656                .expect("accept failed");
657            let mut websocket = tokio_tungstenite::accept_async(stream)
658                .await
659                .expect("websocket handshake failed");
660            while let Some(Ok(msg)) = websocket.next().await {
661                if msg.is_close() {
662                    break;
663                }
664            }
665            let _ = closed_tx.send(());
666        });
667        (addr, closed_rx)
668    }
669
670    /// A failed `start_stream` must close the websocket it opened. Nothing else can reach it
671    /// once `build` has returned.
672    #[tokio::test]
673    async fn test_start_stream_run_failure() {
674        let (addr, closed_rx) = mock_ws_reporting_close().await;
675        let ws_client = WsDeltasClient::new(&format!("ws://{addr}"), None).unwrap();
676        // No synchronizers registered, so `run` fails with `NoSynchronizers`.
677        let block_sync: BlockSynchronizer<
678            ProtocolStateSynchronizer<HttpRPCClient, WsDeltasClient>,
679        > = BlockSynchronizer::new(Duration::from_secs(1), Duration::from_secs(1), 1);
680
681        let start = tokio::spawn(TychoStreamBuilder::start_stream(ws_client, block_sync));
682
683        // Timed from the start of `start_stream` and shorter than the one-second abort window, so
684        // only a graceful close passes.
685        timeout(Duration::from_millis(500), closed_rx)
686            .await
687            .expect("server should observe the websocket closing")
688            .expect("mock server exited without reporting");
689        let res = start.await.unwrap();
690        assert!(matches!(res, Err(StreamError::BlockSynchronizerError(_))), "got {res:?}");
691    }
692
693    /// A websocket task that ignores the close command, as one between reconnection attempts
694    /// does, is aborted.
695    #[tokio::test(start_paused = true)]
696    async fn test_close_websocket_aborts_unresponsive_task() {
697        let ws_client = WsDeltasClient::new("ws://127.0.0.1:1", None).unwrap();
698        let (alive_tx, alive_rx) = oneshot::channel::<()>();
699        let ws_jh = tokio::spawn(async move {
700            let _alive_tx = alive_tx;
701            std::future::pending::<Result<(), DeltasError>>().await
702        });
703
704        TychoStreamBuilder::close_websocket(&ws_client, ws_jh).await;
705
706        let res = timeout(Duration::from_secs(5), alive_rx)
707            .await
708            .expect("the task should be aborted");
709        assert!(res.is_err(), "the aborted task should drop its sender");
710    }
711
712    #[test]
713    fn test_validate_chain_config_errors_on_broken_file() {
714        // Relies on nextest process isolation: this mutates the process-global env var.
715        std::env::set_var("TYCHO_CHAINS_CONFIG", "/nonexistent/does-not-exist.yaml");
716        let result = validate_chain_config();
717        std::env::remove_var("TYCHO_CHAINS_CONFIG");
718
719        let err = result.expect_err("a missing config file must fail validation");
720        assert!(matches!(err, StreamError::SetUpError(_)));
721        assert!(
722            err.to_string()
723                .contains("custom chain config"),
724            "error should name the custom chain config: {err}"
725        );
726    }
727
728    #[test]
729    fn test_validate_chain_config_ok_when_env_unset() {
730        std::env::remove_var("TYCHO_CHAINS_CONFIG");
731        assert!(
732            validate_chain_config().is_ok(),
733            "an unset env var means no custom chains, which is valid"
734        );
735    }
736
737    #[test]
738    fn test_retry_configuration_constant() {
739        let config = RetryConfiguration::constant(5, Duration::from_secs(10));
740        match config {
741            RetryConfiguration::Constant(c) => {
742                assert_eq!(c.max_attempts, 5);
743                assert_eq!(c.cooldown, Duration::from_secs(10));
744            }
745        }
746    }
747
748    #[test]
749    fn test_stream_builder_retry_configs() {
750        let mut builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum);
751        let ws_config = RetryConfiguration::constant(10, Duration::from_secs(2));
752        let state_config = RetryConfiguration::constant(20, Duration::from_secs(5));
753
754        builder = builder
755            .websockets_retry_config(&ws_config)
756            .state_synchronizer_retry_config(&state_config);
757
758        // Verify configs are stored correctly by checking they match expected values
759        match (&builder.websockets_retry_config, &builder.state_sync_retry_config) {
760            (RetryConfiguration::Constant(ws), RetryConfiguration::Constant(state)) => {
761                assert_eq!(ws.max_attempts, 10);
762                assert_eq!(ws.cooldown, Duration::from_secs(2));
763                assert_eq!(state.max_attempts, 20);
764                assert_eq!(state.cooldown, Duration::from_secs(5));
765            }
766        }
767    }
768
769    #[test]
770    fn test_default_stream_builder() {
771        let builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum);
772        assert!(builder.compression, "Compression should be enabled by default.");
773        assert!(!builder.partial_blocks, "partial_blocks should be disabled by default.");
774    }
775
776    #[test]
777    fn arc_uses_fast_chain_default_timing() {
778        assert_eq!(TychoStreamBuilder::default_timing(&Chain::Arc), (1, 5, 100));
779    }
780
781    #[tokio::test]
782    async fn test_no_exchanges() {
783        let receiver = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum)
784            .auth_key(Some("my_api_key".into()))
785            .build()
786            .await;
787        assert!(receiver.is_err(), "Client should fail to build when no exchanges are registered.");
788    }
789
790    #[tokio::test]
791    async fn test_zero_subscription_buffer_size_fails_before_network_io() {
792        let error = TychoStreamBuilder::new("not a valid endpoint", Chain::Ethereum)
793            .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
794            .subscription_buffer_size(0)
795            .build()
796            .await
797            .expect_err("a zero subscription buffer size must be rejected during setup");
798
799        assert!(matches!(error, StreamError::SetUpError(_)));
800        assert!(
801            error
802                .to_string()
803                .contains("subscription buffer size must be greater than zero"),
804            "error should explain how to correct the configuration: {error}"
805        );
806    }
807
808    #[tokio::test]
809    async fn test_too_large_subscription_buffer_size_fails_before_network_io() {
810        let error = TychoStreamBuilder::new("not a valid endpoint", Chain::Ethereum)
811            .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
812            .subscription_buffer_size(usize::MAX)
813            .build()
814            .await
815            .expect_err("an oversized subscription buffer size must be rejected during setup");
816
817        assert!(matches!(error, StreamError::SetUpError(_)));
818        assert!(
819            error
820                .to_string()
821                .contains("subscription buffer size must not exceed"),
822            "error should name the maximum supported capacity: {error}"
823        );
824    }
825
826    #[test]
827    fn test_add_client_metadata_accumulates() {
828        let builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum)
829            .add_client_metadata([("fynd_version", "0.57.0")])
830            .add_client_metadata([("preset", "best")]);
831        assert_eq!(
832            builder
833                .client_metadata
834                .get("fynd_version")
835                .map(String::as_str),
836            Some("0.57.0")
837        );
838        assert_eq!(
839            builder
840                .client_metadata
841                .get("preset")
842                .map(String::as_str),
843            Some("best")
844        );
845    }
846
847    #[ignore = "require tycho gateway"]
848    #[tokio::test]
849    async fn test_simple_build() {
850        let token = env::var("TYCHO_AUTH_TOKEN").unwrap();
851        let receiver = TychoStreamBuilder::new("tycho-beta.propellerheads.xyz", Chain::Ethereum)
852            .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
853            .auth_key(Some(token))
854            .build()
855            .await;
856
857        dbg!(&receiver);
858
859        assert!(receiver.is_ok(), "Client should build successfully with exchanges registered.");
860    }
861}