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!(" ⚠ Could not fetch transactions (use -v for details)");
296 tracing::debug!("Error fetching transactions: {}", e);
297 }
298 }
299 } else {
300 println!("Set ETHERSCAN_API_KEY to enable pattern analysis");
301 }
302
303 Ok(())
304}
305
306pub async fn handle_compliance_report(args: ComplianceReportArgs) -> anyhow::Result<()> {
308 let addresses = resolve_compliance_targets(&args.target)?;
309 if addresses.is_empty() {
310 anyhow::bail!("No addresses to analyze");
311 }
312
313 println!(
314 "Generating {:?} compliance report for {} address(es) ({:?} jurisdiction)...",
315 args.report_type,
316 addresses.len(),
317 args.jurisdiction
318 );
319
320 let client = std::env::var("ETHERSCAN_API_KEY").ok().map(|key| {
321 let sources = DataSources::new(key);
322 BlockchainDataClient::new(sources)
323 });
324
325 let engine = match &client {
326 Some(c) => {
327 println!("Using Etherscan API for enhanced analysis");
328 RiskEngine::with_data_client(c.clone())
329 }
330 None => {
331 println!("Note: Set ETHERSCAN_API_KEY for enhanced analysis");
332 RiskEngine::new()
333 }
334 };
335
336 let mut risk_assessments = Vec::new();
337 let mut pattern_results: Vec<(
338 String,
339 String,
340 Option<crate::compliance::datasource::PatternAnalysis>,
341 )> = Vec::new();
342
343 for (addr, chain) in &addresses {
344 let assessment = engine.assess_address(addr, chain).await?;
345 risk_assessments.push(assessment.clone());
346
347 let pat = if let Some(ref c) = client {
348 c.get_transactions(addr, chain)
349 .await
350 .ok()
351 .map(|txs| crate::compliance::datasource::analyze_patterns(&txs))
352 } else {
353 None
354 };
355 pattern_results.push((addr.clone(), chain.clone(), pat));
356 }
357
358 let content = format_compliance_report(
359 &risk_assessments,
360 &pattern_results,
361 &args.jurisdiction,
362 &args.report_type,
363 );
364
365 std::fs::write(&args.output, &content)?;
366 println!("\nCompliance report saved to: {}", args.output);
367
368 Ok(())
369}
370
371fn resolve_compliance_targets(target: &str) -> anyhow::Result<Vec<(String, String)>> {
373 let path = std::path::Path::new(target);
374 if path.exists() && path.is_file() {
375 let content = std::fs::read_to_string(path)?;
376 let mut out = Vec::new();
377 for line in content.lines() {
378 let line = line.trim();
379 if line.is_empty() || line.starts_with('#') {
380 continue;
381 }
382 let (addr, chain) = parse_address_line(line);
383 out.push((addr.to_string(), chain.to_string()));
384 }
385 Ok(out)
386 } else {
387 let chain = detect_chain(target).unwrap_or_else(|_| "ethereum".to_string());
388 Ok(vec![(target.to_string(), chain)])
389 }
390}
391
392fn parse_address_line(line: &str) -> (&str, &str) {
393 if let Some((addr, rest)) = line.split_once(',') {
394 (addr.trim(), rest.trim())
395 } else {
396 (line, "ethereum")
397 }
398}
399
400fn format_compliance_report(
401 assessments: &[crate::compliance::risk::RiskAssessment],
402 patterns: &[(
403 String,
404 String,
405 Option<crate::compliance::datasource::PatternAnalysis>,
406 )],
407 jurisdiction: &Jurisdiction,
408 report_type: &ReportType,
409) -> String {
410 let mut md = format!(
411 "# Compliance Report\n\n\
412 **Jurisdiction:** {:?} \n\
413 **Report Type:** {:?} \n\
414 **Generated:** {} \n\
415 **Addresses:** {} \n\n",
416 jurisdiction,
417 report_type,
418 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
419 assessments.len()
420 );
421
422 for (i, assessment) in assessments.iter().enumerate() {
423 md.push_str(&format!(
424 "---\n\n## Address {}: `{}`\n\n",
425 i + 1,
426 assessment.address
427 ));
428 md.push_str(&format!(
429 "**Chain:** {} \n**Risk Score:** {:.1}/10 \n**Risk Level:** {} {:?} \n\n",
430 assessment.chain,
431 assessment.overall_score,
432 assessment.risk_level.emoji(),
433 assessment.risk_level
434 ));
435
436 if matches!(report_type, ReportType::Detailed | ReportType::SAR) {
437 md.push_str("### Risk Factor Breakdown\n\n");
438 for f in &assessment.factors {
439 md.push_str(&format!(
440 "- **{}**: {:.1}/10 - {}\n",
441 f.name, f.score, f.description
442 ));
443 }
444 if !assessment.recommendations.is_empty() {
445 md.push_str("\n### Recommendations\n\n");
446 for r in &assessment.recommendations {
447 md.push_str(&format!("- {}\n", r));
448 }
449 }
450 }
451
452 if let Some((_, _, Some(pat))) = patterns
453 .iter()
454 .find(|(a, c, _)| a == &assessment.address && c == &assessment.chain)
455 {
456 md.push_str("\n### Pattern Analysis\n\n");
457 md.push_str(&format!(
458 "- Total transactions: {}\n",
459 pat.total_transactions
460 ));
461 md.push_str(&format!("- Velocity: {:.2} tx/day\n", pat.velocity_score));
462 md.push_str(&format!(
463 "- Structuring detected: {}\n",
464 pat.structuring_detected
465 ));
466 md.push_str(&format!(
467 "- Round number pattern: {}\n",
468 pat.round_number_pattern
469 ));
470 md.push_str(&format!(
471 "- Unusual hour transactions: {}\n",
472 pat.unusual_hours
473 ));
474 }
475 }
476
477 md.push_str(&crate::display::report::report_footer());
478 md
479}
480
481fn detect_chain(address: &str) -> anyhow::Result<String> {
483 if address.starts_with("0x") && address.len() == 42 {
484 Ok("ethereum".to_string())
486 } else if address.len() == 32 || address.len() == 44 {
487 Ok("solana".to_string())
489 } else if address.starts_with("T") && address.len() == 34 {
490 Ok("tron".to_string())
492 } else if address.starts_with("bc1") || address.starts_with("1") || address.starts_with("3") {
493 Ok("bitcoin".to_string())
495 } else {
496 anyhow::bail!("Could not auto-detect chain from address: {}", address)
497 }
498}
499
500#[cfg(test)]
505mod tests {
506 use super::*;
507
508 #[test]
509 fn test_detect_chain_ethereum() {
510 let result = detect_chain("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
511 assert!(result.is_ok());
512 assert_eq!(result.unwrap(), "ethereum");
513 }
514
515 #[test]
516 fn test_detect_chain_solana_short() {
517 let result = detect_chain("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC");
519 assert!(result.is_ok());
520 assert_eq!(result.unwrap(), "solana");
521 }
522
523 #[test]
524 fn test_detect_chain_solana_long() {
525 let result = detect_chain("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
527 assert!(result.is_ok());
528 assert_eq!(result.unwrap(), "solana");
529 }
530
531 #[test]
532 fn test_detect_chain_tron() {
533 let result = detect_chain("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf");
534 assert!(result.is_ok());
535 assert_eq!(result.unwrap(), "tron");
536 }
537
538 #[test]
539 fn test_detect_chain_bitcoin_bech32() {
540 let result = detect_chain("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
541 assert!(result.is_ok());
542 assert_eq!(result.unwrap(), "bitcoin");
543 }
544
545 #[test]
546 fn test_detect_chain_bitcoin_p2pkh() {
547 let result = detect_chain("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2");
548 assert!(result.is_ok());
549 assert_eq!(result.unwrap(), "bitcoin");
550 }
551
552 #[test]
553 fn test_detect_chain_bitcoin_p2sh() {
554 let result = detect_chain("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy");
555 assert!(result.is_ok());
556 assert_eq!(result.unwrap(), "bitcoin");
557 }
558
559 #[test]
560 fn test_parse_address_line_with_chain() {
561 let (addr, chain) = parse_address_line("0xabc, polygon");
562 assert_eq!(addr, "0xabc");
563 assert_eq!(chain, "polygon");
564 }
565
566 #[test]
567 fn test_parse_address_line_no_chain() {
568 let (addr, chain) = parse_address_line("0xabc");
569 assert_eq!(addr, "0xabc");
570 assert_eq!(chain, "ethereum");
571 }
572
573 #[test]
574 fn test_resolve_compliance_targets_single_address() {
575 let result =
576 resolve_compliance_targets("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2").unwrap();
577 assert_eq!(result.len(), 1);
578 assert_eq!(result[0].0, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
579 assert_eq!(result[0].1, "ethereum");
580 }
581
582 #[test]
583 fn test_resolve_compliance_targets_from_file() {
584 let dir = tempfile::tempdir().unwrap();
585 let path = dir.path().join("addresses.txt");
586 std::fs::write(
587 &path,
588 "0xabc123, ethereum\n0xdef456, polygon\n# comment\n\n0x789,solana",
589 )
590 .unwrap();
591 let result = resolve_compliance_targets(path.to_str().unwrap()).unwrap();
592 assert_eq!(result.len(), 3);
593 assert_eq!(result[0].0, "0xabc123");
594 assert_eq!(result[0].1, "ethereum");
595 assert_eq!(result[1].0, "0xdef456");
596 assert_eq!(result[1].1, "polygon");
597 assert_eq!(result[2].0, "0x789");
598 assert_eq!(result[2].1, "solana");
599 }
600
601 #[test]
602 fn test_detect_chain_unknown() {
603 let result = detect_chain("unknown_address_format_xyz");
604 assert!(result.is_err());
605 }
606
607 #[tokio::test]
608 async fn test_handle_risk_no_api_key() {
609 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
611 let args = RiskArgs {
612 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
613 chain: Some("ethereum".to_string()),
614 format: OutputFormat::Table,
615 detailed: false,
616 output: None,
617 };
618 let result = handle_risk(args).await;
619 assert!(result.is_ok());
620 }
621
622 #[tokio::test]
623 async fn test_handle_risk_json_format() {
624 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
625 let args = RiskArgs {
626 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
627 chain: Some("ethereum".to_string()),
628 format: OutputFormat::Json,
629 detailed: true,
630 output: None,
631 };
632 let result = handle_risk(args).await;
633 assert!(result.is_ok());
634 }
635
636 #[tokio::test]
637 async fn test_handle_risk_with_export() {
638 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
639 let temp = tempfile::NamedTempFile::new().unwrap();
640 let path = temp.path().to_string_lossy().to_string();
641 let args = RiskArgs {
642 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
643 chain: Some("ethereum".to_string()),
644 format: OutputFormat::Table,
645 detailed: false,
646 output: Some(path.clone()),
647 };
648 let result = handle_risk(args).await;
649 assert!(result.is_ok());
650 assert!(std::path::Path::new(&path).exists());
651 }
652
653 #[tokio::test]
654 async fn test_handle_risk_export_markdown_extension() {
655 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
656 let dir = tempfile::tempdir().unwrap();
657 let path = dir.path().join("report.md");
658 let path_str = path.to_string_lossy().to_string();
659 let args = RiskArgs {
660 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
661 chain: Some("ethereum".to_string()),
662 format: OutputFormat::Table,
663 detailed: false,
664 output: Some(path_str.clone()),
665 };
666 let result = handle_risk(args).await;
667 assert!(result.is_ok());
668 let content = std::fs::read_to_string(&path).unwrap();
669 assert!(content.contains("Risk") || content.contains("risk"));
670 }
671
672 #[tokio::test]
673 async fn test_handle_risk_export_yaml_extension() {
674 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
675 let dir = tempfile::tempdir().unwrap();
676 let path = dir.path().join("report.yaml");
677 let path_str = path.to_string_lossy().to_string();
678 let args = RiskArgs {
679 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
680 chain: Some("ethereum".to_string()),
681 format: OutputFormat::Table,
682 detailed: false,
683 output: Some(path_str.clone()),
684 };
685 let result = handle_risk(args).await;
686 assert!(result.is_ok());
687 let content = std::fs::read_to_string(&path).unwrap();
688 assert!(content.contains("address") || content.contains("chain"));
689 }
690
691 #[tokio::test]
692 async fn test_handle_risk_auto_detect_chain() {
693 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
694 let args = RiskArgs {
695 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
696 chain: None, format: OutputFormat::Table,
698 detailed: false,
699 output: None,
700 };
701 let result = handle_risk(args).await;
702 assert!(result.is_ok());
703 }
704
705 #[tokio::test]
706 async fn test_handle_trace_no_api_key() {
707 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
708 let args = TraceArgs {
709 tx_hash: "0xabc123".to_string(),
710 depth: 3,
711 flag_suspicious: true,
712 format: OutputFormat::Table,
713 };
714 let result = handle_trace(args).await;
715 assert!(result.is_ok()); }
717
718 #[tokio::test]
719 async fn test_handle_analyze_no_api_key() {
720 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
721 let args = AnalyzeArgs {
722 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
723 patterns: vec![PatternType::Structuring, PatternType::Layering],
724 range: "30d".to_string(),
725 format: OutputFormat::Table,
726 };
727 let result = handle_analyze(args).await;
728 assert!(result.is_ok());
729 }
730
731 #[tokio::test]
732 async fn test_handle_compliance_report() {
733 let args = ComplianceReportArgs {
734 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
735 jurisdiction: Jurisdiction::US,
736 report_type: ReportType::Summary,
737 output: "/tmp/test_compliance.json".to_string(),
738 };
739 let result = handle_compliance_report(args).await;
740 assert!(result.is_ok()); }
742
743 #[tokio::test]
744 async fn test_handle_compliance_report_eu_detailed() {
745 let args = ComplianceReportArgs {
746 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
747 jurisdiction: Jurisdiction::EU,
748 report_type: ReportType::Detailed,
749 output: "/tmp/test_compliance_eu.json".to_string(),
750 };
751 let result = handle_compliance_report(args).await;
752 assert!(result.is_ok());
753 }
754
755 #[tokio::test]
756 async fn test_handle_compliance_report_uk_sar() {
757 let args = ComplianceReportArgs {
758 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
759 jurisdiction: Jurisdiction::UK,
760 report_type: ReportType::SAR,
761 output: "/tmp/test_compliance_uk.json".to_string(),
762 };
763 let result = handle_compliance_report(args).await;
764 assert!(result.is_ok());
765 }
766
767 #[tokio::test]
768 async fn test_handle_compliance_report_singapore_travel_rule() {
769 let args = ComplianceReportArgs {
770 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
771 jurisdiction: Jurisdiction::Singapore,
772 report_type: ReportType::TravelRule,
773 output: "/tmp/test_compliance_sg.json".to_string(),
774 };
775 let result = handle_compliance_report(args).await;
776 assert!(result.is_ok());
777 }
778
779 #[tokio::test]
780 async fn test_handle_compliance_report_switzerland() {
781 let args = ComplianceReportArgs {
782 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
783 jurisdiction: Jurisdiction::Switzerland,
784 report_type: ReportType::Summary,
785 output: "/tmp/test_compliance_ch.json".to_string(),
786 };
787 let result = handle_compliance_report(args).await;
788 assert!(result.is_ok());
789 }
790
791 #[tokio::test]
792 async fn test_handle_risk_yaml_format() {
793 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
794 let args = RiskArgs {
795 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
796 chain: Some("ethereum".to_string()),
797 format: OutputFormat::Yaml,
798 detailed: false,
799 output: None,
800 };
801 let result = handle_risk(args).await;
802 assert!(result.is_ok());
803 }
804
805 fn mock_etherscan_response(txs: &[serde_json::Value]) -> String {
810 serde_json::json!({
811 "status": "1",
812 "message": "OK",
813 "result": txs
814 })
815 .to_string()
816 }
817
818 fn make_mock_client(base_url: &str) -> BlockchainDataClient {
819 let sources = DataSources::new("test_api_key".to_string());
820 BlockchainDataClient::with_base_url(sources, base_url)
821 }
822
823 #[tokio::test]
824 async fn test_handle_risk_with_api_client() {
825 let mut server = mockito::Server::new_async().await;
826 let _mock = server
827 .mock("GET", mockito::Matcher::Any)
828 .with_status(200)
829 .with_body(mock_etherscan_response(&[]))
830 .create_async()
831 .await;
832
833 let client = make_mock_client(&server.url());
834 let args = RiskArgs {
835 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
836 chain: Some("ethereum".to_string()),
837 format: OutputFormat::Table,
838 detailed: true,
839 output: None,
840 };
841 let result = handle_risk_with_client(args, Some(client)).await;
842 assert!(result.is_ok());
843 }
844
845 #[tokio::test]
846 async fn test_handle_risk_with_api_client_json_export() {
847 let mut server = mockito::Server::new_async().await;
848 let _mock = server
849 .mock("GET", mockito::Matcher::Any)
850 .with_status(200)
851 .with_body(mock_etherscan_response(&[]))
852 .create_async()
853 .await;
854
855 let client = make_mock_client(&server.url());
856 let tmp = tempfile::NamedTempFile::new().unwrap();
857 let path = tmp.path().to_string_lossy().to_string();
858
859 let args = RiskArgs {
860 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
861 chain: Some("ethereum".to_string()),
862 format: OutputFormat::Table,
863 detailed: false,
864 output: Some(path.clone()),
865 };
866 let result = handle_risk_with_client(args, Some(client)).await;
867 assert!(result.is_ok());
868 assert!(std::path::Path::new(&path).exists());
869 }
870
871 #[tokio::test]
872 async fn test_handle_trace_with_api_client() {
873 let mut server = mockito::Server::new_async().await;
874 let _mock = server
875 .mock("GET", mockito::Matcher::Any)
876 .with_status(200)
877 .with_body(mock_etherscan_response(&[serde_json::json!({
878 "hash": "0xabc",
879 "from": "0x111",
880 "to": "0x222",
881 "value": "1000000000000000000",
882 "timeStamp": "1700000000",
883 "blockNumber": "18000000",
884 "gasUsed": "21000",
885 "gasPrice": "50000000000",
886 "isError": "0",
887 "input": "0x"
888 })]))
889 .create_async()
890 .await;
891
892 let client = make_mock_client(&server.url());
893 let args = TraceArgs {
894 tx_hash: "0xabc123def456".to_string(),
895 depth: 2,
896 flag_suspicious: true,
897 format: OutputFormat::Table,
898 };
899 let result = handle_trace_with_client(args, Some(client)).await;
900 assert!(result.is_ok());
901 }
902
903 #[tokio::test]
904 async fn test_handle_trace_with_api_client_connection_refused() {
905 let client = make_mock_client("http://127.0.0.1:1");
907 let args = TraceArgs {
908 tx_hash: "0xabc123".to_string(),
909 depth: 2,
910 flag_suspicious: false,
911 format: OutputFormat::Table,
912 };
913 let result = handle_trace_with_client(args, Some(client)).await;
914 assert!(result.is_ok()); }
916
917 #[tokio::test]
918 async fn test_handle_trace_with_api_client_error() {
919 let mut server = mockito::Server::new_async().await;
920 let _mock = server
921 .mock("GET", mockito::Matcher::Any)
922 .with_status(200)
923 .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
924 .create_async()
925 .await;
926
927 let client = make_mock_client(&server.url());
928 let args = TraceArgs {
929 tx_hash: "0xabc123def456".to_string(),
930 depth: 3,
931 flag_suspicious: false,
932 format: OutputFormat::Table,
933 };
934 let result = handle_trace_with_client(args, Some(client)).await;
936 assert!(result.is_ok());
937 }
938
939 #[tokio::test]
940 async fn test_handle_analyze_with_api_client() {
941 let mut server = mockito::Server::new_async().await;
942 let _mock = server
943 .mock("GET", mockito::Matcher::Any)
944 .with_status(200)
945 .with_body(mock_etherscan_response(&[serde_json::json!({
946 "hash": "0xabc",
947 "from": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
948 "to": "0x222",
949 "value": "1000000000000000000",
950 "timeStamp": "1700000000",
951 "blockNumber": "18000000",
952 "gasUsed": "21000",
953 "gasPrice": "50000000000",
954 "isError": "0",
955 "input": "0x"
956 })]))
957 .create_async()
958 .await;
959
960 let client = make_mock_client(&server.url());
961 let args = AnalyzeArgs {
962 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
963 patterns: vec![PatternType::Structuring, PatternType::Velocity],
964 range: "30d".to_string(),
965 format: OutputFormat::Table,
966 };
967 let result = handle_analyze_with_client(args, Some(client)).await;
968 assert!(result.is_ok());
969 }
970
971 #[tokio::test]
972 async fn test_handle_analyze_with_api_client_error() {
973 let mut server = mockito::Server::new_async().await;
974 let _mock = server
975 .mock("GET", mockito::Matcher::Any)
976 .with_status(200)
977 .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
978 .create_async()
979 .await;
980
981 let client = make_mock_client(&server.url());
982 let args = AnalyzeArgs {
983 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
984 patterns: vec![PatternType::Layering],
985 range: "7d".to_string(),
986 format: OutputFormat::Table,
987 };
988 let result = handle_analyze_with_client(args, Some(client)).await;
990 assert!(result.is_ok());
991 }
992
993 #[tokio::test]
994 async fn test_handle_analyze_with_detect_chain_failure() {
995 let mut server = mockito::Server::new_async().await;
996 let _mock = server
997 .mock("GET", mockito::Matcher::Any)
998 .with_status(200)
999 .with_body(mock_etherscan_response(&[]))
1000 .create_async()
1001 .await;
1002
1003 let client = make_mock_client(&server.url());
1004 let args = AnalyzeArgs {
1006 address: "unknown_format_addr".to_string(),
1007 patterns: vec![PatternType::Integration],
1008 range: "1y".to_string(),
1009 format: OutputFormat::Json,
1010 };
1011 let result = handle_analyze_with_client(args, Some(client)).await;
1012 assert!(result.is_ok());
1013 }
1014
1015 #[tokio::test]
1016 async fn test_handle_risk_markdown_detailed() {
1017 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
1018 let args = RiskArgs {
1019 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1020 chain: Some("ethereum".to_string()),
1021 format: OutputFormat::Markdown,
1022 detailed: true,
1023 output: None,
1024 };
1025 let result = handle_risk(args).await;
1026 assert!(result.is_ok());
1027 }
1028
1029 #[tokio::test]
1030 async fn test_handle_trace_no_flag_suspicious() {
1031 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
1032 let args = TraceArgs {
1033 tx_hash: "0xdef456".to_string(),
1034 depth: 5,
1035 flag_suspicious: false,
1036 format: OutputFormat::Json,
1037 };
1038 let result = handle_trace(args).await;
1039 assert!(result.is_ok());
1040 }
1041
1042 #[tokio::test]
1043 async fn test_handle_analyze_all_patterns() {
1044 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
1045 let args = AnalyzeArgs {
1046 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1047 patterns: vec![
1048 PatternType::Structuring,
1049 PatternType::Layering,
1050 PatternType::Integration,
1051 PatternType::Velocity,
1052 PatternType::RoundNumbers,
1053 ],
1054 range: "6m".to_string(),
1055 format: OutputFormat::Json,
1056 };
1057 let result = handle_analyze(args).await;
1058 assert!(result.is_ok());
1059 }
1060
1061 #[test]
1062 fn test_pattern_type_debug() {
1063 let patterns = [
1064 PatternType::Structuring,
1065 PatternType::Layering,
1066 PatternType::Integration,
1067 PatternType::Velocity,
1068 PatternType::RoundNumbers,
1069 ];
1070 for p in &patterns {
1071 let debug = format!("{:?}", p);
1072 assert!(!debug.is_empty());
1073 }
1074 }
1075
1076 #[test]
1077 fn test_jurisdiction_debug() {
1078 let jurisdictions = [
1079 Jurisdiction::US,
1080 Jurisdiction::EU,
1081 Jurisdiction::UK,
1082 Jurisdiction::Switzerland,
1083 Jurisdiction::Singapore,
1084 ];
1085 for j in &jurisdictions {
1086 let debug = format!("{:?}", j);
1087 assert!(!debug.is_empty());
1088 }
1089 }
1090
1091 #[test]
1092 fn test_report_type_debug() {
1093 let types = [
1094 ReportType::Summary,
1095 ReportType::Detailed,
1096 ReportType::SAR,
1097 ReportType::TravelRule,
1098 ];
1099 for t in &types {
1100 let debug = format!("{:?}", t);
1101 assert!(!debug.is_empty());
1102 }
1103 }
1104
1105 #[test]
1106 fn test_format_compliance_report_summary() {
1107 use crate::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
1108 let assessment = RiskAssessment {
1109 address: "0xabc".to_string(),
1110 chain: "ethereum".to_string(),
1111 overall_score: 3.5,
1112 risk_level: RiskLevel::Low,
1113 factors: vec![RiskFactor {
1114 name: "Address Age".to_string(),
1115 category: RiskCategory::Behavioral,
1116 score: 2.0,
1117 weight: 1.0,
1118 description: "Address is well-established".to_string(),
1119 evidence: vec![],
1120 }],
1121 recommendations: vec!["Continue monitoring".to_string()],
1122 assessed_at: chrono::Utc::now(),
1123 };
1124 let patterns: Vec<(
1125 String,
1126 String,
1127 Option<crate::compliance::datasource::PatternAnalysis>,
1128 )> = vec![];
1129 let report = format_compliance_report(
1130 &[assessment],
1131 &patterns,
1132 &Jurisdiction::US,
1133 &ReportType::Summary,
1134 );
1135 assert!(report.contains("Compliance Report"));
1136 assert!(report.contains("0xabc"));
1137 assert!(report.contains("ethereum"));
1138 assert!(report.contains("3.5"));
1139 assert!(report.contains("Low"));
1140 assert!(!report.contains("Risk Factor Breakdown"));
1142 }
1143
1144 #[test]
1145 fn test_format_compliance_report_detailed() {
1146 use crate::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
1147 let assessment = RiskAssessment {
1148 address: "0xdef".to_string(),
1149 chain: "ethereum".to_string(),
1150 overall_score: 5.5,
1151 risk_level: RiskLevel::Medium,
1152 factors: vec![
1153 RiskFactor {
1154 name: "Address Age".to_string(),
1155 category: RiskCategory::Behavioral,
1156 score: 2.0,
1157 weight: 1.0,
1158 description: "Address is well-established".to_string(),
1159 evidence: vec![],
1160 },
1161 RiskFactor {
1162 name: "Transaction Velocity".to_string(),
1163 category: RiskCategory::Behavioral,
1164 score: 7.0,
1165 weight: 0.8,
1166 description: "High transaction frequency detected".to_string(),
1167 evidence: vec![],
1168 },
1169 ],
1170 recommendations: vec![
1171 "Continue monitoring".to_string(),
1172 "Review transaction patterns".to_string(),
1173 ],
1174 assessed_at: chrono::Utc::now(),
1175 };
1176 let patterns: Vec<(
1177 String,
1178 String,
1179 Option<crate::compliance::datasource::PatternAnalysis>,
1180 )> = vec![];
1181 let report = format_compliance_report(
1182 &[assessment],
1183 &patterns,
1184 &Jurisdiction::EU,
1185 &ReportType::Detailed,
1186 );
1187 assert!(report.contains("Compliance Report"));
1188 assert!(report.contains("0xdef"));
1189 assert!(report.contains("5.5"));
1190 assert!(report.contains("Medium"));
1191 assert!(report.contains("Risk Factor Breakdown"));
1193 assert!(report.contains("Address Age"));
1194 assert!(report.contains("Transaction Velocity"));
1195 assert!(report.contains("Recommendations"));
1196 assert!(report.contains("Continue monitoring"));
1197 assert!(report.contains("Review transaction patterns"));
1198 }
1199
1200 #[test]
1201 fn test_format_compliance_report_with_pattern_analysis() {
1202 use crate::compliance::datasource::PatternAnalysis;
1203 use crate::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
1204 let assessment = RiskAssessment {
1205 address: "0x123".to_string(),
1206 chain: "ethereum".to_string(),
1207 overall_score: 3.5,
1208 risk_level: RiskLevel::Low,
1209 factors: vec![RiskFactor {
1210 name: "Address Age".to_string(),
1211 category: RiskCategory::Behavioral,
1212 score: 2.0,
1213 weight: 1.0,
1214 description: "Address is well-established".to_string(),
1215 evidence: vec![],
1216 }],
1217 recommendations: vec!["Continue monitoring".to_string()],
1218 assessed_at: chrono::Utc::now(),
1219 };
1220 let patterns: Vec<(String, String, Option<PatternAnalysis>)> = vec![(
1221 "0x123".to_string(),
1222 "ethereum".to_string(),
1223 Some(PatternAnalysis {
1224 total_transactions: 100,
1225 velocity_score: 2.5,
1226 structuring_detected: false,
1227 round_number_pattern: false,
1228 time_clustering: false,
1229 unusual_hours: 3,
1230 }),
1231 )];
1232 let report = format_compliance_report(
1233 &[assessment],
1234 &patterns,
1235 &Jurisdiction::UK,
1236 &ReportType::Detailed,
1237 );
1238 assert!(report.contains("Compliance Report"));
1239 assert!(report.contains("0x123"));
1240 assert!(report.contains("Pattern Analysis"));
1242 assert!(report.contains("Total transactions: 100"));
1243 assert!(report.contains("Velocity: 2.50 tx/day"));
1244 assert!(report.contains("Structuring detected: false"));
1245 assert!(report.contains("Round number pattern: false"));
1246 assert!(report.contains("Unusual hour transactions: 3"));
1247 }
1248}