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};
13use tracing::{info, warn};
14use tycho_common::{
15 dto::{PaginationLimits, ProtocolSystemsRequestBody},
16 models::{
17 chain_config::{init_chain_registry, ChainConfigRegistry},
18 Chain, ExtractorIdentity,
19 },
20};
21
22use crate::{
23 client_metadata::serialize_client_metadata,
24 deltas::{DeltasClient, DEFAULT_RECONNECTING_SUBSCRIPTION_BUFFER_SIZE},
25 feed::{
26 component_tracker::ComponentFilter, synchronizer::ProtocolStateSynchronizer, BlockHeader,
27 BlockSynchronizer, BlockSynchronizerError, FeedMessage,
28 },
29 rpc::{HttpRPCClientOptions, ProtocolSystemsParams, RPCClient},
30 HttpRPCClient, WsDeltasClient,
31};
32
33#[derive(Error, Debug)]
34pub enum StreamError {
35 #[error("Error during stream set up: {0}")]
36 SetUpError(String),
37
38 #[error("WebSocket client connection error: {0}")]
39 WebSocketConnectionError(String),
40
41 #[error("BlockSynchronizer error: {0}")]
42 BlockSynchronizerError(String),
43}
44
45#[non_exhaustive]
46#[derive(Clone, Debug)]
47pub enum RetryConfiguration {
48 Constant(ConstantRetryConfiguration),
49}
50
51impl RetryConfiguration {
52 pub fn constant(max_attempts: u64, cooldown: Duration) -> Self {
53 RetryConfiguration::Constant(ConstantRetryConfiguration { max_attempts, cooldown })
54 }
55}
56
57#[derive(Clone, Debug)]
58pub struct ConstantRetryConfiguration {
59 max_attempts: u64,
60 cooldown: Duration,
61}
62
63fn validate_chain_config() -> Result<(), StreamError> {
70 let registry = ChainConfigRegistry::load_default()
71 .map_err(|e| StreamError::SetUpError(format!("failed to load custom chain config: {e}")))?;
72 let _ = init_chain_registry(registry);
73 Ok(())
74}
75
76fn validate_subscription_buffer_size(subscription_buffer_size: usize) -> Result<(), StreamError> {
77 if subscription_buffer_size == 0 {
78 return Err(StreamError::SetUpError(
79 "subscription buffer size must be greater than zero".to_string(),
80 ));
81 }
82
83 if subscription_buffer_size > Semaphore::MAX_PERMITS {
84 return Err(StreamError::SetUpError(format!(
85 "subscription buffer size must not exceed {} (Tokio's maximum channel capacity); \
86 choose a value between 1 and {}",
87 Semaphore::MAX_PERMITS,
88 Semaphore::MAX_PERMITS,
89 )));
90 }
91
92 Ok(())
93}
94
95pub struct TychoStreamBuilder {
96 tycho_url: String,
97 chain: Chain,
98 exchanges: HashMap<String, ComponentFilter>,
99 blocklisted_ids: HashSet<String>,
100 block_time: u64,
101 timeout: u64,
102 startup_timeout: Duration,
103 max_missed_blocks: u64,
104 state_sync_retry_config: RetryConfiguration,
105 websockets_retry_config: RetryConfiguration,
106 no_state: bool,
107 auth_key: Option<String>,
108 no_tls: bool,
109 include_tvl: bool,
110 compression: bool,
111 partial_blocks: bool,
112 max_messages: Option<usize>,
113 client_metadata: HashMap<String, String>,
114 subscription_buffer_size: usize,
115}
116
117impl TychoStreamBuilder {
118 pub fn new(tycho_url: &str, chain: Chain) -> Self {
121 let (block_time, timeout, max_missed_blocks) = Self::default_timing(&chain);
122 Self {
123 tycho_url: tycho_url.to_string(),
124 chain,
125 exchanges: HashMap::new(),
126 blocklisted_ids: HashSet::new(),
127 block_time,
128 timeout,
129 startup_timeout: Duration::from_secs(block_time * max_missed_blocks),
130 max_missed_blocks,
131 state_sync_retry_config: RetryConfiguration::constant(
132 32,
133 Duration::from_secs(max(block_time / 4, 2)),
134 ),
135 websockets_retry_config: RetryConfiguration::constant(
136 128,
137 Duration::from_secs(max(block_time / 6, 1)),
138 ),
139 no_state: false,
140 auth_key: None,
141 no_tls: true,
142 include_tvl: false,
143 compression: true,
144 partial_blocks: false,
145 max_messages: None,
146 client_metadata: HashMap::new(),
147 subscription_buffer_size: DEFAULT_RECONNECTING_SUBSCRIPTION_BUFFER_SIZE,
148 }
149 }
150
151 fn default_timing(chain: &Chain) -> (u64, u64, u64) {
154 match chain {
155 Chain::Ethereum => (12, 36, 50),
156 Chain::Starknet => (2, 8, 50),
157 Chain::ZkSync => (3, 12, 50),
158 Chain::Arbitrum => (1, 2, 100), Chain::Base => (2, 12, 50),
160 Chain::Bsc => (1, 12, 50),
161 Chain::Unichain => (1, 10, 100),
162 Chain::Polygon => (2, 12, 50), Chain::Plasma => (1, 10, 100), Chain::Robinhood => (1, 5, 100), _ => {
166 let block_time = chain.block_time_secs();
167 (block_time, block_time * 3, 50)
168 }
169 }
170 }
171
172 pub fn exchange(mut self, name: &str, filter: ComponentFilter) -> Self {
174 self.exchanges
175 .insert(name.to_string(), filter);
176 self
177 }
178
179 pub fn block_time(mut self, block_time: u64) -> Self {
181 self.block_time = block_time;
182 self
183 }
184
185 pub fn timeout(mut self, timeout: u64) -> Self {
187 self.timeout = timeout;
188 self
189 }
190
191 pub fn startup_timeout(mut self, timeout: Duration) -> Self {
192 self.startup_timeout = timeout;
193 self
194 }
195
196 pub fn max_missed_blocks(mut self, max_missed_blocks: u64) -> Self {
197 self.max_missed_blocks = max_missed_blocks;
198 self
199 }
200
201 pub fn websockets_retry_config(mut self, retry_config: &RetryConfiguration) -> Self {
202 self.websockets_retry_config = retry_config.clone();
203 self.warn_on_potential_timing_issues();
204 self
205 }
206
207 pub fn state_synchronizer_retry_config(mut self, retry_config: &RetryConfiguration) -> Self {
208 self.state_sync_retry_config = retry_config.clone();
209 self.warn_on_potential_timing_issues();
210 self
211 }
212
213 fn warn_on_potential_timing_issues(&self) {
214 let (RetryConfiguration::Constant(state_config), RetryConfiguration::Constant(ws_config)) =
215 (&self.state_sync_retry_config, &self.websockets_retry_config);
216
217 if ws_config.cooldown >= state_config.cooldown {
218 warn!(
219 "Websocket cooldown should be < than state syncronizer cooldown \
220 to avoid spending retries due to disconnected websocket."
221 )
222 }
223 }
224
225 pub fn no_state(mut self, no_state: bool) -> Self {
227 self.no_state = no_state;
228 self
229 }
230
231 pub fn auth_key(mut self, auth_key: Option<String>) -> Self {
236 self.auth_key = auth_key;
237 self.no_tls = false;
238 self
239 }
240
241 pub fn add_client_metadata<I, K, V>(mut self, metadata: I) -> Self
251 where
252 I: IntoIterator<Item = (K, V)>,
253 K: Into<String>,
254 V: Into<String>,
255 {
256 self.client_metadata.extend(
257 metadata
258 .into_iter()
259 .map(|(k, v)| (k.into(), v.into())),
260 );
261 self
262 }
263
264 pub fn no_tls(mut self, no_tls: bool) -> Self {
266 self.no_tls = no_tls;
267 self
268 }
269
270 pub fn include_tvl(mut self, include_tvl: bool) -> Self {
274 self.include_tvl = include_tvl;
275 self
276 }
277
278 pub fn disable_compression(mut self) -> Self {
281 self.compression = false;
282 self
283 }
284
285 pub fn enable_partial_blocks(mut self) -> Self {
287 self.partial_blocks = true;
288 self
289 }
290
291 pub fn subscription_buffer_size(mut self, subscription_buffer_size: usize) -> Self {
296 self.subscription_buffer_size = subscription_buffer_size;
297 self
298 }
299
300 pub fn max_messages(mut self, n: usize) -> Self {
303 self.max_messages = Some(n);
304 self
305 }
306
307 pub fn max_retries(mut self, max_retries: u64) -> Self {
310 let cooldown = match &self.state_sync_retry_config {
311 RetryConfiguration::Constant(c) => c.cooldown,
312 };
313 self.state_sync_retry_config = RetryConfiguration::constant(max_retries, cooldown);
314 self
315 }
316
317 pub fn blocklisted_ids(mut self, ids: impl IntoIterator<Item = String>) -> Self {
322 self.blocklisted_ids.extend(ids);
323 self
324 }
325
326 pub(crate) fn build_ws_deltas_client(
329 &self,
330 ws_uri: &str,
331 auth_key: Option<&str>,
332 client_metadata_header: Option<String>,
333 ) -> Result<WsDeltasClient, StreamError> {
334 validate_subscription_buffer_size(self.subscription_buffer_size)?;
335
336 let ws_client = match &self.websockets_retry_config {
337 RetryConfiguration::Constant(config) => WsDeltasClient::new_with_reconnects(
338 ws_uri,
339 auth_key,
340 config.max_attempts,
341 config.cooldown,
342 ),
343 }
344 .map_err(|e| StreamError::SetUpError(e.to_string()))?
345 .with_subscription_buffer_size(self.subscription_buffer_size)
346 .with_client_metadata_header(client_metadata_header);
347
348 Ok(ws_client)
349 }
350
351 pub async fn build(
354 self,
355 ) -> Result<
356 (JoinHandle<()>, Receiver<Result<FeedMessage<BlockHeader>, BlockSynchronizerError>>),
357 StreamError,
358 > {
359 validate_subscription_buffer_size(self.subscription_buffer_size)?;
360
361 if self.exchanges.is_empty() {
362 return Err(StreamError::SetUpError(
363 "At least one exchange must be registered.".to_string(),
364 ));
365 }
366
367 let metadata_header =
370 serialize_client_metadata(&self.client_metadata).unwrap_or_else(|e| {
371 warn!("Ignoring invalid client metadata: {e}");
372 None
373 });
374
375 validate_chain_config()?;
377
378 let auth_key = self
380 .auth_key
381 .clone()
382 .or_else(|| env::var("TYCHO_AUTH_TOKEN").ok());
383
384 info!("Running with version: {}", option_env!("CARGO_PKG_VERSION").unwrap_or("unknown"));
385
386 let (tycho_ws_url, tycho_rpc_url) = if self.no_tls {
388 info!("Using non-secure connection: ws:// and http://");
389 let tycho_ws_url = format!("ws://{}", self.tycho_url);
390 let tycho_rpc_url = format!("http://{}", self.tycho_url);
391 (tycho_ws_url, tycho_rpc_url)
392 } else {
393 info!("Using secure connection: wss:// and https://");
394 let tycho_ws_url = format!("wss://{}", self.tycho_url);
395 let tycho_rpc_url = format!("https://{}", self.tycho_url);
396 (tycho_ws_url, tycho_rpc_url)
397 };
398
399 let ws_client = self.build_ws_deltas_client(
400 &tycho_ws_url,
401 auth_key.as_deref(),
402 metadata_header.clone(),
403 )?;
404 let rpc_client = HttpRPCClient::new(
405 &tycho_rpc_url,
406 HttpRPCClientOptions::new()
407 .with_auth_key(auth_key)
408 .with_compression(self.compression)
409 .with_client_metadata_header(metadata_header),
410 )
411 .map_err(|e| StreamError::SetUpError(e.to_string()))?;
412 let ws_jh = ws_client
413 .connect()
414 .await
415 .map_err(|e| StreamError::WebSocketConnectionError(e.to_string()))?;
416
417 let mut block_sync = BlockSynchronizer::new(
419 Duration::from_secs(self.block_time),
420 Duration::from_secs(self.timeout),
421 self.max_missed_blocks,
422 );
423 if let Some(n) = self.max_messages {
424 block_sync.max_messages(n);
425 }
426
427 let requested: HashSet<_> = self.exchanges.keys().cloned().collect();
428 let info = ProtocolSystemsInfo::fetch(&rpc_client, self.chain, &requested).await;
429 info.log_other_available();
430 let dci_protocols = info.dci_protocols;
431
432 for (name, filter) in self
434 .exchanges
435 .into_iter()
436 .map(|(name, filter)| {
437 let filter = if self.blocklisted_ids.is_empty() {
438 filter
439 } else {
440 filter.blocklist(self.blocklisted_ids.iter().cloned())
441 };
442 (name, filter)
443 })
444 {
445 info!("Registering exchange: {}", name);
446 let id = ExtractorIdentity { chain: self.chain, name: name.clone() };
447 let uses_dci = dci_protocols.contains(&name);
448 let sync = match &self.state_sync_retry_config {
449 RetryConfiguration::Constant(retry_config) => ProtocolStateSynchronizer::new(
450 id.clone(),
451 true,
452 filter,
453 retry_config.max_attempts,
454 retry_config.cooldown,
455 !self.no_state,
456 self.include_tvl,
457 self.compression,
458 rpc_client.clone(),
459 ws_client.clone(),
460 self.block_time + self.timeout,
461 )
462 .with_dci(uses_dci)
463 .with_partial_blocks(self.partial_blocks),
464 };
465 block_sync = block_sync.register_synchronizer(id, sync);
466 }
467
468 let (sync_jh, rx) = block_sync
470 .run()
471 .await
472 .map_err(|e| StreamError::BlockSynchronizerError(e.to_string()))?;
473
474 let handle = tokio::spawn(async move {
476 tokio::select! {
477 res = ws_jh => {
478 let _ = res.map_err(|e| StreamError::WebSocketConnectionError(e.to_string()));
479 }
480 res = sync_jh => {
481 res.map_err(|e| StreamError::BlockSynchronizerError(e.to_string())).unwrap();
482 }
483 }
484 if let Err(e) = ws_client.close().await {
485 warn!(?e, "Failed to close WebSocket client");
486 }
487 });
488
489 Ok((handle, rx))
490 }
491}
492
493pub struct ProtocolSystemsInfo {
496 pub dci_protocols: HashSet<String>,
497 pub other_available: HashSet<String>,
498}
499
500impl ProtocolSystemsInfo {
501 pub async fn fetch(
504 rpc_client: &HttpRPCClient,
505 chain: Chain,
506 requested_exchanges: &HashSet<String>,
507 ) -> Self {
508 let page_size =
509 ProtocolSystemsRequestBody::effective_max_page_size(rpc_client.compression());
510 let params = ProtocolSystemsParams::new(chain).with_pagination(0, page_size);
511 let response = rpc_client
512 .get_protocol_systems(params)
513 .await
514 .map_err(|e| {
515 warn!(
516 "Failed to fetch protocol systems: {e}. Skipping protocol availability check."
517 );
518 e
519 })
520 .ok();
521
522 let Some(response) = response else {
523 return Self { dci_protocols: HashSet::new(), other_available: HashSet::new() };
524 };
525
526 if response.total() > page_size {
527 warn!(
528 "Server has {} protocol systems but only {} were fetched (page_size={page_size}). \
529 Availability info may be incomplete.",
530 response.total(),
531 response.data().protocol_systems().len(),
532 );
533 }
534
535 let available: HashSet<_> = response
536 .data()
537 .protocol_systems()
538 .iter()
539 .cloned()
540 .collect();
541 let other_available = available
542 .difference(requested_exchanges)
543 .cloned()
544 .collect();
545 let mut dci_protocols: HashSet<String> = response
546 .data()
547 .dci_protocols()
548 .iter()
549 .cloned()
550 .collect();
551
552 if dci_protocols.is_empty() {
557 const LEGACY_DCI: &[&str] = &[
558 "uniswap_v4_hooks",
559 "vm:curve",
560 "vm:balancer_v2",
561 "vm:balancer_v3",
562 "fluid_v1",
563 "erc4626",
564 ];
565 for name in requested_exchanges {
566 if LEGACY_DCI.contains(&name.as_str()) {
567 dci_protocols.insert(name.clone());
568 }
569 }
570 }
571
572 Self { dci_protocols, other_available }
573 }
574
575 pub fn log_other_available(&self) {
577 if !self.other_available.is_empty() {
578 let names: Vec<_> = self
579 .other_available
580 .iter()
581 .cloned()
582 .collect();
583 info!("Other available protocols: {}", names.join(", "));
584 }
585 }
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591
592 #[test]
593 fn test_validate_chain_config_errors_on_broken_file() {
594 std::env::set_var("TYCHO_CHAINS_CONFIG", "/nonexistent/does-not-exist.yaml");
596 let result = validate_chain_config();
597 std::env::remove_var("TYCHO_CHAINS_CONFIG");
598
599 let err = result.expect_err("a missing config file must fail validation");
600 assert!(matches!(err, StreamError::SetUpError(_)));
601 assert!(
602 err.to_string()
603 .contains("custom chain config"),
604 "error should name the custom chain config: {err}"
605 );
606 }
607
608 #[test]
609 fn test_validate_chain_config_ok_when_env_unset() {
610 std::env::remove_var("TYCHO_CHAINS_CONFIG");
611 assert!(
612 validate_chain_config().is_ok(),
613 "an unset env var means no custom chains, which is valid"
614 );
615 }
616
617 #[test]
618 fn test_retry_configuration_constant() {
619 let config = RetryConfiguration::constant(5, Duration::from_secs(10));
620 match config {
621 RetryConfiguration::Constant(c) => {
622 assert_eq!(c.max_attempts, 5);
623 assert_eq!(c.cooldown, Duration::from_secs(10));
624 }
625 }
626 }
627
628 #[test]
629 fn test_stream_builder_retry_configs() {
630 let mut builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum);
631 let ws_config = RetryConfiguration::constant(10, Duration::from_secs(2));
632 let state_config = RetryConfiguration::constant(20, Duration::from_secs(5));
633
634 builder = builder
635 .websockets_retry_config(&ws_config)
636 .state_synchronizer_retry_config(&state_config);
637
638 match (&builder.websockets_retry_config, &builder.state_sync_retry_config) {
640 (RetryConfiguration::Constant(ws), RetryConfiguration::Constant(state)) => {
641 assert_eq!(ws.max_attempts, 10);
642 assert_eq!(ws.cooldown, Duration::from_secs(2));
643 assert_eq!(state.max_attempts, 20);
644 assert_eq!(state.cooldown, Duration::from_secs(5));
645 }
646 }
647 }
648
649 #[test]
650 fn test_default_stream_builder() {
651 let builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum);
652 assert!(builder.compression, "Compression should be enabled by default.");
653 assert!(!builder.partial_blocks, "partial_blocks should be disabled by default.");
654 }
655
656 #[tokio::test]
657 async fn test_no_exchanges() {
658 let receiver = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum)
659 .auth_key(Some("my_api_key".into()))
660 .build()
661 .await;
662 assert!(receiver.is_err(), "Client should fail to build when no exchanges are registered.");
663 }
664
665 #[tokio::test]
666 async fn test_zero_subscription_buffer_size_fails_before_network_io() {
667 let error = TychoStreamBuilder::new("not a valid endpoint", Chain::Ethereum)
668 .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
669 .subscription_buffer_size(0)
670 .build()
671 .await
672 .expect_err("a zero subscription buffer size must be rejected during setup");
673
674 assert!(matches!(error, StreamError::SetUpError(_)));
675 assert!(
676 error
677 .to_string()
678 .contains("subscription buffer size must be greater than zero"),
679 "error should explain how to correct the configuration: {error}"
680 );
681 }
682
683 #[tokio::test]
684 async fn test_too_large_subscription_buffer_size_fails_before_network_io() {
685 let error = TychoStreamBuilder::new("not a valid endpoint", Chain::Ethereum)
686 .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
687 .subscription_buffer_size(usize::MAX)
688 .build()
689 .await
690 .expect_err("an oversized subscription buffer size must be rejected during setup");
691
692 assert!(matches!(error, StreamError::SetUpError(_)));
693 assert!(
694 error
695 .to_string()
696 .contains("subscription buffer size must not exceed"),
697 "error should name the maximum supported capacity: {error}"
698 );
699 }
700
701 #[test]
702 fn test_add_client_metadata_accumulates() {
703 let builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum)
704 .add_client_metadata([("fynd_version", "0.57.0")])
705 .add_client_metadata([("preset", "best")]);
706 assert_eq!(
707 builder
708 .client_metadata
709 .get("fynd_version")
710 .map(String::as_str),
711 Some("0.57.0")
712 );
713 assert_eq!(
714 builder
715 .client_metadata
716 .get("preset")
717 .map(String::as_str),
718 Some("best")
719 );
720 }
721
722 #[ignore = "require tycho gateway"]
723 #[tokio::test]
724 async fn test_simple_build() {
725 let token = env::var("TYCHO_AUTH_TOKEN").unwrap();
726 let receiver = TychoStreamBuilder::new("tycho-beta.propellerheads.xyz", Chain::Ethereum)
727 .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
728 .auth_key(Some(token))
729 .build()
730 .await;
731
732 dbg!(&receiver);
733
734 assert!(receiver.is_ok(), "Client should build successfully with exchanges registered.");
735 }
736}