Skip to main content

tycho_client/
cli.rs

1use std::{path::Path, str::FromStr, time::Duration};
2
3use clap::Parser;
4use tracing::info;
5use tracing_appender::rolling;
6use tycho_common::{dto::TvlThresholdTier, models::Chain};
7
8use crate::{
9    feed::{component_tracker::ComponentFilter, dto::FeedMessage as FeedMessageDto},
10    stream::TychoStreamBuilder,
11};
12
13/// Tycho Client CLI - A tool for indexing and tracking blockchain protocol data
14///
15/// This CLI tool connects to a Tycho server and tracks various blockchain protocols,
16/// providing real-time updates about their state.
17#[derive(Parser, Debug, Clone, PartialEq)]
18#[clap(version = env!("CARGO_PKG_VERSION"))]
19struct CliArgs {
20    /// Tycho server URL, without protocol. Example: localhost:4242
21    #[clap(long, default_value = "localhost:4242", env = "TYCHO_URL")]
22    tycho_url: String,
23
24    /// Tycho gateway API key, used as authentication for both websocket and http connections.
25    /// Can be set with TYCHO_AUTH_TOKEN env variable.
26    #[clap(short = 'k', long, env = "TYCHO_AUTH_TOKEN")]
27    auth_key: Option<String>,
28
29    /// If set, use unsecured transports: http and ws instead of https and wss.
30    #[clap(long)]
31    no_tls: bool,
32
33    /// The blockchain to index on
34    #[clap(short = 'c', long, default_value = "ethereum")]
35    pub chain: String,
36
37    /// Specifies exchanges. Optionally also supply a pool address in the format
38    /// {exchange}-{pool_address}
39    #[clap(short = 'e', long, number_of_values = 1)]
40    exchange: Vec<String>,
41
42    /// Specifies the minimum TVL to filter the components. Denoted in the native token.
43    /// Defaults to a chain-appropriate value targeting ~$20K USD equivalent.
44    /// Ignored if addresses or range tvl values are provided.
45    #[clap(long)]
46    min_tvl: Option<f64>,
47
48    /// Specifies the lower bound of the TVL threshold range. Denoted in the native token (e.g.
49    /// Mainnet -> ETH). Components below this TVL will be removed from tracking.
50    #[clap(long)]
51    remove_tvl_threshold: Option<f64>,
52
53    /// Specifies the upper bound of the TVL threshold range. Denoted in the native token (e.g.
54    /// Mainnet -> ETH). Components above this TVL will be added to tracking.
55    #[clap(long)]
56    add_tvl_threshold: Option<f64>,
57
58    /// Expected block time in seconds. Defaults to the canonical block time for the selected
59    /// chain (e.g. 12s for Ethereum, 2s for Base).
60    ///
61    /// Adjusting `block_time` helps balance efficiency and responsiveness:
62    /// - **Low values**: Increase sync frequency but may waste resources on retries.
63    /// - **High values**: Reduce sync frequency but may delay updates on faster chains.
64    #[clap(long)]
65    block_time: Option<u64>,
66
67    /// Maximum wait time in seconds beyond the block time. Useful for handling
68    /// chains with variable block intervals or network delays. Defaults to a chain-appropriate
69    /// value.
70    #[clap(long)]
71    timeout: Option<u64>,
72
73    /// Logging folder path.
74    #[clap(long, default_value = "logs")]
75    log_folder: String,
76
77    /// Run the example on a single block with UniswapV2 and UniswapV3.
78    #[clap(long)]
79    example: bool,
80
81    /// If set, only component and tokens are streamed, any snapshots or state updates
82    /// are omitted from the stream.
83    #[clap(long)]
84    no_state: bool,
85
86    /// Maximum amount of messages to process before exiting. Useful for debugging e.g.
87    /// to easily get a state sync messages for a fixture. Alternatively this may be
88    /// used to trigger a regular restart or resync.
89    #[clap(short='n', long, default_value=None)]
90    max_messages: Option<usize>,
91
92    /// Maximum blocks an exchange can be absent for before it is marked as stale. Used
93    /// in conjunction with block_time to calculate a timeout: block_time * max_missed_blocks.
94    /// Defaults to a chain-appropriate value.
95    #[clap(long)]
96    max_missed_blocks: Option<u64>,
97
98    /// If set, the synchronizer will include TVL in the messages.
99    /// Enabling this option will increase the number of network requests made during start-up,
100    /// which may result in increased start-up latency.
101    #[clap(long)]
102    include_tvl: bool,
103
104    /// If set, disable compression for WebSocket messages.
105    /// By default, messages are compressed using zstd.
106    #[clap(long)]
107    disable_compression: bool,
108
109    /// If set, enables receiving partial block updates (flashblocks).
110    /// This allows the client to receive incremental updates within a block, allowing for
111    /// lower latency.
112    #[clap(long)]
113    partial_blocks: bool,
114
115    /// Enable verbose logging. This will show more detailed information about the
116    /// synchronization process and any errors that occur.
117    #[clap(long)]
118    verbose: bool,
119
120    /// Maximum number of retry attempts for failed startups
121    #[clap(long, default_value = "32")]
122    max_retries: u64,
123
124    /// Maximum random delay in seconds before a restarted synchronizer requests a snapshot.
125    /// Spreads snapshot requests after a server or extractor restart. The first start has no
126    /// delay. The minimum is 5.
127    #[clap(long, default_value = "60", value_parser = clap::value_parser!(u64).range(5..))]
128    snapshot_jitter: u64,
129
130    /// Path to a TOML file containing component IDs to exclude from tracking.
131    #[clap(long)]
132    blocklist_config: Option<std::path::PathBuf>,
133}
134
135impl CliArgs {
136    fn validate(&self) -> Result<(), String> {
137        // TVL thresholds must be set together - either both or neither
138        match (self.remove_tvl_threshold, self.add_tvl_threshold) {
139            (Some(remove), Some(add)) if remove >= add => {
140                return Err("remove_tvl_threshold must be less than add_tvl_threshold".to_string());
141            }
142            (Some(_), None) | (None, Some(_)) => {
143                return Err(
144                    "Both remove_tvl_threshold and add_tvl_threshold must be set.".to_string()
145                );
146            }
147            _ => {}
148        }
149
150        Ok(())
151    }
152}
153
154#[derive(serde::Deserialize)]
155struct BlocklistFile {
156    ids: Vec<String>,
157}
158
159fn load_blocklist(path: &Path) -> Result<Vec<String>, String> {
160    let content = std::fs::read_to_string(path)
161        .map_err(|e| format!("Failed to read blocklist file {}: {e}", path.display()))?;
162    let file: BlocklistFile = toml::from_str(&content)
163        .map_err(|e| format!("Failed to parse blocklist file {}: {e}", path.display()))?;
164    Ok(file.ids)
165}
166
167pub async fn run_cli() -> Result<(), String> {
168    // Parse CLI Args
169    let args: CliArgs = CliArgs::parse();
170    args.validate()?;
171
172    // Setup Logging
173    let log_level = if args.verbose { "debug" } else { "info" };
174    let (non_blocking, _guard) =
175        tracing_appender::non_blocking(rolling::never(&args.log_folder, "dev_logs.log"));
176    let subscriber = tracing_subscriber::fmt()
177        .with_env_filter(
178            tracing_subscriber::EnvFilter::try_from_default_env()
179                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(log_level)),
180        )
181        .with_writer(non_blocking)
182        .finish();
183
184    tracing::subscriber::set_global_default(subscriber)
185        .map_err(|e| format!("Failed to set up logging subscriber: {e}"))?;
186
187    // Build the list of exchanges.  When --example is provided, we seed the list with a fixed
188    // pair of well-known pools, otherwise we parse user supplied values (either plain exchange
189    // names or exchange-pool pairs in the {exchange}-{pool_address} format).
190    let exchanges: Vec<(String, Option<String>)> = if args.example {
191        // You will need to port-forward tycho to run the example:
192        //
193        // ```bash
194        // kubectl port-forward -n dev-tycho deploy/tycho-indexer 8888:4242
195        // ```
196        vec![
197            (
198                "uniswap_v3".to_string(),
199                Some("0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640".to_string()),
200            ),
201            (
202                "uniswap_v2".to_string(),
203                Some("0xa478c2975ab1ea89e8196811f51a7b7ade33eb11".to_string()),
204            ),
205        ]
206    } else {
207        args.exchange
208            .iter()
209            .filter_map(|e| {
210                if e.contains('-') {
211                    let parts: Vec<&str> = e.split('-').collect();
212                    if parts.len() == 2 {
213                        Some((parts[0].to_string(), Some(parts[1].to_string())))
214                    } else {
215                        tracing::warn!("Ignoring invalid exchange format: {}", e);
216                        None
217                    }
218                } else {
219                    Some((e.to_string(), None))
220                }
221            })
222            .collect()
223    };
224
225    info!("Running with exchanges: {:?}", exchanges);
226
227    run(exchanges, args).await?;
228    Ok(())
229}
230
231async fn run(exchanges: Vec<(String, Option<String>)>, args: CliArgs) -> Result<(), String> {
232    let blocklist = match &args.blocklist_config {
233        Some(path) => load_blocklist(path)?,
234        None => Vec::new(),
235    };
236
237    let chain = Chain::from_str(&args.chain)
238        .map_err(|_| format!("Unknown chain: {chain}", chain = args.chain))?;
239
240    let mut builder = TychoStreamBuilder::new(&args.tycho_url, chain)
241        .auth_key(args.auth_key)
242        .no_tls(args.no_tls)
243        .no_state(args.no_state)
244        .include_tvl(args.include_tvl)
245        .max_retries(args.max_retries)
246        .snapshot_jitter(Duration::from_secs(args.snapshot_jitter))
247        .blocklisted_ids(blocklist);
248
249    if let Some(bt) = args.block_time {
250        builder = builder.block_time(bt);
251    }
252    if let Some(to) = args.timeout {
253        builder = builder.timeout(to);
254    }
255    if let Some(mmb) = args.max_missed_blocks {
256        builder = builder.max_missed_blocks(mmb);
257    }
258    if args.disable_compression {
259        builder = builder.disable_compression();
260    }
261    if args.partial_blocks {
262        builder = builder.enable_partial_blocks();
263    }
264    if let Some(n) = args.max_messages {
265        builder = builder.max_messages(n);
266    }
267    // Register exchanges
268    let builder = exchanges
269        .into_iter()
270        .fold(builder, |b, (name, address)| {
271            let filter = if let Some(addr) = address {
272                ComponentFilter::Ids(vec![addr])
273            } else if let (Some(remove_tvl), Some(add_tvl)) =
274                (args.remove_tvl_threshold, args.add_tvl_threshold)
275            {
276                ComponentFilter::with_tvl_range(remove_tvl, add_tvl)
277            } else {
278                let default_min_tvl = chain.default_tvl_threshold(TvlThresholdTier::Low);
279                let min_tvl = args.min_tvl.unwrap_or(default_min_tvl);
280                ComponentFilter::with_tvl_range(min_tvl, min_tvl)
281            };
282            b.exchange(&name, filter)
283        });
284
285    let (handle, mut rx) = builder
286        .build()
287        .await
288        .map_err(|e| e.to_string())?;
289
290    let msg_printer = tokio::spawn(async move {
291        while let Some(result) = rx.recv().await {
292            let msg =
293                result.map_err(|e| format!("Message printer received synchronizer error: {e}"))?;
294
295            let json = serde_json::to_string(&FeedMessageDto::from(msg))
296                .map_err(|e| format!("Message printer failed to serialize message: {e}"))?;
297            println!("{json}");
298        }
299
300        Ok::<(), String>(())
301    });
302
303    // Monitor the stream handle and message printer futures.
304    let (failed_task, shutdown_reason) = tokio::select! {
305        res = handle => (
306            "Stream",
307            res.err().map(|e| e.to_string())
308        ),
309        res = msg_printer => (
310            "MessagePrinter",
311            extract_nested_error(res)
312        ),
313    };
314
315    Err(format!(
316        "{failed_task} task terminated: {}",
317        shutdown_reason.unwrap_or("unknown reason".to_string())
318    ))
319}
320
321#[inline]
322fn extract_nested_error<T, E1: ToString, E2: ToString>(
323    res: Result<Result<T, E1>, E2>,
324) -> Option<String> {
325    res.map_err(|e| e.to_string())
326        .and_then(|r| r.map_err(|e| e.to_string()))
327        .err()
328}
329
330#[cfg(test)]
331mod cli_tests {
332    use clap::Parser;
333
334    use super::CliArgs;
335
336    #[tokio::test]
337    async fn test_cli_args() {
338        let args = CliArgs::parse_from([
339            "tycho-client",
340            "--tycho-url",
341            "localhost:5000",
342            "--exchange",
343            "uniswap_v2",
344            "--min-tvl",
345            "3000",
346            "--block-time",
347            "50",
348            "--timeout",
349            "5",
350            "--log-folder",
351            "test_logs",
352            "--example",
353            "--max-messages",
354            "1",
355            "--blocklist-config",
356            "blocklist.toml",
357        ]);
358        let exchanges: Vec<String> = vec!["uniswap_v2".to_string()];
359        assert_eq!(args.tycho_url, "localhost:5000");
360        assert_eq!(args.exchange, exchanges);
361        assert_eq!(args.min_tvl, Some(3000.0));
362        assert_eq!(args.block_time, Some(50));
363        assert_eq!(args.timeout, Some(5));
364        assert_eq!(args.log_folder, "test_logs");
365        assert_eq!(args.max_messages, Some(1));
366        assert!(args.example);
367        assert_eq!(args.disable_compression, false);
368        assert_eq!(args.partial_blocks, false);
369        assert_eq!(args.snapshot_jitter, 60);
370        assert_eq!(args.blocklist_config, Some(std::path::PathBuf::from("blocklist.toml")));
371
372        let args = CliArgs::parse_from([
373            "tycho-client",
374            "--exchange",
375            "uniswap_v2",
376            "--snapshot-jitter",
377            "5",
378        ]);
379        assert_eq!(args.snapshot_jitter, 5);
380
381        let res = CliArgs::try_parse_from([
382            "tycho-client",
383            "--exchange",
384            "uniswap_v2",
385            "--snapshot-jitter",
386            "4",
387        ]);
388        assert!(res.is_err(), "A snapshot jitter below 5 should be rejected");
389    }
390}