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), Chain::Arc => (1, 5, 100), _ => {
167 let block_time = chain.block_time_secs();
168 (block_time, block_time * 3, 50)
169 }
170 }
171 }
172
173 pub fn exchange(mut self, name: &str, filter: ComponentFilter) -> Self {
175 self.exchanges
176 .insert(name.to_string(), filter);
177 self
178 }
179
180 pub fn block_time(mut self, block_time: u64) -> Self {
182 self.block_time = block_time;
183 self
184 }
185
186 pub fn timeout(mut self, timeout: u64) -> Self {
188 self.timeout = timeout;
189 self
190 }
191
192 pub fn startup_timeout(mut self, timeout: Duration) -> Self {
193 self.startup_timeout = timeout;
194 self
195 }
196
197 pub fn max_missed_blocks(mut self, max_missed_blocks: u64) -> Self {
198 self.max_missed_blocks = max_missed_blocks;
199 self
200 }
201
202 pub fn websockets_retry_config(mut self, retry_config: &RetryConfiguration) -> Self {
203 self.websockets_retry_config = retry_config.clone();
204 self.warn_on_potential_timing_issues();
205 self
206 }
207
208 pub fn state_synchronizer_retry_config(mut self, retry_config: &RetryConfiguration) -> Self {
209 self.state_sync_retry_config = retry_config.clone();
210 self.warn_on_potential_timing_issues();
211 self
212 }
213
214 fn warn_on_potential_timing_issues(&self) {
215 let (RetryConfiguration::Constant(state_config), RetryConfiguration::Constant(ws_config)) =
216 (&self.state_sync_retry_config, &self.websockets_retry_config);
217
218 if ws_config.cooldown >= state_config.cooldown {
219 warn!(
220 "Websocket cooldown should be < than state syncronizer cooldown \
221 to avoid spending retries due to disconnected websocket."
222 )
223 }
224 }
225
226 pub fn no_state(mut self, no_state: bool) -> Self {
228 self.no_state = no_state;
229 self
230 }
231
232 pub fn auth_key(mut self, auth_key: Option<String>) -> Self {
237 self.auth_key = auth_key;
238 self.no_tls = false;
239 self
240 }
241
242 pub fn add_client_metadata<I, K, V>(mut self, metadata: I) -> Self
252 where
253 I: IntoIterator<Item = (K, V)>,
254 K: Into<String>,
255 V: Into<String>,
256 {
257 self.client_metadata.extend(
258 metadata
259 .into_iter()
260 .map(|(k, v)| (k.into(), v.into())),
261 );
262 self
263 }
264
265 pub fn no_tls(mut self, no_tls: bool) -> Self {
267 self.no_tls = no_tls;
268 self
269 }
270
271 pub fn include_tvl(mut self, include_tvl: bool) -> Self {
275 self.include_tvl = include_tvl;
276 self
277 }
278
279 pub fn disable_compression(mut self) -> Self {
282 self.compression = false;
283 self
284 }
285
286 pub fn enable_partial_blocks(mut self) -> Self {
288 self.partial_blocks = true;
289 self
290 }
291
292 pub fn subscription_buffer_size(mut self, subscription_buffer_size: usize) -> Self {
297 self.subscription_buffer_size = subscription_buffer_size;
298 self
299 }
300
301 pub fn max_messages(mut self, n: usize) -> Self {
304 self.max_messages = Some(n);
305 self
306 }
307
308 pub fn max_retries(mut self, max_retries: u64) -> Self {
311 let cooldown = match &self.state_sync_retry_config {
312 RetryConfiguration::Constant(c) => c.cooldown,
313 };
314 self.state_sync_retry_config = RetryConfiguration::constant(max_retries, cooldown);
315 self
316 }
317
318 pub fn blocklisted_ids(mut self, ids: impl IntoIterator<Item = String>) -> Self {
323 self.blocklisted_ids.extend(ids);
324 self
325 }
326
327 pub(crate) fn build_ws_deltas_client(
330 &self,
331 ws_uri: &str,
332 auth_key: Option<&str>,
333 client_metadata_header: Option<String>,
334 ) -> Result<WsDeltasClient, StreamError> {
335 validate_subscription_buffer_size(self.subscription_buffer_size)?;
336
337 let ws_client = match &self.websockets_retry_config {
338 RetryConfiguration::Constant(config) => WsDeltasClient::new_with_reconnects(
339 ws_uri,
340 auth_key,
341 config.max_attempts,
342 config.cooldown,
343 ),
344 }
345 .map_err(|e| StreamError::SetUpError(e.to_string()))?
346 .with_subscription_buffer_size(self.subscription_buffer_size)
347 .with_client_metadata_header(client_metadata_header);
348
349 Ok(ws_client)
350 }
351
352 pub async fn build(
355 self,
356 ) -> Result<
357 (JoinHandle<()>, Receiver<Result<FeedMessage<BlockHeader>, BlockSynchronizerError>>),
358 StreamError,
359 > {
360 validate_subscription_buffer_size(self.subscription_buffer_size)?;
361
362 if self.exchanges.is_empty() {
363 return Err(StreamError::SetUpError(
364 "At least one exchange must be registered.".to_string(),
365 ));
366 }
367
368 let metadata_header =
371 serialize_client_metadata(&self.client_metadata).unwrap_or_else(|e| {
372 warn!("Ignoring invalid client metadata: {e}");
373 None
374 });
375
376 validate_chain_config()?;
378
379 let auth_key = self
381 .auth_key
382 .clone()
383 .or_else(|| env::var("TYCHO_AUTH_TOKEN").ok());
384
385 info!("Running with version: {}", option_env!("CARGO_PKG_VERSION").unwrap_or("unknown"));
386
387 let (tycho_ws_url, tycho_rpc_url) = if self.no_tls {
389 info!("Using non-secure connection: ws:// and http://");
390 let tycho_ws_url = format!("ws://{}", self.tycho_url);
391 let tycho_rpc_url = format!("http://{}", self.tycho_url);
392 (tycho_ws_url, tycho_rpc_url)
393 } else {
394 info!("Using secure connection: wss:// and https://");
395 let tycho_ws_url = format!("wss://{}", self.tycho_url);
396 let tycho_rpc_url = format!("https://{}", self.tycho_url);
397 (tycho_ws_url, tycho_rpc_url)
398 };
399
400 let ws_client = self.build_ws_deltas_client(
401 &tycho_ws_url,
402 auth_key.as_deref(),
403 metadata_header.clone(),
404 )?;
405 let rpc_client = HttpRPCClient::new(
406 &tycho_rpc_url,
407 HttpRPCClientOptions::new()
408 .with_auth_key(auth_key)
409 .with_compression(self.compression)
410 .with_client_metadata_header(metadata_header),
411 )
412 .map_err(|e| StreamError::SetUpError(e.to_string()))?;
413 let ws_jh = ws_client
414 .connect()
415 .await
416 .map_err(|e| StreamError::WebSocketConnectionError(e.to_string()))?;
417
418 let mut block_sync = BlockSynchronizer::new(
420 Duration::from_secs(self.block_time),
421 Duration::from_secs(self.timeout),
422 self.max_missed_blocks,
423 );
424 if let Some(n) = self.max_messages {
425 block_sync.max_messages(n);
426 }
427
428 let requested: HashSet<_> = self.exchanges.keys().cloned().collect();
429 let info = ProtocolSystemsInfo::fetch(&rpc_client, self.chain, &requested).await;
430 info.log_other_available();
431 let dci_protocols = info.dci_protocols;
432
433 for (name, filter) in self
435 .exchanges
436 .into_iter()
437 .map(|(name, filter)| {
438 let filter = if self.blocklisted_ids.is_empty() {
439 filter
440 } else {
441 filter.blocklist(self.blocklisted_ids.iter().cloned())
442 };
443 (name, filter)
444 })
445 {
446 info!("Registering exchange: {}", name);
447 let id = ExtractorIdentity { chain: self.chain, name: name.clone() };
448 let uses_dci = dci_protocols.contains(&name);
449 let sync = match &self.state_sync_retry_config {
450 RetryConfiguration::Constant(retry_config) => ProtocolStateSynchronizer::new(
451 id.clone(),
452 true,
453 filter,
454 retry_config.max_attempts,
455 retry_config.cooldown,
456 !self.no_state,
457 self.include_tvl,
458 self.compression,
459 rpc_client.clone(),
460 ws_client.clone(),
461 self.block_time + self.timeout,
462 )
463 .with_dci(uses_dci)
464 .with_partial_blocks(self.partial_blocks),
465 };
466 block_sync = block_sync.register_synchronizer(id, sync);
467 }
468
469 let (sync_jh, rx) = block_sync
471 .run()
472 .await
473 .map_err(|e| StreamError::BlockSynchronizerError(e.to_string()))?;
474
475 let handle = tokio::spawn(async move {
477 tokio::select! {
478 res = ws_jh => {
479 let _ = res.map_err(|e| StreamError::WebSocketConnectionError(e.to_string()));
480 }
481 res = sync_jh => {
482 res.map_err(|e| StreamError::BlockSynchronizerError(e.to_string())).unwrap();
483 }
484 }
485 if let Err(e) = ws_client.close().await {
486 warn!(?e, "Failed to close WebSocket client");
487 }
488 });
489
490 Ok((handle, rx))
491 }
492}
493
494pub struct ProtocolSystemsInfo {
497 pub dci_protocols: HashSet<String>,
498 pub other_available: HashSet<String>,
499}
500
501impl ProtocolSystemsInfo {
502 pub async fn fetch(
505 rpc_client: &HttpRPCClient,
506 chain: Chain,
507 requested_exchanges: &HashSet<String>,
508 ) -> Self {
509 let page_size =
510 ProtocolSystemsRequestBody::effective_max_page_size(rpc_client.compression());
511 let params = ProtocolSystemsParams::new(chain).with_pagination(0, page_size);
512 let response = rpc_client
513 .get_protocol_systems(params)
514 .await
515 .map_err(|e| {
516 warn!(
517 "Failed to fetch protocol systems: {e}. Skipping protocol availability check."
518 );
519 e
520 })
521 .ok();
522
523 let Some(response) = response else {
524 return Self { dci_protocols: HashSet::new(), other_available: HashSet::new() };
525 };
526
527 if response.total() > page_size {
528 warn!(
529 "Server has {} protocol systems but only {} were fetched (page_size={page_size}). \
530 Availability info may be incomplete.",
531 response.total(),
532 response.data().protocol_systems().len(),
533 );
534 }
535
536 let available: HashSet<_> = response
537 .data()
538 .protocol_systems()
539 .iter()
540 .cloned()
541 .collect();
542 let other_available = available
543 .difference(requested_exchanges)
544 .cloned()
545 .collect();
546 let mut dci_protocols: HashSet<String> = response
547 .data()
548 .dci_protocols()
549 .iter()
550 .cloned()
551 .collect();
552
553 if dci_protocols.is_empty() {
558 const LEGACY_DCI: &[&str] = &[
559 "uniswap_v4_hooks",
560 "vm:curve",
561 "vm:balancer_v2",
562 "vm:balancer_v3",
563 "fluid_v1",
564 "erc4626",
565 ];
566 for name in requested_exchanges {
567 if LEGACY_DCI.contains(&name.as_str()) {
568 dci_protocols.insert(name.clone());
569 }
570 }
571 }
572
573 Self { dci_protocols, other_available }
574 }
575
576 pub fn log_other_available(&self) {
578 if !self.other_available.is_empty() {
579 let names: Vec<_> = self
580 .other_available
581 .iter()
582 .cloned()
583 .collect();
584 info!("Other available protocols: {}", names.join(", "));
585 }
586 }
587}
588
589#[cfg(test)]
590mod tests {
591 use super::*;
592
593 #[test]
594 fn test_validate_chain_config_errors_on_broken_file() {
595 std::env::set_var("TYCHO_CHAINS_CONFIG", "/nonexistent/does-not-exist.yaml");
597 let result = validate_chain_config();
598 std::env::remove_var("TYCHO_CHAINS_CONFIG");
599
600 let err = result.expect_err("a missing config file must fail validation");
601 assert!(matches!(err, StreamError::SetUpError(_)));
602 assert!(
603 err.to_string()
604 .contains("custom chain config"),
605 "error should name the custom chain config: {err}"
606 );
607 }
608
609 #[test]
610 fn test_validate_chain_config_ok_when_env_unset() {
611 std::env::remove_var("TYCHO_CHAINS_CONFIG");
612 assert!(
613 validate_chain_config().is_ok(),
614 "an unset env var means no custom chains, which is valid"
615 );
616 }
617
618 #[test]
619 fn test_retry_configuration_constant() {
620 let config = RetryConfiguration::constant(5, Duration::from_secs(10));
621 match config {
622 RetryConfiguration::Constant(c) => {
623 assert_eq!(c.max_attempts, 5);
624 assert_eq!(c.cooldown, Duration::from_secs(10));
625 }
626 }
627 }
628
629 #[test]
630 fn test_stream_builder_retry_configs() {
631 let mut builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum);
632 let ws_config = RetryConfiguration::constant(10, Duration::from_secs(2));
633 let state_config = RetryConfiguration::constant(20, Duration::from_secs(5));
634
635 builder = builder
636 .websockets_retry_config(&ws_config)
637 .state_synchronizer_retry_config(&state_config);
638
639 match (&builder.websockets_retry_config, &builder.state_sync_retry_config) {
641 (RetryConfiguration::Constant(ws), RetryConfiguration::Constant(state)) => {
642 assert_eq!(ws.max_attempts, 10);
643 assert_eq!(ws.cooldown, Duration::from_secs(2));
644 assert_eq!(state.max_attempts, 20);
645 assert_eq!(state.cooldown, Duration::from_secs(5));
646 }
647 }
648 }
649
650 #[test]
651 fn test_default_stream_builder() {
652 let builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum);
653 assert!(builder.compression, "Compression should be enabled by default.");
654 assert!(!builder.partial_blocks, "partial_blocks should be disabled by default.");
655 }
656
657 #[test]
658 fn arc_uses_fast_chain_default_timing() {
659 assert_eq!(TychoStreamBuilder::default_timing(&Chain::Arc), (1, 5, 100));
660 }
661
662 #[tokio::test]
663 async fn test_no_exchanges() {
664 let receiver = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum)
665 .auth_key(Some("my_api_key".into()))
666 .build()
667 .await;
668 assert!(receiver.is_err(), "Client should fail to build when no exchanges are registered.");
669 }
670
671 #[tokio::test]
672 async fn test_zero_subscription_buffer_size_fails_before_network_io() {
673 let error = TychoStreamBuilder::new("not a valid endpoint", Chain::Ethereum)
674 .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
675 .subscription_buffer_size(0)
676 .build()
677 .await
678 .expect_err("a zero subscription buffer size must be rejected during setup");
679
680 assert!(matches!(error, StreamError::SetUpError(_)));
681 assert!(
682 error
683 .to_string()
684 .contains("subscription buffer size must be greater than zero"),
685 "error should explain how to correct the configuration: {error}"
686 );
687 }
688
689 #[tokio::test]
690 async fn test_too_large_subscription_buffer_size_fails_before_network_io() {
691 let error = TychoStreamBuilder::new("not a valid endpoint", Chain::Ethereum)
692 .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
693 .subscription_buffer_size(usize::MAX)
694 .build()
695 .await
696 .expect_err("an oversized subscription buffer size must be rejected during setup");
697
698 assert!(matches!(error, StreamError::SetUpError(_)));
699 assert!(
700 error
701 .to_string()
702 .contains("subscription buffer size must not exceed"),
703 "error should name the maximum supported capacity: {error}"
704 );
705 }
706
707 #[test]
708 fn test_add_client_metadata_accumulates() {
709 let builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum)
710 .add_client_metadata([("fynd_version", "0.57.0")])
711 .add_client_metadata([("preset", "best")]);
712 assert_eq!(
713 builder
714 .client_metadata
715 .get("fynd_version")
716 .map(String::as_str),
717 Some("0.57.0")
718 );
719 assert_eq!(
720 builder
721 .client_metadata
722 .get("preset")
723 .map(String::as_str),
724 Some("best")
725 );
726 }
727
728 #[ignore = "require tycho gateway"]
729 #[tokio::test]
730 async fn test_simple_build() {
731 let token = env::var("TYCHO_AUTH_TOKEN").unwrap();
732 let receiver = TychoStreamBuilder::new("tycho-beta.propellerheads.xyz", Chain::Ethereum)
733 .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
734 .auth_key(Some(token))
735 .build()
736 .await;
737
738 dbg!(&receiver);
739
740 assert!(receiver.is_ok(), "Client should build successfully with exchanges registered.");
741 }
742}