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
60fn 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 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 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), Chain::Base => (2, 12, 50),
136 Chain::Bsc => (1, 12, 50),
137 Chain::Unichain => (1, 10, 100),
138 Chain::Polygon => (2, 12, 50), Chain::Plasma => (1, 10, 100), _ => {
141 let block_time = chain.block_time_secs();
142 (block_time, block_time * 3, 50)
143 }
144 }
145 }
146
147 pub fn exchange(mut self, name: &str, filter: ComponentFilter) -> Self {
149 self.exchanges
150 .insert(name.to_string(), filter);
151 self
152 }
153
154 pub fn block_time(mut self, block_time: u64) -> Self {
156 self.block_time = block_time;
157 self
158 }
159
160 pub fn timeout(mut self, timeout: u64) -> Self {
162 self.timeout = timeout;
163 self
164 }
165
166 pub fn startup_timeout(mut self, timeout: Duration) -> Self {
167 self.startup_timeout = timeout;
168 self
169 }
170
171 pub fn max_missed_blocks(mut self, max_missed_blocks: u64) -> Self {
172 self.max_missed_blocks = max_missed_blocks;
173 self
174 }
175
176 pub fn websockets_retry_config(mut self, retry_config: &RetryConfiguration) -> Self {
177 self.websockets_retry_config = retry_config.clone();
178 self.warn_on_potential_timing_issues();
179 self
180 }
181
182 pub fn state_synchronizer_retry_config(mut self, retry_config: &RetryConfiguration) -> Self {
183 self.state_sync_retry_config = retry_config.clone();
184 self.warn_on_potential_timing_issues();
185 self
186 }
187
188 fn warn_on_potential_timing_issues(&self) {
189 let (RetryConfiguration::Constant(state_config), RetryConfiguration::Constant(ws_config)) =
190 (&self.state_sync_retry_config, &self.websockets_retry_config);
191
192 if ws_config.cooldown >= state_config.cooldown {
193 warn!(
194 "Websocket cooldown should be < than state syncronizer cooldown \
195 to avoid spending retries due to disconnected websocket."
196 )
197 }
198 }
199
200 pub fn no_state(mut self, no_state: bool) -> Self {
202 self.no_state = no_state;
203 self
204 }
205
206 pub fn auth_key(mut self, auth_key: Option<String>) -> Self {
211 self.auth_key = auth_key;
212 self.no_tls = false;
213 self
214 }
215
216 pub fn add_client_metadata<I, K, V>(mut self, metadata: I) -> Self
226 where
227 I: IntoIterator<Item = (K, V)>,
228 K: Into<String>,
229 V: Into<String>,
230 {
231 self.client_metadata.extend(
232 metadata
233 .into_iter()
234 .map(|(k, v)| (k.into(), v.into())),
235 );
236 self
237 }
238
239 pub fn no_tls(mut self, no_tls: bool) -> Self {
241 self.no_tls = no_tls;
242 self
243 }
244
245 pub fn include_tvl(mut self, include_tvl: bool) -> Self {
249 self.include_tvl = include_tvl;
250 self
251 }
252
253 pub fn disable_compression(mut self) -> Self {
256 self.compression = false;
257 self
258 }
259
260 pub fn enable_partial_blocks(mut self) -> Self {
262 self.partial_blocks = true;
263 self
264 }
265
266 pub fn max_messages(mut self, n: usize) -> Self {
269 self.max_messages = Some(n);
270 self
271 }
272
273 pub fn max_retries(mut self, max_retries: u64) -> Self {
276 let cooldown = match &self.state_sync_retry_config {
277 RetryConfiguration::Constant(c) => c.cooldown,
278 };
279 self.state_sync_retry_config = RetryConfiguration::constant(max_retries, cooldown);
280 self
281 }
282
283 pub fn blocklisted_ids(mut self, ids: impl IntoIterator<Item = String>) -> Self {
288 self.blocklisted_ids.extend(ids);
289 self
290 }
291
292 pub async fn build(
295 self,
296 ) -> Result<
297 (JoinHandle<()>, Receiver<Result<FeedMessage<BlockHeader>, BlockSynchronizerError>>),
298 StreamError,
299 > {
300 if self.exchanges.is_empty() {
301 return Err(StreamError::SetUpError(
302 "At least one exchange must be registered.".to_string(),
303 ));
304 }
305
306 let metadata_header =
309 serialize_client_metadata(&self.client_metadata).unwrap_or_else(|e| {
310 warn!("Ignoring invalid client metadata: {e}");
311 None
312 });
313
314 validate_chain_config()?;
316
317 let auth_key = self
319 .auth_key
320 .clone()
321 .or_else(|| env::var("TYCHO_AUTH_TOKEN").ok());
322
323 info!("Running with version: {}", option_env!("CARGO_PKG_VERSION").unwrap_or("unknown"));
324
325 let (tycho_ws_url, tycho_rpc_url) = if self.no_tls {
327 info!("Using non-secure connection: ws:// and http://");
328 let tycho_ws_url = format!("ws://{}", self.tycho_url);
329 let tycho_rpc_url = format!("http://{}", self.tycho_url);
330 (tycho_ws_url, tycho_rpc_url)
331 } else {
332 info!("Using secure connection: wss:// and https://");
333 let tycho_ws_url = format!("wss://{}", self.tycho_url);
334 let tycho_rpc_url = format!("https://{}", self.tycho_url);
335 (tycho_ws_url, tycho_rpc_url)
336 };
337
338 let ws_client = match &self.websockets_retry_config {
340 RetryConfiguration::Constant(config) => WsDeltasClient::new_with_reconnects(
341 &tycho_ws_url,
342 auth_key.as_deref(),
343 config.max_attempts,
344 config.cooldown,
345 ),
346 }
347 .map_err(|e| StreamError::SetUpError(e.to_string()))?
348 .with_client_metadata_header(metadata_header.clone());
349 let rpc_client = HttpRPCClient::new(
350 &tycho_rpc_url,
351 HttpRPCClientOptions::new()
352 .with_auth_key(auth_key)
353 .with_compression(self.compression)
354 .with_client_metadata_header(metadata_header),
355 )
356 .map_err(|e| StreamError::SetUpError(e.to_string()))?;
357 let ws_jh = ws_client
358 .connect()
359 .await
360 .map_err(|e| StreamError::WebSocketConnectionError(e.to_string()))?;
361
362 let mut block_sync = BlockSynchronizer::new(
364 Duration::from_secs(self.block_time),
365 Duration::from_secs(self.timeout),
366 self.max_missed_blocks,
367 );
368 if let Some(n) = self.max_messages {
369 block_sync.max_messages(n);
370 }
371
372 let requested: HashSet<_> = self.exchanges.keys().cloned().collect();
373 let info = ProtocolSystemsInfo::fetch(&rpc_client, self.chain, &requested).await;
374 info.log_other_available();
375 let dci_protocols = info.dci_protocols;
376
377 for (name, filter) in self
379 .exchanges
380 .into_iter()
381 .map(|(name, filter)| {
382 let filter = if self.blocklisted_ids.is_empty() {
383 filter
384 } else {
385 filter.blocklist(self.blocklisted_ids.iter().cloned())
386 };
387 (name, filter)
388 })
389 {
390 info!("Registering exchange: {}", name);
391 let id = ExtractorIdentity { chain: self.chain, name: name.clone() };
392 let uses_dci = dci_protocols.contains(&name);
393 let sync = match &self.state_sync_retry_config {
394 RetryConfiguration::Constant(retry_config) => ProtocolStateSynchronizer::new(
395 id.clone(),
396 true,
397 filter,
398 retry_config.max_attempts,
399 retry_config.cooldown,
400 !self.no_state,
401 self.include_tvl,
402 self.compression,
403 rpc_client.clone(),
404 ws_client.clone(),
405 self.block_time + self.timeout,
406 )
407 .with_dci(uses_dci)
408 .with_partial_blocks(self.partial_blocks),
409 };
410 block_sync = block_sync.register_synchronizer(id, sync);
411 }
412
413 let (sync_jh, rx) = block_sync
415 .run()
416 .await
417 .map_err(|e| StreamError::BlockSynchronizerError(e.to_string()))?;
418
419 let handle = tokio::spawn(async move {
421 tokio::select! {
422 res = ws_jh => {
423 let _ = res.map_err(|e| StreamError::WebSocketConnectionError(e.to_string()));
424 }
425 res = sync_jh => {
426 res.map_err(|e| StreamError::BlockSynchronizerError(e.to_string())).unwrap();
427 }
428 }
429 if let Err(e) = ws_client.close().await {
430 warn!(?e, "Failed to close WebSocket client");
431 }
432 });
433
434 Ok((handle, rx))
435 }
436}
437
438pub struct ProtocolSystemsInfo {
441 pub dci_protocols: HashSet<String>,
442 pub other_available: HashSet<String>,
443}
444
445impl ProtocolSystemsInfo {
446 pub async fn fetch(
449 rpc_client: &HttpRPCClient,
450 chain: Chain,
451 requested_exchanges: &HashSet<String>,
452 ) -> Self {
453 let page_size =
454 ProtocolSystemsRequestBody::effective_max_page_size(rpc_client.compression());
455 let params = ProtocolSystemsParams::new(chain).with_pagination(0, page_size);
456 let response = rpc_client
457 .get_protocol_systems(params)
458 .await
459 .map_err(|e| {
460 warn!(
461 "Failed to fetch protocol systems: {e}. Skipping protocol availability check."
462 );
463 e
464 })
465 .ok();
466
467 let Some(response) = response else {
468 return Self { dci_protocols: HashSet::new(), other_available: HashSet::new() };
469 };
470
471 if response.total() > page_size {
472 warn!(
473 "Server has {} protocol systems but only {} were fetched (page_size={page_size}). \
474 Availability info may be incomplete.",
475 response.total(),
476 response.data().protocol_systems().len(),
477 );
478 }
479
480 let available: HashSet<_> = response
481 .data()
482 .protocol_systems()
483 .iter()
484 .cloned()
485 .collect();
486 let other_available = available
487 .difference(requested_exchanges)
488 .cloned()
489 .collect();
490 let mut dci_protocols: HashSet<String> = response
491 .data()
492 .dci_protocols()
493 .iter()
494 .cloned()
495 .collect();
496
497 if dci_protocols.is_empty() {
502 const LEGACY_DCI: &[&str] = &[
503 "uniswap_v4_hooks",
504 "vm:curve",
505 "vm:balancer_v2",
506 "vm:balancer_v3",
507 "fluid_v1",
508 "erc4626",
509 ];
510 for name in requested_exchanges {
511 if LEGACY_DCI.contains(&name.as_str()) {
512 dci_protocols.insert(name.clone());
513 }
514 }
515 }
516
517 Self { dci_protocols, other_available }
518 }
519
520 pub fn log_other_available(&self) {
522 if !self.other_available.is_empty() {
523 let names: Vec<_> = self
524 .other_available
525 .iter()
526 .cloned()
527 .collect();
528 info!("Other available protocols: {}", names.join(", "));
529 }
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536
537 #[test]
538 fn test_validate_chain_config_errors_on_broken_file() {
539 std::env::set_var("TYCHO_CHAINS_CONFIG", "/nonexistent/does-not-exist.yaml");
541 let result = validate_chain_config();
542 std::env::remove_var("TYCHO_CHAINS_CONFIG");
543
544 let err = result.expect_err("a missing config file must fail validation");
545 assert!(matches!(err, StreamError::SetUpError(_)));
546 assert!(
547 err.to_string()
548 .contains("custom chain config"),
549 "error should name the custom chain config: {err}"
550 );
551 }
552
553 #[test]
554 fn test_validate_chain_config_ok_when_env_unset() {
555 std::env::remove_var("TYCHO_CHAINS_CONFIG");
556 assert!(
557 validate_chain_config().is_ok(),
558 "an unset env var means no custom chains, which is valid"
559 );
560 }
561
562 #[test]
563 fn test_retry_configuration_constant() {
564 let config = RetryConfiguration::constant(5, Duration::from_secs(10));
565 match config {
566 RetryConfiguration::Constant(c) => {
567 assert_eq!(c.max_attempts, 5);
568 assert_eq!(c.cooldown, Duration::from_secs(10));
569 }
570 }
571 }
572
573 #[test]
574 fn test_stream_builder_retry_configs() {
575 let mut builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum);
576 let ws_config = RetryConfiguration::constant(10, Duration::from_secs(2));
577 let state_config = RetryConfiguration::constant(20, Duration::from_secs(5));
578
579 builder = builder
580 .websockets_retry_config(&ws_config)
581 .state_synchronizer_retry_config(&state_config);
582
583 match (&builder.websockets_retry_config, &builder.state_sync_retry_config) {
585 (RetryConfiguration::Constant(ws), RetryConfiguration::Constant(state)) => {
586 assert_eq!(ws.max_attempts, 10);
587 assert_eq!(ws.cooldown, Duration::from_secs(2));
588 assert_eq!(state.max_attempts, 20);
589 assert_eq!(state.cooldown, Duration::from_secs(5));
590 }
591 }
592 }
593
594 #[test]
595 fn test_default_stream_builder() {
596 let builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum);
597 assert!(builder.compression, "Compression should be enabled by default.");
598 assert!(!builder.partial_blocks, "partial_blocks should be disabled by default.");
599 }
600
601 #[tokio::test]
602 async fn test_no_exchanges() {
603 let receiver = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum)
604 .auth_key(Some("my_api_key".into()))
605 .build()
606 .await;
607 assert!(receiver.is_err(), "Client should fail to build when no exchanges are registered.");
608 }
609
610 #[test]
611 fn test_add_client_metadata_accumulates() {
612 let builder = TychoStreamBuilder::new("localhost:4242", Chain::Ethereum)
613 .add_client_metadata([("fynd_version", "0.57.0")])
614 .add_client_metadata([("preset", "best")]);
615 assert_eq!(
616 builder
617 .client_metadata
618 .get("fynd_version")
619 .map(String::as_str),
620 Some("0.57.0")
621 );
622 assert_eq!(
623 builder
624 .client_metadata
625 .get("preset")
626 .map(String::as_str),
627 Some("best")
628 );
629 }
630
631 #[ignore = "require tycho gateway"]
632 #[tokio::test]
633 async fn test_simple_build() {
634 let token = env::var("TYCHO_AUTH_TOKEN").unwrap();
635 let receiver = TychoStreamBuilder::new("tycho-beta.propellerheads.xyz", Chain::Ethereum)
636 .exchange("uniswap_v2", ComponentFilter::with_tvl_range(100.0, 100.0))
637 .auth_key(Some(token))
638 .build()
639 .await;
640
641 dbg!(&receiver);
642
643 assert!(receiver.is_ok(), "Client should build successfully with exchanges registered.");
644 }
645}