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