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