Skip to main content

tycho_client/
cli.rs

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