1use crate::cli::output::Output;
4use crate::{note, outln};
5use clap::{Args, Subcommand};
6use scope::compliance::datasource::{BlockchainDataClient, analyze_patterns};
7use scope::compliance::etherscan::ETHERSCAN_KEY_ENV;
8use scope::compliance::risk::RiskEngine;
9use scope::display::{OutputFormat, format_risk_report};
10
11#[derive(Debug, Subcommand)]
12pub enum ComplianceCommands {
13 #[command(name = "risk")]
15 Risk(RiskArgs),
16
17 #[command(name = "trace")]
19 Trace(TraceArgs),
20
21 #[command(name = "analyze")]
23 Analyze(AnalyzeArgs),
24
25 #[command(name = "compliance-report")]
27 ComplianceReport(ComplianceReportArgs),
28}
29
30#[derive(Debug, Args)]
31#[command(after_help = "\x1b[1mExamples:\x1b[0m
32 scope compliance risk 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2
33 scope compliance risk @main-wallet \x1b[2m# address book shortcut\x1b[0m
34 scope compliance risk 0x742d... --detailed --format json
35 scope compliance risk 0x742d... --output risk_report.json")]
36pub struct RiskArgs {
37 #[arg(value_name = "ADDRESS")]
39 pub address: String,
40
41 #[arg(short, long)]
43 pub chain: Option<String>,
44
45 #[arg(short, long, value_enum, default_value = "table")]
47 pub format: OutputFormat,
48
49 #[arg(long)]
51 pub detailed: bool,
52
53 #[arg(short, long)]
55 pub output: Option<String>,
56}
57
58#[derive(Debug, Args)]
59#[command(after_help = "\x1b[1mExamples:\x1b[0m
60 scope compliance trace 0xabc123def456...
61 scope compliance trace 0xabc123... --depth 5 --flag-suspicious
62 scope compliance trace 0xabc123... --format json")]
63pub struct TraceArgs {
64 #[arg(value_name = "TX_HASH")]
66 pub tx_hash: String,
67
68 #[arg(short, long, default_value = "3")]
70 pub depth: u32,
71
72 #[arg(long)]
74 pub flag_suspicious: bool,
75
76 #[arg(short, long, value_enum, default_value = "table")]
78 pub format: OutputFormat,
79}
80
81#[derive(Debug, Args)]
82#[command(after_help = "\x1b[1mExamples:\x1b[0m
83 scope compliance analyze 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2
84 scope compliance analyze 0x742d... --patterns structuring,layering --range 90d
85 scope compliance analyze 0x742d... --format json")]
86pub struct AnalyzeArgs {
87 #[arg(value_name = "ADDRESS")]
89 pub address: String,
90
91 #[arg(long, value_enum, default_values = &["structuring", "layering", "integration"])]
93 pub patterns: Vec<PatternType>,
94
95 #[arg(short, long, default_value = "30d")]
97 pub range: String,
98
99 #[arg(short, long, value_enum, default_value = "table")]
101 pub format: OutputFormat,
102}
103
104#[derive(Debug, Args)]
105#[command(after_help = "\x1b[1mExamples:\x1b[0m
106 scope compliance compliance-report 0x742d... -j us -o report.json
107 scope compliance compliance-report addresses.txt -j eu --report-type detailed -o report.json")]
108pub struct ComplianceReportArgs {
109 #[arg(value_name = "TARGET")]
111 pub target: String,
112
113 #[arg(short, long, value_enum)]
115 pub jurisdiction: Jurisdiction,
116
117 #[arg(short, long, value_enum, default_value = "summary")]
119 pub report_type: ReportType,
120
121 #[arg(short, long, required = true)]
123 pub output: String,
124}
125
126#[derive(Clone, Copy, Debug, clap::ValueEnum)]
127pub enum PatternType {
128 Structuring,
129 Layering,
130 Integration,
131 Velocity,
132 RoundNumbers,
133}
134
135#[derive(Clone, Copy, Debug, clap::ValueEnum)]
136pub enum Jurisdiction {
137 US,
138 EU,
139 UK,
140 Switzerland,
141 Singapore,
142}
143
144#[derive(Clone, Copy, Debug, clap::ValueEnum)]
145pub enum ReportType {
146 Summary,
147 Detailed,
148 SAR, TravelRule,
150}
151
152pub async fn handle_risk(args: RiskArgs, out: &Output) -> anyhow::Result<()> {
154 handle_risk_with_client(args, None, out).await
155}
156
157fn etherscan_client(given: Option<BlockchainDataClient>) -> Option<BlockchainDataClient> {
159 given.or_else(BlockchainDataClient::from_env_opt)
160}
161
162fn risk_engine(
165 client: Option<&BlockchainDataClient>,
166 spinner: Option<&crate::cli::progress::Spinner>,
167 out: &Output,
168) -> RiskEngine {
169 let line = match client {
170 Some(_) => "Using Etherscan API for enhanced analysis".to_string(),
171 None => format!(
172 "Note: no Etherscan API key, so most risk factors are not assessed. \
173 Add one with `scope setup --key etherscan` or {ETHERSCAN_KEY_ENV}."
174 ),
175 };
176 crate::cli::progress::notice(spinner, out, &line);
177 RiskEngine::with_optional_client(client.cloned())
178}
179
180pub async fn handle_risk_with_client(
182 args: RiskArgs,
183 client: Option<BlockchainDataClient>,
184 out: &Output,
185) -> anyhow::Result<()> {
186 let chain = match args.chain {
188 Some(c) => c,
189 None => detect_chain(&args.address)?,
190 };
191
192 let sp = crate::cli::progress::Spinner::new(
193 &format!("Assessing risk for {} on {}...", args.address, chain),
194 out,
195 );
196
197 let engine = risk_engine(etherscan_client(client).as_ref(), Some(&sp), out);
198
199 let assessment = engine.assess_address(&args.address, &chain).await?;
200 sp.finish("Risk assessment complete.");
201
202 let output = format_risk_report(&assessment, args.format, args.detailed);
204 outln!(out, "{}", output)?;
205
206 if let Some(path) = args.output {
208 let content = match std::path::Path::new(&path)
209 .extension()
210 .and_then(|e| e.to_str())
211 {
212 Some("md") | Some("markdown") => {
213 format_risk_report(&assessment, OutputFormat::Markdown, args.detailed)
214 }
215 Some("yaml") | Some("yml") => {
216 format_risk_report(&assessment, OutputFormat::Yaml, args.detailed)
217 }
218 _ => format_risk_report(&assessment, OutputFormat::Json, args.detailed),
219 };
220 std::fs::write(&path, content)?;
221 out.note_saved("Report", &path);
222 }
223
224 Ok(())
225}
226
227pub async fn handle_trace(args: TraceArgs, out: &Output) -> anyhow::Result<()> {
229 handle_trace_with_client(args, None, out).await
230}
231
232pub async fn handle_trace_with_client(
234 args: TraceArgs,
235 client: Option<BlockchainDataClient>,
236 out: &Output,
237) -> anyhow::Result<()> {
238 note!(out, "Tracing transaction {}...", args.tx_hash);
239 note!(out, "Depth: {} hops", args.depth);
240
241 if args.flag_suspicious {
242 note!(out, "Flagging suspicious addresses enabled");
243 }
244
245 let resolved_client = etherscan_client(client);
246
247 if let Some(client) = resolved_client {
248 match client.trace_transaction(&args.tx_hash, args.depth).await {
249 Ok(trace) => {
250 outln!(out, "\nTransaction Trace")?;
251 outln!(out, "=================")?;
252 outln!(out, "Root: {}", trace.root_hash)?;
253 outln!(out, "Hops: {}", trace.hops.len())?;
254
255 for hop in &trace.hops {
256 outln!(
257 out,
258 " Depth {}: {} ({} ETH)",
259 hop.depth,
260 hop.address,
261 hop.amount
262 )?;
263 }
264 }
265 Err(e) => return Err(e.context(format!("cannot trace transaction {}", args.tx_hash))),
266 }
267 } else {
268 note!(out, "Set {ETHERSCAN_KEY_ENV} to enable transaction tracing");
269 }
270
271 Ok(())
272}
273
274pub async fn handle_analyze(args: AnalyzeArgs, out: &Output) -> anyhow::Result<()> {
276 handle_analyze_with_client(args, None, out).await
277}
278
279pub async fn handle_analyze_with_client(
281 args: AnalyzeArgs,
282 client: Option<BlockchainDataClient>,
283 out: &Output,
284) -> anyhow::Result<()> {
285 note!(out, "Analyzing patterns for {}...", args.address);
286 note!(out, "Patterns: {:?}", args.patterns);
287 note!(out, "Time range: {}", args.range);
288
289 let resolved_client = etherscan_client(client);
290
291 if let Some(client) = resolved_client {
292 let chain = match detect_chain(&args.address) {
294 Ok(c) => c,
295 Err(_) => "ethereum".to_string(),
296 };
297
298 match client.get_transactions(&args.address, &chain).await {
299 Ok(txs) => {
300 let analysis = analyze_patterns(&txs);
301
302 outln!(out, "\nPattern Analysis Results")?;
303 outln!(out, "========================")?;
304 outln!(out, "Total transactions: {}", analysis.total_transactions)?;
305 outln!(out, "Velocity: {:.2} tx/day", analysis.velocity_score)?;
306 outln!(
307 out,
308 "Structuring detected: {}",
309 analysis.structuring_detected
310 )?;
311 outln!(
312 out,
313 "Round number pattern: {}",
314 analysis.round_number_pattern
315 )?;
316 outln!(out, "Unusual hour transactions: {}", analysis.unusual_hours)?;
317 }
318 Err(e) => {
319 return Err(e.context(format!("cannot fetch transactions for {}", args.address)));
320 }
321 }
322 } else {
323 note!(out, "Set {ETHERSCAN_KEY_ENV} to enable pattern analysis");
324 }
325
326 Ok(())
327}
328
329pub async fn handle_compliance_report(
331 args: ComplianceReportArgs,
332 out: &Output,
333) -> anyhow::Result<()> {
334 let addresses = resolve_compliance_targets(&args.target)?;
335 if addresses.is_empty() {
336 anyhow::bail!("No addresses to analyze");
337 }
338
339 note!(
340 out,
341 "Generating {:?} compliance report for {} address(es) ({:?} jurisdiction)...",
342 args.report_type,
343 addresses.len(),
344 args.jurisdiction
345 );
346
347 let client = etherscan_client(None);
348 let engine = risk_engine(client.as_ref(), None, out);
349
350 let mut risk_assessments = Vec::new();
351 let mut pattern_results: Vec<(
352 String,
353 String,
354 Option<scope::compliance::datasource::PatternAnalysis>,
355 )> = Vec::new();
356
357 for (addr, chain) in &addresses {
358 let assessment = engine.assess_address(addr, chain).await?;
359 risk_assessments.push(assessment.clone());
360
361 let pat = if let Some(ref c) = client {
362 c.get_transactions(addr, chain)
363 .await
364 .ok()
365 .map(|txs| scope::compliance::datasource::analyze_patterns(&txs))
366 } else {
367 None
368 };
369 pattern_results.push((addr.clone(), chain.clone(), pat));
370 }
371
372 let content = format_compliance_report(
373 &risk_assessments,
374 &pattern_results,
375 &args.jurisdiction,
376 &args.report_type,
377 );
378
379 std::fs::write(&args.output, &content)?;
380 out.note_saved("Compliance report", &args.output);
381
382 Ok(())
383}
384
385fn resolve_compliance_targets(target: &str) -> anyhow::Result<Vec<(String, String)>> {
387 let path = std::path::Path::new(target);
388 if path.exists() && path.is_file() {
389 let content = std::fs::read_to_string(path)?;
390 let mut out = Vec::new();
391 for line in content.lines() {
392 let line = line.trim();
393 if line.is_empty() || line.starts_with('#') {
394 continue;
395 }
396 let (addr, chain) = parse_address_line(line);
397 out.push((addr.to_string(), chain.to_string()));
398 }
399 Ok(out)
400 } else {
401 let chain = detect_chain(target).unwrap_or_else(|_| "ethereum".to_string());
402 Ok(vec![(target.to_string(), chain)])
403 }
404}
405
406fn parse_address_line(line: &str) -> (&str, &str) {
407 if let Some((addr, rest)) = line.split_once(',') {
408 (addr.trim(), rest.trim())
409 } else {
410 (line, "ethereum")
411 }
412}
413
414fn format_compliance_report(
415 assessments: &[scope::compliance::risk::RiskAssessment],
416 patterns: &[(
417 String,
418 String,
419 Option<scope::compliance::datasource::PatternAnalysis>,
420 )],
421 jurisdiction: &Jurisdiction,
422 report_type: &ReportType,
423) -> String {
424 let mut md = format!(
425 "# Compliance Report\n\n\
426 **Jurisdiction:** {:?} \n\
427 **Report Type:** {:?} \n\
428 **Generated:** {} \n\
429 **Addresses:** {} \n\n",
430 jurisdiction,
431 report_type,
432 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
433 assessments.len()
434 );
435
436 for (i, assessment) in assessments.iter().enumerate() {
437 md.push_str(&format!(
438 "---\n\n## Address {}: `{}`\n\n",
439 i + 1,
440 assessment.address
441 ));
442 md.push_str(&format!(
443 "**Chain:** {} \n**Risk Score:** {:.1}/10 \n**Risk Level:** {} {:?} \n\n",
444 assessment.chain,
445 assessment.overall_score,
446 assessment.risk_level.emoji(),
447 assessment.risk_level
448 ));
449
450 if matches!(report_type, ReportType::Detailed | ReportType::SAR) {
451 md.push_str("### Risk Factor Breakdown\n\n");
452 for f in &assessment.factors {
453 md.push_str(&format!(
454 "- **{}**: {:.1}/10 - {}\n",
455 f.name, f.score, f.description
456 ));
457 }
458 if !assessment.recommendations.is_empty() {
459 md.push_str("\n### Recommendations\n\n");
460 for r in &assessment.recommendations {
461 md.push_str(&format!("- {}\n", r));
462 }
463 }
464 }
465
466 if let Some((_, _, Some(pat))) = patterns
467 .iter()
468 .find(|(a, c, _)| a == &assessment.address && c == &assessment.chain)
469 {
470 md.push_str("\n### Pattern Analysis\n\n");
471 md.push_str(&format!(
472 "- Total transactions: {}\n",
473 pat.total_transactions
474 ));
475 md.push_str(&format!("- Velocity: {:.2} tx/day\n", pat.velocity_score));
476 md.push_str(&format!(
477 "- Structuring detected: {}\n",
478 pat.structuring_detected
479 ));
480 md.push_str(&format!(
481 "- Round number pattern: {}\n",
482 pat.round_number_pattern
483 ));
484 md.push_str(&format!(
485 "- Unusual hour transactions: {}\n",
486 pat.unusual_hours
487 ));
488 }
489 }
490
491 md.push_str(&scope::display::report::report_footer());
492 md
493}
494
495fn detect_chain(address: &str) -> anyhow::Result<String> {
497 if address.starts_with("0x") && address.len() == 42 {
498 Ok("ethereum".to_string())
500 } else if address.len() == 32 || address.len() == 44 {
501 Ok("solana".to_string())
503 } else if address.starts_with("T") && address.len() == 34 {
504 Ok("tron".to_string())
506 } else if address.starts_with("bc1") || address.starts_with("1") || address.starts_with("3") {
507 Ok("bitcoin".to_string())
509 } else {
510 anyhow::bail!("Could not auto-detect chain from address: {}", address)
511 }
512}
513
514#[cfg(test)]
519mod tests {
520 use super::*;
521
522 fn quiet() -> crate::cli::output::Output {
523 crate::cli::output::Output::capture().0
524 }
525
526 #[test]
527 fn test_detect_chain_ethereum() {
528 let result = detect_chain("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
529 assert!(result.is_ok());
530 assert_eq!(result.unwrap(), "ethereum");
531 }
532
533 #[test]
534 fn test_detect_chain_solana_short() {
535 let result = detect_chain("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC");
537 assert!(result.is_ok());
538 assert_eq!(result.unwrap(), "solana");
539 }
540
541 #[test]
542 fn test_detect_chain_solana_long() {
543 let result = detect_chain("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
545 assert!(result.is_ok());
546 assert_eq!(result.unwrap(), "solana");
547 }
548
549 #[test]
550 fn test_detect_chain_tron() {
551 let result = detect_chain("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf");
552 assert!(result.is_ok());
553 assert_eq!(result.unwrap(), "tron");
554 }
555
556 #[test]
557 fn test_detect_chain_bitcoin_bech32() {
558 let result = detect_chain("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
559 assert!(result.is_ok());
560 assert_eq!(result.unwrap(), "bitcoin");
561 }
562
563 #[test]
564 fn test_detect_chain_bitcoin_p2pkh() {
565 let result = detect_chain("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2");
566 assert!(result.is_ok());
567 assert_eq!(result.unwrap(), "bitcoin");
568 }
569
570 #[test]
571 fn test_detect_chain_bitcoin_p2sh() {
572 let result = detect_chain("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy");
573 assert!(result.is_ok());
574 assert_eq!(result.unwrap(), "bitcoin");
575 }
576
577 #[test]
578 fn test_parse_address_line_with_chain() {
579 let (addr, chain) = parse_address_line("0xabc, polygon");
580 assert_eq!(addr, "0xabc");
581 assert_eq!(chain, "polygon");
582 }
583
584 #[test]
585 fn test_parse_address_line_no_chain() {
586 let (addr, chain) = parse_address_line("0xabc");
587 assert_eq!(addr, "0xabc");
588 assert_eq!(chain, "ethereum");
589 }
590
591 #[test]
592 fn test_resolve_compliance_targets_single_address() {
593 let result =
594 resolve_compliance_targets("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2").unwrap();
595 assert_eq!(result.len(), 1);
596 assert_eq!(result[0].0, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
597 assert_eq!(result[0].1, "ethereum");
598 }
599
600 #[test]
601 fn test_resolve_compliance_targets_from_file() {
602 let dir = tempfile::tempdir().unwrap();
603 let path = dir.path().join("addresses.txt");
604 std::fs::write(
605 &path,
606 "0xabc123, ethereum\n0xdef456, polygon\n# comment\n\n0x789,solana",
607 )
608 .unwrap();
609 let result = resolve_compliance_targets(path.to_str().unwrap()).unwrap();
610 assert_eq!(result.len(), 3);
611 assert_eq!(result[0].0, "0xabc123");
612 assert_eq!(result[0].1, "ethereum");
613 assert_eq!(result[1].0, "0xdef456");
614 assert_eq!(result[1].1, "polygon");
615 assert_eq!(result[2].0, "0x789");
616 assert_eq!(result[2].1, "solana");
617 }
618
619 #[test]
620 fn test_detect_chain_unknown() {
621 let result = detect_chain("unknown_address_format_xyz");
622 assert!(result.is_err());
623 }
624
625 #[tokio::test]
626 async fn test_handle_risk_no_api_key() {
627 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
629 let args = RiskArgs {
630 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
631 chain: Some("ethereum".to_string()),
632 format: OutputFormat::Table,
633 detailed: false,
634 output: None,
635 };
636 let result = handle_risk(args, &quiet()).await;
637 assert!(result.is_ok());
638 }
639
640 #[tokio::test]
641 async fn test_handle_risk_json_format() {
642 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
643 let args = RiskArgs {
644 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
645 chain: Some("ethereum".to_string()),
646 format: OutputFormat::Json,
647 detailed: true,
648 output: None,
649 };
650 let result = handle_risk(args, &quiet()).await;
651 assert!(result.is_ok());
652 }
653
654 #[tokio::test]
655 async fn test_handle_risk_with_export() {
656 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
657 let temp = tempfile::NamedTempFile::new().unwrap();
658 let path = temp.path().to_string_lossy().to_string();
659 let args = RiskArgs {
660 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
661 chain: Some("ethereum".to_string()),
662 format: OutputFormat::Table,
663 detailed: false,
664 output: Some(path.clone()),
665 };
666 let result = handle_risk(args, &quiet()).await;
667 assert!(result.is_ok());
668 assert!(std::path::Path::new(&path).exists());
669 }
670
671 #[tokio::test]
672 async fn test_handle_risk_export_markdown_extension() {
673 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
674 let dir = tempfile::tempdir().unwrap();
675 let path = dir.path().join("report.md");
676 let path_str = path.to_string_lossy().to_string();
677 let args = RiskArgs {
678 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
679 chain: Some("ethereum".to_string()),
680 format: OutputFormat::Table,
681 detailed: false,
682 output: Some(path_str.clone()),
683 };
684 let result = handle_risk(args, &quiet()).await;
685 assert!(result.is_ok());
686 let content = std::fs::read_to_string(&path).unwrap();
687 assert!(content.contains("Risk") || content.contains("risk"));
688 }
689
690 #[tokio::test]
691 async fn test_handle_risk_export_yaml_extension() {
692 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
693 let dir = tempfile::tempdir().unwrap();
694 let path = dir.path().join("report.yaml");
695 let path_str = path.to_string_lossy().to_string();
696 let args = RiskArgs {
697 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
698 chain: Some("ethereum".to_string()),
699 format: OutputFormat::Table,
700 detailed: false,
701 output: Some(path_str.clone()),
702 };
703 let result = handle_risk(args, &quiet()).await;
704 assert!(result.is_ok());
705 let content = std::fs::read_to_string(&path).unwrap();
706 assert!(content.contains("address") || content.contains("chain"));
707 }
708
709 #[tokio::test]
710 async fn test_handle_risk_auto_detect_chain() {
711 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
712 let args = RiskArgs {
713 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
714 chain: None, format: OutputFormat::Table,
716 detailed: false,
717 output: None,
718 };
719 let result = handle_risk(args, &quiet()).await;
720 assert!(result.is_ok());
721 }
722
723 #[tokio::test]
724 async fn test_handle_trace_no_api_key() {
725 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
726 let args = TraceArgs {
727 tx_hash: "0xabc123".to_string(),
728 depth: 3,
729 flag_suspicious: true,
730 format: OutputFormat::Table,
731 };
732 let result = handle_trace(args, &quiet()).await;
733 assert!(result.is_ok()); }
735
736 #[tokio::test]
737 async fn test_handle_analyze_no_api_key() {
738 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
739 let args = AnalyzeArgs {
740 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
741 patterns: vec![PatternType::Structuring, PatternType::Layering],
742 range: "30d".to_string(),
743 format: OutputFormat::Table,
744 };
745 let result = handle_analyze(args, &quiet()).await;
746 assert!(result.is_ok());
747 }
748
749 #[tokio::test]
750 async fn test_handle_compliance_report() {
751 let args = ComplianceReportArgs {
752 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
753 jurisdiction: Jurisdiction::US,
754 report_type: ReportType::Summary,
755 output: "/tmp/test_compliance.json".to_string(),
756 };
757 let result = handle_compliance_report(args, &quiet()).await;
758 assert!(result.is_ok()); }
760
761 #[tokio::test]
762 async fn test_handle_compliance_report_eu_detailed() {
763 let args = ComplianceReportArgs {
764 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
765 jurisdiction: Jurisdiction::EU,
766 report_type: ReportType::Detailed,
767 output: "/tmp/test_compliance_eu.json".to_string(),
768 };
769 let result = handle_compliance_report(args, &quiet()).await;
770 assert!(result.is_ok());
771 }
772
773 #[tokio::test]
774 async fn test_handle_compliance_report_uk_sar() {
775 let args = ComplianceReportArgs {
776 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
777 jurisdiction: Jurisdiction::UK,
778 report_type: ReportType::SAR,
779 output: "/tmp/test_compliance_uk.json".to_string(),
780 };
781 let result = handle_compliance_report(args, &quiet()).await;
782 assert!(result.is_ok());
783 }
784
785 #[tokio::test]
786 async fn test_handle_compliance_report_singapore_travel_rule() {
787 let args = ComplianceReportArgs {
788 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
789 jurisdiction: Jurisdiction::Singapore,
790 report_type: ReportType::TravelRule,
791 output: "/tmp/test_compliance_sg.json".to_string(),
792 };
793 let result = handle_compliance_report(args, &quiet()).await;
794 assert!(result.is_ok());
795 }
796
797 #[tokio::test]
798 async fn test_handle_compliance_report_switzerland() {
799 let args = ComplianceReportArgs {
800 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
801 jurisdiction: Jurisdiction::Switzerland,
802 report_type: ReportType::Summary,
803 output: "/tmp/test_compliance_ch.json".to_string(),
804 };
805 let result = handle_compliance_report(args, &quiet()).await;
806 assert!(result.is_ok());
807 }
808
809 #[tokio::test]
810 async fn test_handle_risk_yaml_format() {
811 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
812 let args = RiskArgs {
813 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
814 chain: Some("ethereum".to_string()),
815 format: OutputFormat::Yaml,
816 detailed: false,
817 output: None,
818 };
819 let result = handle_risk(args, &quiet()).await;
820 assert!(result.is_ok());
821 }
822
823 fn mock_etherscan_response(txs: &[serde_json::Value]) -> String {
828 serde_json::json!({
829 "status": "1",
830 "message": "OK",
831 "result": txs
832 })
833 .to_string()
834 }
835
836 fn make_mock_client(base_url: &str) -> BlockchainDataClient {
837 let sources = scope::compliance::datasource::DataSources::new("test_api_key".to_string());
838 BlockchainDataClient::with_base_url(sources, base_url)
839 }
840
841 #[tokio::test]
842 async fn test_handle_risk_with_api_client() {
843 let mut server = mockito::Server::new_async().await;
844 let _mock = server
845 .mock("GET", mockito::Matcher::Any)
846 .with_status(200)
847 .with_body(mock_etherscan_response(&[]))
848 .create_async()
849 .await;
850
851 let client = make_mock_client(&server.url());
852 let args = RiskArgs {
853 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
854 chain: Some("ethereum".to_string()),
855 format: OutputFormat::Table,
856 detailed: true,
857 output: None,
858 };
859 let result = handle_risk_with_client(args, Some(client), &quiet()).await;
860 assert!(result.is_ok());
861 }
862
863 #[tokio::test]
864 async fn test_handle_risk_with_api_client_json_export() {
865 let mut server = mockito::Server::new_async().await;
866 let _mock = server
867 .mock("GET", mockito::Matcher::Any)
868 .with_status(200)
869 .with_body(mock_etherscan_response(&[]))
870 .create_async()
871 .await;
872
873 let client = make_mock_client(&server.url());
874 let tmp = tempfile::NamedTempFile::new().unwrap();
875 let path = tmp.path().to_string_lossy().to_string();
876
877 let args = RiskArgs {
878 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
879 chain: Some("ethereum".to_string()),
880 format: OutputFormat::Table,
881 detailed: false,
882 output: Some(path.clone()),
883 };
884 let result = handle_risk_with_client(args, Some(client), &quiet()).await;
885 assert!(result.is_ok());
886 assert!(std::path::Path::new(&path).exists());
887 }
888
889 #[tokio::test]
890 async fn test_handle_trace_with_api_client() {
891 let mut server = mockito::Server::new_async().await;
892 let _mock = server
893 .mock("GET", mockito::Matcher::Any)
894 .with_status(200)
895 .with_body(mock_etherscan_response(&[serde_json::json!({
896 "hash": "0xabc",
897 "from": "0x111",
898 "to": "0x222",
899 "value": "1000000000000000000",
900 "timeStamp": "1700000000",
901 "blockNumber": "18000000",
902 "gasUsed": "21000",
903 "gasPrice": "50000000000",
904 "isError": "0",
905 "input": "0x"
906 })]))
907 .create_async()
908 .await;
909
910 let client = make_mock_client(&server.url());
911 let args = TraceArgs {
912 tx_hash: "0xabc123def456".to_string(),
913 depth: 2,
914 flag_suspicious: true,
915 format: OutputFormat::Table,
916 };
917 let result = handle_trace_with_client(args, Some(client), &quiet()).await;
918 assert!(result.is_ok());
919 }
920
921 #[tokio::test]
922 async fn test_handle_trace_with_api_client_connection_refused() {
923 let client = make_mock_client("http://127.0.0.1:1");
925 let args = TraceArgs {
926 tx_hash: "0xabc123".to_string(),
927 depth: 2,
928 flag_suspicious: false,
929 format: OutputFormat::Table,
930 };
931 let err = handle_trace_with_client(args, Some(client), &quiet())
934 .await
935 .unwrap_err();
936 assert!(
937 err.to_string()
938 .contains("cannot trace transaction 0xabc123"),
939 "{err:#}"
940 );
941 }
942
943 #[tokio::test]
944 async fn test_handle_trace_with_api_client_error() {
945 let mut server = mockito::Server::new_async().await;
946 let _mock = server
947 .mock("GET", mockito::Matcher::Any)
948 .with_status(200)
949 .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
950 .create_async()
951 .await;
952
953 let client = make_mock_client(&server.url());
954 let args = TraceArgs {
955 tx_hash: "0xabc123def456".to_string(),
956 depth: 3,
957 flag_suspicious: false,
958 format: OutputFormat::Table,
959 };
960 let result = handle_trace_with_client(args, Some(client), &quiet()).await;
962 assert!(result.is_ok());
963 }
964
965 #[tokio::test]
966 async fn test_handle_analyze_with_api_client() {
967 let mut server = mockito::Server::new_async().await;
968 let _mock = server
969 .mock("GET", mockito::Matcher::Any)
970 .with_status(200)
971 .with_body(mock_etherscan_response(&[serde_json::json!({
972 "hash": "0xabc",
973 "from": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
974 "to": "0x222",
975 "value": "1000000000000000000",
976 "timeStamp": "1700000000",
977 "blockNumber": "18000000",
978 "gasUsed": "21000",
979 "gasPrice": "50000000000",
980 "isError": "0",
981 "input": "0x"
982 })]))
983 .create_async()
984 .await;
985
986 let client = make_mock_client(&server.url());
987 let args = AnalyzeArgs {
988 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
989 patterns: vec![PatternType::Structuring, PatternType::Velocity],
990 range: "30d".to_string(),
991 format: OutputFormat::Table,
992 };
993 let result = handle_analyze_with_client(args, Some(client), &quiet()).await;
994 assert!(result.is_ok(), "{result:?}");
995 }
996
997 #[tokio::test]
998 async fn test_handle_analyze_with_api_client_error() {
999 let mut server = mockito::Server::new_async().await;
1000 let _mock = server
1001 .mock("GET", mockito::Matcher::Any)
1002 .with_status(200)
1003 .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
1004 .create_async()
1005 .await;
1006
1007 let client = make_mock_client(&server.url());
1008 let args = AnalyzeArgs {
1009 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1010 patterns: vec![PatternType::Layering],
1011 range: "7d".to_string(),
1012 format: OutputFormat::Table,
1013 };
1014 let err = handle_analyze_with_client(args, Some(client), &quiet())
1016 .await
1017 .unwrap_err();
1018 assert!(
1019 err.to_string()
1020 .contains("cannot fetch transactions for 0x742d"),
1021 "{err:#}"
1022 );
1023 }
1024
1025 #[tokio::test]
1026 async fn test_handle_analyze_with_detect_chain_failure() {
1027 let mut server = mockito::Server::new_async().await;
1028 let _mock = server
1029 .mock("GET", mockito::Matcher::Any)
1030 .with_status(200)
1031 .with_body(mock_etherscan_response(&[]))
1032 .create_async()
1033 .await;
1034
1035 let client = make_mock_client(&server.url());
1036 let args = AnalyzeArgs {
1038 address: "unknown_format_addr".to_string(),
1039 patterns: vec![PatternType::Integration],
1040 range: "1y".to_string(),
1041 format: OutputFormat::Json,
1042 };
1043 let result = handle_analyze_with_client(args, Some(client), &quiet()).await;
1044 assert!(result.is_ok());
1045 }
1046
1047 #[tokio::test]
1048 async fn test_handle_risk_markdown_detailed() {
1049 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
1050 let args = RiskArgs {
1051 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1052 chain: Some("ethereum".to_string()),
1053 format: OutputFormat::Markdown,
1054 detailed: true,
1055 output: None,
1056 };
1057 let result = handle_risk(args, &quiet()).await;
1058 assert!(result.is_ok());
1059 }
1060
1061 #[tokio::test]
1062 async fn test_handle_trace_no_flag_suspicious() {
1063 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
1064 let args = TraceArgs {
1065 tx_hash: "0xdef456".to_string(),
1066 depth: 5,
1067 flag_suspicious: false,
1068 format: OutputFormat::Json,
1069 };
1070 let result = handle_trace(args, &quiet()).await;
1071 assert!(result.is_ok());
1072 }
1073
1074 #[tokio::test]
1075 async fn test_handle_analyze_all_patterns() {
1076 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
1077 let args = AnalyzeArgs {
1078 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1079 patterns: vec![
1080 PatternType::Structuring,
1081 PatternType::Layering,
1082 PatternType::Integration,
1083 PatternType::Velocity,
1084 PatternType::RoundNumbers,
1085 ],
1086 range: "6m".to_string(),
1087 format: OutputFormat::Json,
1088 };
1089 let result = handle_analyze(args, &quiet()).await;
1090 assert!(result.is_ok());
1091 }
1092
1093 #[test]
1094 fn test_pattern_type_debug() {
1095 let patterns = [
1096 PatternType::Structuring,
1097 PatternType::Layering,
1098 PatternType::Integration,
1099 PatternType::Velocity,
1100 PatternType::RoundNumbers,
1101 ];
1102 for p in &patterns {
1103 let debug = format!("{:?}", p);
1104 assert!(!debug.is_empty());
1105 }
1106 }
1107
1108 #[test]
1109 fn test_jurisdiction_debug() {
1110 let jurisdictions = [
1111 Jurisdiction::US,
1112 Jurisdiction::EU,
1113 Jurisdiction::UK,
1114 Jurisdiction::Switzerland,
1115 Jurisdiction::Singapore,
1116 ];
1117 for j in &jurisdictions {
1118 let debug = format!("{:?}", j);
1119 assert!(!debug.is_empty());
1120 }
1121 }
1122
1123 #[test]
1124 fn test_report_type_debug() {
1125 let types = [
1126 ReportType::Summary,
1127 ReportType::Detailed,
1128 ReportType::SAR,
1129 ReportType::TravelRule,
1130 ];
1131 for t in &types {
1132 let debug = format!("{:?}", t);
1133 assert!(!debug.is_empty());
1134 }
1135 }
1136
1137 #[test]
1138 fn test_format_compliance_report_summary() {
1139 use scope::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
1140 let assessment = RiskAssessment {
1141 address: "0xabc".to_string(),
1142 chain: "ethereum".to_string(),
1143 overall_score: 3.5,
1144 risk_level: RiskLevel::Low,
1145 factors: vec![RiskFactor {
1146 name: "Address Age".to_string(),
1147 category: RiskCategory::Behavioral,
1148 score: 2.0,
1149 weight: 1.0,
1150 description: "Address is well-established".to_string(),
1151 evidence: vec![],
1152 }],
1153 recommendations: vec!["Continue monitoring".to_string()],
1154 not_assessed: Vec::new(),
1155 assessed_at: chrono::Utc::now(),
1156 };
1157 let patterns: Vec<(
1158 String,
1159 String,
1160 Option<scope::compliance::datasource::PatternAnalysis>,
1161 )> = vec![];
1162 let report = format_compliance_report(
1163 &[assessment],
1164 &patterns,
1165 &Jurisdiction::US,
1166 &ReportType::Summary,
1167 );
1168 assert!(report.contains("Compliance Report"));
1169 assert!(report.contains("0xabc"));
1170 assert!(report.contains("ethereum"));
1171 assert!(report.contains("3.5"));
1172 assert!(report.contains("Low"));
1173 assert!(!report.contains("Risk Factor Breakdown"));
1175 }
1176
1177 #[test]
1178 fn test_format_compliance_report_detailed() {
1179 use scope::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
1180 let assessment = RiskAssessment {
1181 address: "0xdef".to_string(),
1182 chain: "ethereum".to_string(),
1183 overall_score: 5.5,
1184 risk_level: RiskLevel::Medium,
1185 factors: vec![
1186 RiskFactor {
1187 name: "Address Age".to_string(),
1188 category: RiskCategory::Behavioral,
1189 score: 2.0,
1190 weight: 1.0,
1191 description: "Address is well-established".to_string(),
1192 evidence: vec![],
1193 },
1194 RiskFactor {
1195 name: "Transaction Velocity".to_string(),
1196 category: RiskCategory::Behavioral,
1197 score: 7.0,
1198 weight: 0.8,
1199 description: "High transaction frequency detected".to_string(),
1200 evidence: vec![],
1201 },
1202 ],
1203 recommendations: vec![
1204 "Continue monitoring".to_string(),
1205 "Review transaction patterns".to_string(),
1206 ],
1207 not_assessed: Vec::new(),
1208 assessed_at: chrono::Utc::now(),
1209 };
1210 let patterns: Vec<(
1211 String,
1212 String,
1213 Option<scope::compliance::datasource::PatternAnalysis>,
1214 )> = vec![];
1215 let report = format_compliance_report(
1216 &[assessment],
1217 &patterns,
1218 &Jurisdiction::EU,
1219 &ReportType::Detailed,
1220 );
1221 assert!(report.contains("Compliance Report"));
1222 assert!(report.contains("0xdef"));
1223 assert!(report.contains("5.5"));
1224 assert!(report.contains("Medium"));
1225 assert!(report.contains("Risk Factor Breakdown"));
1227 assert!(report.contains("Address Age"));
1228 assert!(report.contains("Transaction Velocity"));
1229 assert!(report.contains("Recommendations"));
1230 assert!(report.contains("Continue monitoring"));
1231 assert!(report.contains("Review transaction patterns"));
1232 }
1233
1234 #[test]
1235 fn test_format_compliance_report_with_pattern_analysis() {
1236 use scope::compliance::datasource::PatternAnalysis;
1237 use scope::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
1238 let assessment = RiskAssessment {
1239 address: "0x123".to_string(),
1240 chain: "ethereum".to_string(),
1241 overall_score: 3.5,
1242 risk_level: RiskLevel::Low,
1243 factors: vec![RiskFactor {
1244 name: "Address Age".to_string(),
1245 category: RiskCategory::Behavioral,
1246 score: 2.0,
1247 weight: 1.0,
1248 description: "Address is well-established".to_string(),
1249 evidence: vec![],
1250 }],
1251 recommendations: vec!["Continue monitoring".to_string()],
1252 not_assessed: Vec::new(),
1253 assessed_at: chrono::Utc::now(),
1254 };
1255 let patterns: Vec<(String, String, Option<PatternAnalysis>)> = vec![(
1256 "0x123".to_string(),
1257 "ethereum".to_string(),
1258 Some(PatternAnalysis {
1259 total_transactions: 100,
1260 velocity_score: 2.5,
1261 structuring_detected: false,
1262 round_number_pattern: false,
1263 time_clustering: false,
1264 unusual_hours: 3,
1265 }),
1266 )];
1267 let report = format_compliance_report(
1268 &[assessment],
1269 &patterns,
1270 &Jurisdiction::UK,
1271 &ReportType::Detailed,
1272 );
1273 assert!(report.contains("Compliance Report"));
1274 assert!(report.contains("0x123"));
1275 assert!(report.contains("Pattern Analysis"));
1277 assert!(report.contains("Total transactions: 100"));
1278 assert!(report.contains("Velocity: 2.50 tx/day"));
1279 assert!(report.contains("Structuring detected: false"));
1280 assert!(report.contains("Round number pattern: false"));
1281 assert!(report.contains("Unusual hour transactions: 3"));
1282 }
1283
1284 #[test]
1285 fn test_format_compliance_report_sar_type() {
1286 use scope::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
1287 let assessment = RiskAssessment {
1288 address: "0xsar".to_string(),
1289 chain: "ethereum".to_string(),
1290 overall_score: 7.5,
1291 risk_level: RiskLevel::High,
1292 factors: vec![RiskFactor {
1293 name: "Tx Velocity".to_string(),
1294 category: RiskCategory::Behavioral,
1295 score: 8.0,
1296 weight: 1.0,
1297 description: "High velocity".to_string(),
1298 evidence: vec![],
1299 }],
1300 recommendations: vec!["File SAR".to_string()],
1301 not_assessed: Vec::new(),
1302 assessed_at: chrono::Utc::now(),
1303 };
1304 let patterns: Vec<(
1305 String,
1306 String,
1307 Option<scope::compliance::datasource::PatternAnalysis>,
1308 )> = vec![];
1309 let report = format_compliance_report(
1310 &[assessment],
1311 &patterns,
1312 &Jurisdiction::US,
1313 &ReportType::SAR,
1314 );
1315 assert!(report.contains("Compliance Report"));
1316 assert!(report.contains("Risk Factor Breakdown"));
1317 assert!(report.contains("Tx Velocity"));
1318 assert!(report.contains("File SAR"));
1319 }
1320
1321 #[test]
1322 fn test_format_compliance_report_travel_rule_type() {
1323 use scope::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
1324 let assessment = RiskAssessment {
1325 address: "0xtravel".to_string(),
1326 chain: "ethereum".to_string(),
1327 overall_score: 4.0,
1328 risk_level: RiskLevel::Medium,
1329 factors: vec![RiskFactor {
1330 name: "Travel Rule".to_string(),
1331 category: RiskCategory::Behavioral,
1332 score: 4.0,
1333 weight: 1.0,
1334 description: "Threshold check".to_string(),
1335 evidence: vec![],
1336 }],
1337 recommendations: vec![],
1338 not_assessed: Vec::new(),
1339 assessed_at: chrono::Utc::now(),
1340 };
1341 let patterns: Vec<(
1342 String,
1343 String,
1344 Option<scope::compliance::datasource::PatternAnalysis>,
1345 )> = vec![];
1346 let report = format_compliance_report(
1347 &[assessment],
1348 &patterns,
1349 &Jurisdiction::Singapore,
1350 &ReportType::TravelRule,
1351 );
1352 assert!(report.contains("Compliance Report"));
1353 assert!(report.contains("0xtravel"));
1354 assert!(report.contains("4.0"));
1355 }
1356
1357 #[test]
1358 fn test_resolve_compliance_targets_file_empty_and_comments_only() {
1359 let dir = tempfile::tempdir().unwrap();
1360 let path = dir.path().join("empty.txt");
1361 std::fs::write(&path, "\n\n# comment only\n \n").unwrap();
1362 let result = resolve_compliance_targets(path.to_str().unwrap()).unwrap();
1363 assert!(result.is_empty());
1364 }
1365
1366 #[test]
1367 fn test_parse_address_line_with_chain_trimmed() {
1368 let (addr, chain) = parse_address_line("0xabc , polygon ");
1369 assert_eq!(addr, "0xabc");
1370 assert_eq!(chain, "polygon");
1371 }
1372}