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