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