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 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 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), Chain::Base => (2, 12, 50),
120 Chain::Bsc => (1, 12, 50),
121 Chain::Unichain => (1, 10, 100),
122 Chain::Polygon => (2, 12, 50), }
124 }
125
126 pub fn exchange(mut self, name: &str, filter: ComponentFilter) -> Self {
128 self.exchanges
129 .insert(name.to_string(), filter);
130 self
131 }
132
133 pub fn block_time(mut self, block_time: u64) -> Self {
135 self.block_time = block_time;
136 self
137 }
138
139 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 pub fn no_state(mut self, no_state: bool) -> Self {
181 self.no_state = no_state;
182 self
183 }
184
185 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 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 pub fn no_tls(mut self, no_tls: bool) -> Self {
220 self.no_tls = no_tls;
221 self
222 }
223
224 pub fn include_tvl(mut self, include_tvl: bool) -> Self {
228 self.include_tvl = include_tvl;
229 self
230 }
231
232 pub fn disable_compression(mut self) -> Self {
235 self.compression = false;
236 self
237 }
238
239 pub fn enable_partial_blocks(mut self) -> Self {
241 self.partial_blocks = true;
242 self
243 }
244
245 pub fn max_messages(mut self, n: usize) -> Self {
248 self.max_messages = Some(n);
249 self
250 }
251
252 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 pub fn blocklisted_ids(mut self, ids: impl IntoIterator<Item = String>) -> Self {
267 self.blocklisted_ids.extend(ids);
268 self
269 }
270
271 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 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 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 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 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 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 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 let (sync_jh, rx) = block_sync
391 .run()
392 .await
393 .map_err(|e| StreamError::BlockSynchronizerError(e.to_string()))?;
394
395 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
414pub struct ProtocolSystemsInfo {
417 pub dci_protocols: HashSet<String>,
418 pub other_available: HashSet<String>,
419}
420
421impl ProtocolSystemsInfo {
422 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 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 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 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}