1use crate::cli::address::{self, AddressArgs, AddressReport};
6use crate::cli::address_report;
7use crate::cli::output::Output;
8use crate::note;
9use clap::{Args, Subcommand};
10use scope::chains::{ChainClientFactory, infer_chain_from_address};
11use scope::config::Config;
12use scope::error::{Result, ScopeError};
13
14#[derive(Debug, Subcommand)]
16pub enum ReportCommands {
17 Batch(BatchArgs),
22}
23
24#[derive(Debug, Args)]
25#[command(after_help = "\x1b[1mExamples:\x1b[0m
26 scope report batch --addresses 0x742d...,0xabc1... --output report.md
27 scope report batch --from-file addresses.txt --output report.md --with-risk
28 scope report batch --addresses 0x742d... --chain polygon --output report.md")]
29pub struct BatchArgs {
30 #[arg(long, value_delimiter = ',', value_name = "ADDRESS")]
32 pub addresses: Vec<String>,
33
34 #[arg(long, value_name = "PATH")]
36 pub from_file: Option<std::path::PathBuf>,
37
38 #[arg(short, long, required = true, value_name = "PATH")]
40 pub output: std::path::PathBuf,
41
42 #[arg(short, long, default_value = scope::chains::DEFAULT_CHAIN)]
44 pub chain: String,
45
46 #[arg(long, default_value_t = false)]
48 pub with_risk: bool,
49}
50
51pub async fn run(
53 args: ReportCommands,
54 config: &Config,
55 clients: &dyn ChainClientFactory,
56 out: &Output,
57) -> Result<()> {
58 match args {
59 ReportCommands::Batch(batch_args) => run_batch(batch_args, config, clients, out).await,
60 }
61}
62
63async fn run_batch(
64 args: BatchArgs,
65 config: &Config,
66 clients: &dyn ChainClientFactory,
67 out: &Output,
68) -> Result<()> {
69 let targets = resolve_targets(&args)?;
70 if targets.is_empty() {
71 return Err(ScopeError::Export(
72 "No addresses to analyze. Use --addresses or --from-file.".to_string(),
73 ));
74 }
75
76 let prog = crate::cli::progress::StepProgress::new(
77 targets.len() as u64,
78 &format!(
79 "Batch report{}",
80 if args.with_risk { " (with risk)" } else { "" }
81 ),
82 out,
83 );
84 let mut reports = Vec::new();
85 let mut risk_assessments: Vec<Option<scope::compliance::risk::RiskAssessment>> = Vec::new();
86
87 let engine = scope::compliance::risk::RiskEngine::from_env();
88
89 for (address, chain) in &targets {
90 let short_addr = scope::display::text::abbreviate(address);
91 prog.inc(&short_addr);
92
93 let addr_args = AddressArgs {
94 address: address.clone(),
95 chain: chain.clone(),
96 format: Some(config.output.format),
97 include_txs: true,
98 include_tokens: true,
99 limit: 50,
100 report: None,
101 dossier: false,
102 };
103
104 let client = clients.create_chain_client(chain)?;
105 match address::analyze_address(&addr_args, client.as_ref(), out).await {
106 Ok(report) => {
107 let risk = if args.with_risk {
108 engine.assess_address(address, chain).await.ok()
109 } else {
110 None
111 };
112 reports.push(report);
113 risk_assessments.push(risk);
114 }
115 Err(e) => {
116 note!(out, "Warning: Failed to analyze {}: {}", address, e);
117 }
118 }
119 }
120
121 prog.finish("All addresses analyzed.");
122 let md = batch_report_to_markdown(&reports, &risk_assessments, args.with_risk);
123 std::fs::write(&args.output, &md)?;
124 out.note_saved("Batch report", args.output.display());
125 Ok(())
126}
127
128fn resolve_targets(args: &BatchArgs) -> Result<Vec<(String, String)>> {
129 let mut targets = Vec::new();
130
131 for addr in &args.addresses {
132 let chain = if args.chain == "ethereum" {
133 infer_chain_from_address(addr)
134 .map(String::from)
135 .unwrap_or_else(|| args.chain.clone())
136 } else {
137 args.chain.clone()
138 };
139 targets.push((addr.clone(), chain));
140 }
141
142 if let Some(ref path) = args.from_file {
143 if !path.exists() {
144 return Err(ScopeError::IoError(std::io::Error::new(
145 std::io::ErrorKind::NotFound,
146 format!("File not found: {}", path.display()),
147 )));
148 }
149 let content = std::fs::read_to_string(path)?;
150 for line in content.lines() {
151 let line = line.trim();
152 if line.is_empty() || line.starts_with('#') {
153 continue;
154 }
155 let (addr, chain) = if let Some((a, c)) = line.split_once(',') {
156 (a.trim().to_string(), c.trim().to_string())
157 } else {
158 (
159 line.to_string(),
160 infer_chain_from_address(line)
161 .map(String::from)
162 .unwrap_or_else(|| args.chain.clone()),
163 )
164 };
165 if !addr.is_empty() {
166 targets.push((addr, chain));
167 }
168 }
169 }
170
171 Ok(targets)
172}
173
174fn batch_report_to_markdown(
175 reports: &[AddressReport],
176 risks: &[Option<scope::compliance::risk::RiskAssessment>],
177 with_risk: bool,
178) -> String {
179 let mut md = format!(
180 "# Batch Address Report{}\n\n\
181 **Generated:** {} \n\
182 **Addresses:** {} \n\n",
183 if with_risk {
184 " (with Risk Assessment)"
185 } else {
186 ""
187 },
188 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
189 reports.len()
190 );
191
192 for (i, report) in reports.iter().enumerate() {
193 md.push_str(&format!(
194 "---\n\n## Address {}: `{}`\n\n",
195 i + 1,
196 report.address
197 ));
198 md.push_str(&address_report::generate_address_report_section(report));
199
200 if with_risk {
201 if let Some(risk) = risks.get(i).and_then(|r| r.as_ref()) {
202 md.push_str("\n### Risk Assessment\n\n");
203 md.push_str(&scope::display::format_risk_report(
204 risk,
205 scope::display::OutputFormat::Markdown,
206 false,
207 ));
208 } else {
209 md.push_str("\n### Risk Assessment\n\n*Risk assessment unavailable for this address/chain.*\n");
210 }
211 }
212 md.push('\n');
213 }
214
215 md.push_str(&scope::display::report::report_footer());
216 md
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use crate::cli::address::{AddressReport, Balance, TokenBalance, TransactionSummary};
223 use tempfile::NamedTempFile;
224
225 fn quiet() -> crate::cli::output::Output {
226 crate::cli::output::Output::capture().0
227 }
228
229 #[test]
230 fn test_resolve_targets_addresses_only() {
231 let args = BatchArgs {
232 addresses: vec!["0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()],
233 from_file: None,
234 output: std::path::PathBuf::from("/tmp/out.md"),
235 chain: "ethereum".to_string(),
236 with_risk: false,
237 };
238 let targets = resolve_targets(&args).unwrap();
239 assert_eq!(targets.len(), 1);
240 assert_eq!(targets[0].0, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
241 assert_eq!(targets[0].1, "ethereum");
242 }
243
244 #[test]
245 fn test_resolve_targets_multiple_addresses() {
246 let args = BatchArgs {
247 addresses: vec![
248 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
249 "0x0000000000000000000000000000000000000001".to_string(),
250 ],
251 from_file: None,
252 output: std::path::PathBuf::from("/tmp/out.md"),
253 chain: "ethereum".to_string(),
254 with_risk: false,
255 };
256 let targets = resolve_targets(&args).unwrap();
257 assert_eq!(targets.len(), 2);
258 }
259
260 #[test]
261 fn test_resolve_targets_from_file() {
262 let file = NamedTempFile::new().unwrap();
263 std::fs::write(
264 file.path(),
265 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2\n# comment\n\n0x0000000000000000000000000000000000000001",
266 )
267 .unwrap();
268
269 let args = BatchArgs {
270 addresses: vec![],
271 from_file: Some(file.path().to_path_buf()),
272 output: std::path::PathBuf::from("/tmp/out.md"),
273 chain: "ethereum".to_string(),
274 with_risk: false,
275 };
276 let targets = resolve_targets(&args).unwrap();
277 assert_eq!(targets.len(), 2);
278 }
279
280 #[test]
281 fn test_resolve_targets_from_file_with_chain_override() {
282 let file = NamedTempFile::new().unwrap();
283 std::fs::write(
284 file.path(),
285 "0x1234567890123456789012345678901234567890,polygon\n",
286 )
287 .unwrap();
288
289 let args = BatchArgs {
290 addresses: vec![],
291 from_file: Some(file.path().to_path_buf()),
292 output: std::path::PathBuf::from("/tmp/out.md"),
293 chain: "ethereum".to_string(),
294 with_risk: false,
295 };
296 let targets = resolve_targets(&args).unwrap();
297 assert_eq!(targets.len(), 1);
298 assert_eq!(targets[0].1, "polygon");
299 }
300
301 #[test]
302 fn test_resolve_targets_file_not_found() {
303 let args = BatchArgs {
304 addresses: vec![],
305 from_file: Some(std::path::PathBuf::from("/nonexistent/path/12345")),
306 output: std::path::PathBuf::from("/tmp/out.md"),
307 chain: "ethereum".to_string(),
308 with_risk: false,
309 };
310 let result = resolve_targets(&args);
311 assert!(result.is_err());
312 }
313
314 fn minimal_report() -> AddressReport {
315 AddressReport {
316 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
317 chain: "ethereum".to_string(),
318 balance: Balance {
319 raw: "1000000000000000000".to_string(),
320 formatted: "1.0 ETH".to_string(),
321 usd: Some(3500.0),
322 },
323 transaction_count: 42,
324 transactions: None,
325 tokens: None,
326 }
327 }
328
329 #[test]
330 fn test_batch_report_to_markdown_single_report() {
331 let reports = vec![minimal_report()];
332 let risks = vec![None];
333 let md = batch_report_to_markdown(&reports, &risks, false);
334 assert!(md.contains("Batch Address Report"));
335 assert!(md.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
336 assert!(md.contains("Balance Summary"));
337 assert!(md.contains("1.0 ETH"));
338 assert!(!md.contains("Risk Assessment"));
339 }
340
341 #[test]
342 fn test_batch_report_to_markdown_with_risk_placeholder() {
343 let reports = vec![minimal_report()];
344 let risks = vec![None];
345 let md = batch_report_to_markdown(&reports, &risks, true);
346 assert!(md.contains("Risk Assessment"));
347 assert!(md.contains("unavailable"));
348 }
349
350 #[test]
351 fn test_batch_report_to_markdown_with_transactions_and_tokens() {
352 let mut report = minimal_report();
353 report.transactions = Some(vec![TransactionSummary {
354 hash: "0xabc123".to_string(),
355 block_number: 12345,
356 timestamp: 1700000000,
357 from: "0xfrom".to_string(),
358 to: Some("0xto".to_string()),
359 value: "1 ETH".to_string(),
360 status: true,
361 }]);
362 report.tokens = Some(vec![TokenBalance {
363 contract_address: "0xusdc".to_string(),
364 symbol: "USDC".to_string(),
365 name: "USD Coin".to_string(),
366 decimals: 6,
367 balance: "1000000".to_string(),
368 formatted_balance: "1.0 USDC".to_string(),
369 }]);
370
371 let reports = vec![report];
372 let risks = vec![None];
373 let md = batch_report_to_markdown(&reports, &risks, false);
374 assert!(md.contains("Recent Transactions"));
375 assert!(md.contains("Token Balances"));
376 assert!(md.contains("USDC"));
377 }
378
379 #[test]
380 fn test_batch_args_debug() {
381 let args = BatchArgs {
382 addresses: vec!["0x123".to_string(), "0x456".to_string()],
383 from_file: None,
384 output: std::path::PathBuf::from("/tmp/report.md"),
385 chain: "ethereum".to_string(),
386 with_risk: false,
387 };
388 let debug = format!("{:?}", args);
389 assert!(debug.contains("BatchArgs"));
390 assert!(debug.contains("0x123"));
391 }
392
393 #[test]
394 fn test_batch_args_with_risk() {
395 let args = BatchArgs {
396 addresses: vec![],
397 from_file: Some(std::path::PathBuf::from("addrs.txt")),
398 output: std::path::PathBuf::from("/tmp/report.md"),
399 chain: "polygon".to_string(),
400 with_risk: true,
401 };
402 assert!(args.with_risk);
403 assert_eq!(args.chain, "polygon");
404 assert!(args.from_file.is_some());
405 }
406
407 #[test]
408 fn test_report_commands_debug() {
409 let cmd = ReportCommands::Batch(BatchArgs {
410 addresses: vec!["0xabc".to_string()],
411 from_file: None,
412 output: std::path::PathBuf::from("out.md"),
413 chain: "ethereum".to_string(),
414 with_risk: false,
415 });
416 let debug = format!("{:?}", cmd);
417 assert!(debug.contains("Batch"));
418 }
419
420 #[test]
421 fn test_batch_report_to_markdown_with_risk_data() {
422 use scope::compliance::risk::{RiskAssessment, RiskFactor, RiskLevel};
423
424 let reports = vec![minimal_report()];
425 let risk = RiskAssessment {
426 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
427 chain: "ethereum".to_string(),
428 overall_score: 3.5,
429 risk_level: RiskLevel::Low,
430 factors: vec![RiskFactor {
431 name: "Address Age".to_string(),
432 category: scope::compliance::risk::RiskCategory::Behavioral,
433 score: 2.0,
434 weight: 1.0,
435 description: "Address is well-established".to_string(),
436 evidence: vec!["Known address".to_string()],
437 }],
438 recommendations: vec!["Continue monitoring".to_string()],
439 not_assessed: Vec::new(),
440 assessed_at: chrono::Utc::now(),
441 };
442 let risks = vec![Some(risk)];
443 let md = batch_report_to_markdown(&reports, &risks, true);
444 assert!(md.contains("Risk Assessment"));
445 assert!(md.contains("with Risk Assessment"));
446 assert!(!md.contains("unavailable"));
448 }
449
450 #[test]
451 fn test_batch_report_to_markdown_multiple_reports() {
452 let mut report1 = minimal_report();
453 report1.address = "0xaaa".to_string();
454 let mut report2 = minimal_report();
455 report2.address = "0xbbb".to_string();
456 report2.chain = "polygon".to_string();
457
458 let reports = vec![report1, report2];
459 let risks = vec![None, None];
460 let md = batch_report_to_markdown(&reports, &risks, false);
461 assert!(md.contains("0xaaa"));
462 assert!(md.contains("0xbbb"));
463 assert!(md.contains("Address 1"));
464 assert!(md.contains("Address 2"));
465 }
466
467 #[test]
468 fn test_resolve_targets_non_default_chain() {
469 let args = BatchArgs {
470 addresses: vec!["0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()],
471 from_file: None,
472 output: std::path::PathBuf::from("/tmp/out.md"),
473 chain: "polygon".to_string(),
474 with_risk: false,
475 };
476 let targets = resolve_targets(&args).unwrap();
477 assert_eq!(targets.len(), 1);
478 assert_eq!(targets[0].1, "polygon");
480 }
481
482 #[test]
483 fn test_resolve_targets_empty() {
484 let args = BatchArgs {
485 addresses: vec![],
486 from_file: None,
487 output: std::path::PathBuf::from("/tmp/out.md"),
488 chain: "ethereum".to_string(),
489 with_risk: false,
490 };
491 let targets = resolve_targets(&args).unwrap();
492 assert_eq!(targets.len(), 0);
493 }
494
495 #[tokio::test]
496 async fn test_run_batch_with_mock_factory() {
497 use scope::chains::mocks::MockClientFactory;
498
499 let temp_dir = tempfile::tempdir().unwrap();
500 let output_path = temp_dir.path().join("batch_report.md");
501
502 let args = BatchArgs {
503 addresses: vec!["0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()],
504 from_file: None,
505 output: output_path.clone(),
506 chain: "ethereum".to_string(),
507 with_risk: false,
508 };
509
510 let config = Config::default();
511 let factory = MockClientFactory::new();
512
513 let result = run_batch(args, &config, &factory, &quiet()).await;
514 assert!(result.is_ok());
515
516 assert!(output_path.exists());
518 let content = std::fs::read_to_string(&output_path).unwrap();
519 assert!(content.contains("Batch Address Report"));
520 assert!(content.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
521 }
522
523 #[tokio::test]
524 async fn test_run_batch_with_risk() {
525 use scope::chains::mocks::MockClientFactory;
526
527 let temp_dir = tempfile::tempdir().unwrap();
528 let output_path = temp_dir.path().join("batch_risk_report.md");
529
530 let args = BatchArgs {
531 addresses: vec!["0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()],
532 from_file: None,
533 output: output_path.clone(),
534 chain: "ethereum".to_string(),
535 with_risk: true,
536 };
537
538 let config = Config::default();
539 let factory = MockClientFactory::new();
540
541 let result = run_batch(args, &config, &factory, &quiet()).await;
542 assert!(result.is_ok());
543 let content = std::fs::read_to_string(&output_path).unwrap();
544 assert!(content.contains("Risk Assessment"));
545 }
546
547 #[tokio::test]
548 async fn test_run_batch_empty_targets() {
549 use scope::chains::mocks::MockClientFactory;
550
551 let temp_dir = tempfile::tempdir().unwrap();
552 let output_path = temp_dir.path().join("empty.md");
553
554 let args = BatchArgs {
555 addresses: vec![],
556 from_file: None,
557 output: output_path,
558 chain: "ethereum".to_string(),
559 with_risk: false,
560 };
561
562 let config = Config::default();
563 let factory = MockClientFactory::new();
564
565 let result = run_batch(args, &config, &factory, &quiet()).await;
566 assert!(result.is_err());
567 let err_msg = result.unwrap_err().to_string();
568 assert!(err_msg.contains("No addresses"));
569 }
570
571 #[tokio::test]
572 async fn test_run_dispatch() {
573 use scope::chains::mocks::MockClientFactory;
574
575 let temp_dir = tempfile::tempdir().unwrap();
576 let output_path = temp_dir.path().join("dispatch_report.md");
577
578 let args = ReportCommands::Batch(BatchArgs {
579 addresses: vec!["0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string()],
580 from_file: None,
581 output: output_path.clone(),
582 chain: "ethereum".to_string(),
583 with_risk: false,
584 });
585
586 let config = Config::default();
587 let factory = MockClientFactory::new();
588
589 let result = run(args, &config, &factory, &quiet()).await;
590 assert!(result.is_ok());
591 }
592}