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