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#[derive(Parser, Debug, Clone, PartialEq)]
18#[clap(version = env!("CARGO_PKG_VERSION"))]
19struct CliArgs {
20 #[clap(long, default_value = "localhost:4242", env = "TYCHO_URL")]
22 tycho_url: String,
23
24 #[clap(short = 'k', long, env = "TYCHO_AUTH_TOKEN")]
27 auth_key: Option<String>,
28
29 #[clap(long)]
31 no_tls: bool,
32
33 #[clap(short = 'c', long, default_value = "ethereum")]
35 pub chain: String,
36
37 #[clap(short = 'e', long, number_of_values = 1)]
40 exchange: Vec<String>,
41
42 #[clap(long)]
46 min_tvl: Option<f64>,
47
48 #[clap(long)]
51 remove_tvl_threshold: Option<f64>,
52
53 #[clap(long)]
56 add_tvl_threshold: Option<f64>,
57
58 #[clap(long)]
65 block_time: Option<u64>,
66
67 #[clap(long)]
71 timeout: Option<u64>,
72
73 #[clap(long, default_value = "logs")]
75 log_folder: String,
76
77 #[clap(long)]
79 example: bool,
80
81 #[clap(long)]
84 no_state: bool,
85
86 #[clap(short='n', long, default_value=None)]
90 max_messages: Option<usize>,
91
92 #[clap(long)]
96 max_missed_blocks: Option<u64>,
97
98 #[clap(long)]
102 include_tvl: bool,
103
104 #[clap(long)]
107 disable_compression: bool,
108
109 #[clap(long)]
113 partial_blocks: bool,
114
115 #[clap(long)]
118 verbose: bool,
119
120 #[clap(long, default_value = "32")]
122 max_retries: u64,
123
124 #[clap(long, default_value = "60", value_parser = clap::value_parser!(u64).range(5..))]
128 snapshot_jitter: u64,
129
130 #[clap(long)]
132 blocklist_config: Option<std::path::PathBuf>,
133}
134
135impl CliArgs {
136 fn validate(&self) -> Result<(), String> {
137 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 let args: CliArgs = CliArgs::parse();
170 args.validate()?;
171
172 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 let exchanges: Vec<(String, Option<String>)> = if args.example {
191 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 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 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}