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