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 println!("Assessing risk for {} on {}...", args.address, chain);
150
151 let engine = if let Some(c) = client {
152 println!("Using Etherscan API for enhanced analysis");
153 RiskEngine::with_data_client(c)
154 } else {
155 let etherscan_key = std::env::var("ETHERSCAN_API_KEY").ok();
157
158 if let Some(key) = etherscan_key {
159 let sources = DataSources::new(key);
160 let client = BlockchainDataClient::new(sources);
161 println!("Using Etherscan API for enhanced analysis");
162 RiskEngine::with_data_client(client)
163 } else {
164 println!("Note: Set ETHERSCAN_API_KEY for enhanced analysis");
165 RiskEngine::new()
166 }
167 };
168
169 let assessment = engine.assess_address(&args.address, &chain).await?;
170
171 let output = format_risk_report(&assessment, args.format, args.detailed);
173 println!("{}", output);
174
175 if let Some(path) = args.output {
177 let content = match std::path::Path::new(&path)
178 .extension()
179 .and_then(|e| e.to_str())
180 {
181 Some("md") | Some("markdown") => {
182 format_risk_report(&assessment, OutputFormat::Markdown, args.detailed)
183 }
184 Some("yaml") | Some("yml") => {
185 format_risk_report(&assessment, OutputFormat::Yaml, args.detailed)
186 }
187 _ => format_risk_report(&assessment, OutputFormat::Json, args.detailed),
188 };
189 std::fs::write(&path, content)?;
190 println!("\nReport exported to: {}", path);
191 }
192
193 Ok(())
194}
195
196pub async fn handle_trace(args: TraceArgs) -> anyhow::Result<()> {
198 handle_trace_with_client(args, None).await
199}
200
201pub async fn handle_trace_with_client(
203 args: TraceArgs,
204 client: Option<BlockchainDataClient>,
205) -> anyhow::Result<()> {
206 println!("Tracing transaction {}...", args.tx_hash);
207 println!("Depth: {} hops", args.depth);
208
209 if args.flag_suspicious {
210 println!("Flagging suspicious addresses enabled");
211 }
212
213 let resolved_client = if let Some(c) = client {
214 Some(c)
215 } else {
216 std::env::var("ETHERSCAN_API_KEY").ok().map(|key| {
217 let sources = DataSources::new(key);
218 BlockchainDataClient::new(sources)
219 })
220 };
221
222 if let Some(client) = resolved_client {
223 match client.trace_transaction(&args.tx_hash, args.depth).await {
224 Ok(trace) => {
225 println!("\nTransaction Trace");
226 println!("=================");
227 println!("Root: {}", trace.root_hash);
228 println!("Hops: {}", trace.hops.len());
229
230 for hop in &trace.hops {
231 println!(
232 " Depth {}: {} ({} ETH)",
233 hop.depth, hop.address, hop.amount
234 );
235 }
236 }
237 Err(e) => {
238 eprintln!("Error tracing transaction: {}", e);
239 }
240 }
241 } else {
242 println!("Set ETHERSCAN_API_KEY to enable transaction tracing");
243 }
244
245 Ok(())
246}
247
248pub async fn handle_analyze(args: AnalyzeArgs) -> anyhow::Result<()> {
250 handle_analyze_with_client(args, None).await
251}
252
253pub async fn handle_analyze_with_client(
255 args: AnalyzeArgs,
256 client: Option<BlockchainDataClient>,
257) -> anyhow::Result<()> {
258 println!("Analyzing patterns for {}...", args.address);
259 println!("Patterns: {:?}", args.patterns);
260 println!("Time range: {}", args.range);
261
262 let resolved_client = if let Some(c) = client {
263 Some(c)
264 } else {
265 std::env::var("ETHERSCAN_API_KEY").ok().map(|key| {
266 let sources = DataSources::new(key);
267 BlockchainDataClient::new(sources)
268 })
269 };
270
271 if let Some(client) = resolved_client {
272 let chain = match detect_chain(&args.address) {
274 Ok(c) => c,
275 Err(_) => "ethereum".to_string(),
276 };
277
278 match client.get_transactions(&args.address, &chain).await {
279 Ok(txs) => {
280 let analysis = analyze_patterns(&txs);
281
282 println!("\nPattern Analysis Results");
283 println!("========================");
284 println!("Total transactions: {}", analysis.total_transactions);
285 println!("Velocity: {:.2} tx/day", analysis.velocity_score);
286 println!("Structuring detected: {}", analysis.structuring_detected);
287 println!("Round number pattern: {}", analysis.round_number_pattern);
288 println!("Unusual hour transactions: {}", analysis.unusual_hours);
289 }
290 Err(e) => {
291 eprintln!("Error fetching transactions: {}", e);
292 }
293 }
294 } else {
295 println!("Set ETHERSCAN_API_KEY to enable pattern analysis");
296 }
297
298 Ok(())
299}
300
301pub async fn handle_compliance_report(args: ComplianceReportArgs) -> anyhow::Result<()> {
303 let addresses = resolve_compliance_targets(&args.target)?;
304 if addresses.is_empty() {
305 anyhow::bail!("No addresses to analyze");
306 }
307
308 println!(
309 "Generating {:?} compliance report for {} address(es) ({:?} jurisdiction)...",
310 args.report_type,
311 addresses.len(),
312 args.jurisdiction
313 );
314
315 let client = std::env::var("ETHERSCAN_API_KEY").ok().map(|key| {
316 let sources = DataSources::new(key);
317 BlockchainDataClient::new(sources)
318 });
319
320 let engine = match &client {
321 Some(c) => {
322 println!("Using Etherscan API for enhanced analysis");
323 RiskEngine::with_data_client(c.clone())
324 }
325 None => {
326 println!("Note: Set ETHERSCAN_API_KEY for enhanced analysis");
327 RiskEngine::new()
328 }
329 };
330
331 let mut risk_assessments = Vec::new();
332 let mut pattern_results: Vec<(
333 String,
334 String,
335 Option<crate::compliance::datasource::PatternAnalysis>,
336 )> = Vec::new();
337
338 for (addr, chain) in &addresses {
339 let assessment = engine.assess_address(addr, chain).await?;
340 risk_assessments.push(assessment.clone());
341
342 let pat = if let Some(ref c) = client {
343 c.get_transactions(addr, chain)
344 .await
345 .ok()
346 .map(|txs| crate::compliance::datasource::analyze_patterns(&txs))
347 } else {
348 None
349 };
350 pattern_results.push((addr.clone(), chain.clone(), pat));
351 }
352
353 let content = format_compliance_report(
354 &risk_assessments,
355 &pattern_results,
356 &args.jurisdiction,
357 &args.report_type,
358 );
359
360 std::fs::write(&args.output, &content)?;
361 println!("\nCompliance report saved to: {}", args.output);
362
363 Ok(())
364}
365
366fn resolve_compliance_targets(target: &str) -> anyhow::Result<Vec<(String, String)>> {
368 let path = std::path::Path::new(target);
369 if path.exists() && path.is_file() {
370 let content = std::fs::read_to_string(path)?;
371 let mut out = Vec::new();
372 for line in content.lines() {
373 let line = line.trim();
374 if line.is_empty() || line.starts_with('#') {
375 continue;
376 }
377 let (addr, chain) = parse_address_line(line);
378 out.push((addr.to_string(), chain.to_string()));
379 }
380 Ok(out)
381 } else {
382 let chain = detect_chain(target).unwrap_or_else(|_| "ethereum".to_string());
383 Ok(vec![(target.to_string(), chain)])
384 }
385}
386
387fn parse_address_line(line: &str) -> (&str, &str) {
388 if let Some((addr, rest)) = line.split_once(',') {
389 (addr.trim(), rest.trim())
390 } else {
391 (line, "ethereum")
392 }
393}
394
395fn format_compliance_report(
396 assessments: &[crate::compliance::risk::RiskAssessment],
397 patterns: &[(
398 String,
399 String,
400 Option<crate::compliance::datasource::PatternAnalysis>,
401 )],
402 jurisdiction: &Jurisdiction,
403 report_type: &ReportType,
404) -> String {
405 let mut md = format!(
406 "# Compliance Report\n\n\
407 **Jurisdiction:** {:?} \n\
408 **Report Type:** {:?} \n\
409 **Generated:** {} \n\
410 **Addresses:** {} \n\n",
411 jurisdiction,
412 report_type,
413 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
414 assessments.len()
415 );
416
417 for (i, assessment) in assessments.iter().enumerate() {
418 md.push_str(&format!(
419 "---\n\n## Address {}: `{}`\n\n",
420 i + 1,
421 assessment.address
422 ));
423 md.push_str(&format!(
424 "**Chain:** {} \n**Risk Score:** {:.1}/10 \n**Risk Level:** {} {:?} \n\n",
425 assessment.chain,
426 assessment.overall_score,
427 assessment.risk_level.emoji(),
428 assessment.risk_level
429 ));
430
431 if matches!(report_type, ReportType::Detailed | ReportType::SAR) {
432 md.push_str("### Risk Factor Breakdown\n\n");
433 for f in &assessment.factors {
434 md.push_str(&format!(
435 "- **{}**: {:.1}/10 - {}\n",
436 f.name, f.score, f.description
437 ));
438 }
439 if !assessment.recommendations.is_empty() {
440 md.push_str("\n### Recommendations\n\n");
441 for r in &assessment.recommendations {
442 md.push_str(&format!("- {}\n", r));
443 }
444 }
445 }
446
447 if let Some((_, _, Some(pat))) = patterns
448 .iter()
449 .find(|(a, c, _)| a == &assessment.address && c == &assessment.chain)
450 {
451 md.push_str("\n### Pattern Analysis\n\n");
452 md.push_str(&format!(
453 "- Total transactions: {}\n",
454 pat.total_transactions
455 ));
456 md.push_str(&format!("- Velocity: {:.2} tx/day\n", pat.velocity_score));
457 md.push_str(&format!(
458 "- Structuring detected: {}\n",
459 pat.structuring_detected
460 ));
461 md.push_str(&format!(
462 "- Round number pattern: {}\n",
463 pat.round_number_pattern
464 ));
465 md.push_str(&format!(
466 "- Unusual hour transactions: {}\n",
467 pat.unusual_hours
468 ));
469 }
470 }
471
472 md.push_str(&crate::display::report::report_footer());
473 md
474}
475
476fn detect_chain(address: &str) -> anyhow::Result<String> {
478 if address.starts_with("0x") && address.len() == 42 {
479 Ok("ethereum".to_string())
481 } else if address.len() == 32 || address.len() == 44 {
482 Ok("solana".to_string())
484 } else if address.starts_with("T") && address.len() == 34 {
485 Ok("tron".to_string())
487 } else if address.starts_with("bc1") || address.starts_with("1") || address.starts_with("3") {
488 Ok("bitcoin".to_string())
490 } else {
491 anyhow::bail!("Could not auto-detect chain from address: {}", address)
492 }
493}
494
495#[cfg(test)]
500mod tests {
501 use super::*;
502
503 #[test]
504 fn test_detect_chain_ethereum() {
505 let result = detect_chain("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
506 assert!(result.is_ok());
507 assert_eq!(result.unwrap(), "ethereum");
508 }
509
510 #[test]
511 fn test_detect_chain_solana_short() {
512 let result = detect_chain("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC");
514 assert!(result.is_ok());
515 assert_eq!(result.unwrap(), "solana");
516 }
517
518 #[test]
519 fn test_detect_chain_solana_long() {
520 let result = detect_chain("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
522 assert!(result.is_ok());
523 assert_eq!(result.unwrap(), "solana");
524 }
525
526 #[test]
527 fn test_detect_chain_tron() {
528 let result = detect_chain("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf");
529 assert!(result.is_ok());
530 assert_eq!(result.unwrap(), "tron");
531 }
532
533 #[test]
534 fn test_detect_chain_bitcoin_bech32() {
535 let result = detect_chain("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
536 assert!(result.is_ok());
537 assert_eq!(result.unwrap(), "bitcoin");
538 }
539
540 #[test]
541 fn test_detect_chain_bitcoin_p2pkh() {
542 let result = detect_chain("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2");
543 assert!(result.is_ok());
544 assert_eq!(result.unwrap(), "bitcoin");
545 }
546
547 #[test]
548 fn test_detect_chain_bitcoin_p2sh() {
549 let result = detect_chain("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy");
550 assert!(result.is_ok());
551 assert_eq!(result.unwrap(), "bitcoin");
552 }
553
554 #[test]
555 fn test_parse_address_line_with_chain() {
556 let (addr, chain) = parse_address_line("0xabc, polygon");
557 assert_eq!(addr, "0xabc");
558 assert_eq!(chain, "polygon");
559 }
560
561 #[test]
562 fn test_parse_address_line_no_chain() {
563 let (addr, chain) = parse_address_line("0xabc");
564 assert_eq!(addr, "0xabc");
565 assert_eq!(chain, "ethereum");
566 }
567
568 #[test]
569 fn test_resolve_compliance_targets_single_address() {
570 let result =
571 resolve_compliance_targets("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2").unwrap();
572 assert_eq!(result.len(), 1);
573 assert_eq!(result[0].0, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
574 assert_eq!(result[0].1, "ethereum");
575 }
576
577 #[test]
578 fn test_resolve_compliance_targets_from_file() {
579 let dir = tempfile::tempdir().unwrap();
580 let path = dir.path().join("addresses.txt");
581 std::fs::write(
582 &path,
583 "0xabc123, ethereum\n0xdef456, polygon\n# comment\n\n0x789,solana",
584 )
585 .unwrap();
586 let result = resolve_compliance_targets(path.to_str().unwrap()).unwrap();
587 assert_eq!(result.len(), 3);
588 assert_eq!(result[0].0, "0xabc123");
589 assert_eq!(result[0].1, "ethereum");
590 assert_eq!(result[1].0, "0xdef456");
591 assert_eq!(result[1].1, "polygon");
592 assert_eq!(result[2].0, "0x789");
593 assert_eq!(result[2].1, "solana");
594 }
595
596 #[test]
597 fn test_detect_chain_unknown() {
598 let result = detect_chain("unknown_address_format_xyz");
599 assert!(result.is_err());
600 }
601
602 #[tokio::test]
603 async fn test_handle_risk_no_api_key() {
604 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
606 let args = RiskArgs {
607 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
608 chain: Some("ethereum".to_string()),
609 format: OutputFormat::Table,
610 detailed: false,
611 output: None,
612 };
613 let result = handle_risk(args).await;
614 assert!(result.is_ok());
615 }
616
617 #[tokio::test]
618 async fn test_handle_risk_json_format() {
619 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
620 let args = RiskArgs {
621 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
622 chain: Some("ethereum".to_string()),
623 format: OutputFormat::Json,
624 detailed: true,
625 output: None,
626 };
627 let result = handle_risk(args).await;
628 assert!(result.is_ok());
629 }
630
631 #[tokio::test]
632 async fn test_handle_risk_with_export() {
633 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
634 let temp = tempfile::NamedTempFile::new().unwrap();
635 let path = temp.path().to_string_lossy().to_string();
636 let args = RiskArgs {
637 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
638 chain: Some("ethereum".to_string()),
639 format: OutputFormat::Table,
640 detailed: false,
641 output: Some(path.clone()),
642 };
643 let result = handle_risk(args).await;
644 assert!(result.is_ok());
645 assert!(std::path::Path::new(&path).exists());
646 }
647
648 #[tokio::test]
649 async fn test_handle_risk_export_markdown_extension() {
650 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
651 let dir = tempfile::tempdir().unwrap();
652 let path = dir.path().join("report.md");
653 let path_str = path.to_string_lossy().to_string();
654 let args = RiskArgs {
655 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
656 chain: Some("ethereum".to_string()),
657 format: OutputFormat::Table,
658 detailed: false,
659 output: Some(path_str.clone()),
660 };
661 let result = handle_risk(args).await;
662 assert!(result.is_ok());
663 let content = std::fs::read_to_string(&path).unwrap();
664 assert!(content.contains("Risk") || content.contains("risk"));
665 }
666
667 #[tokio::test]
668 async fn test_handle_risk_export_yaml_extension() {
669 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
670 let dir = tempfile::tempdir().unwrap();
671 let path = dir.path().join("report.yaml");
672 let path_str = path.to_string_lossy().to_string();
673 let args = RiskArgs {
674 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
675 chain: Some("ethereum".to_string()),
676 format: OutputFormat::Table,
677 detailed: false,
678 output: Some(path_str.clone()),
679 };
680 let result = handle_risk(args).await;
681 assert!(result.is_ok());
682 let content = std::fs::read_to_string(&path).unwrap();
683 assert!(content.contains("address") || content.contains("chain"));
684 }
685
686 #[tokio::test]
687 async fn test_handle_risk_auto_detect_chain() {
688 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
689 let args = RiskArgs {
690 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
691 chain: None, format: OutputFormat::Table,
693 detailed: false,
694 output: None,
695 };
696 let result = handle_risk(args).await;
697 assert!(result.is_ok());
698 }
699
700 #[tokio::test]
701 async fn test_handle_trace_no_api_key() {
702 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
703 let args = TraceArgs {
704 tx_hash: "0xabc123".to_string(),
705 depth: 3,
706 flag_suspicious: true,
707 format: OutputFormat::Table,
708 };
709 let result = handle_trace(args).await;
710 assert!(result.is_ok()); }
712
713 #[tokio::test]
714 async fn test_handle_analyze_no_api_key() {
715 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
716 let args = AnalyzeArgs {
717 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
718 patterns: vec![PatternType::Structuring, PatternType::Layering],
719 range: "30d".to_string(),
720 format: OutputFormat::Table,
721 };
722 let result = handle_analyze(args).await;
723 assert!(result.is_ok());
724 }
725
726 #[tokio::test]
727 async fn test_handle_compliance_report() {
728 let args = ComplianceReportArgs {
729 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
730 jurisdiction: Jurisdiction::US,
731 report_type: ReportType::Summary,
732 output: "/tmp/test_compliance.json".to_string(),
733 };
734 let result = handle_compliance_report(args).await;
735 assert!(result.is_ok()); }
737
738 #[tokio::test]
739 async fn test_handle_compliance_report_eu_detailed() {
740 let args = ComplianceReportArgs {
741 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
742 jurisdiction: Jurisdiction::EU,
743 report_type: ReportType::Detailed,
744 output: "/tmp/test_compliance_eu.json".to_string(),
745 };
746 let result = handle_compliance_report(args).await;
747 assert!(result.is_ok());
748 }
749
750 #[tokio::test]
751 async fn test_handle_compliance_report_uk_sar() {
752 let args = ComplianceReportArgs {
753 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
754 jurisdiction: Jurisdiction::UK,
755 report_type: ReportType::SAR,
756 output: "/tmp/test_compliance_uk.json".to_string(),
757 };
758 let result = handle_compliance_report(args).await;
759 assert!(result.is_ok());
760 }
761
762 #[tokio::test]
763 async fn test_handle_compliance_report_singapore_travel_rule() {
764 let args = ComplianceReportArgs {
765 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
766 jurisdiction: Jurisdiction::Singapore,
767 report_type: ReportType::TravelRule,
768 output: "/tmp/test_compliance_sg.json".to_string(),
769 };
770 let result = handle_compliance_report(args).await;
771 assert!(result.is_ok());
772 }
773
774 #[tokio::test]
775 async fn test_handle_compliance_report_switzerland() {
776 let args = ComplianceReportArgs {
777 target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
778 jurisdiction: Jurisdiction::Switzerland,
779 report_type: ReportType::Summary,
780 output: "/tmp/test_compliance_ch.json".to_string(),
781 };
782 let result = handle_compliance_report(args).await;
783 assert!(result.is_ok());
784 }
785
786 #[tokio::test]
787 async fn test_handle_risk_yaml_format() {
788 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
789 let args = RiskArgs {
790 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
791 chain: Some("ethereum".to_string()),
792 format: OutputFormat::Yaml,
793 detailed: false,
794 output: None,
795 };
796 let result = handle_risk(args).await;
797 assert!(result.is_ok());
798 }
799
800 fn mock_etherscan_response(txs: &[serde_json::Value]) -> String {
805 serde_json::json!({
806 "status": "1",
807 "message": "OK",
808 "result": txs
809 })
810 .to_string()
811 }
812
813 fn make_mock_client(base_url: &str) -> BlockchainDataClient {
814 let sources = DataSources::new("test_api_key".to_string());
815 BlockchainDataClient::with_base_url(sources, base_url)
816 }
817
818 #[tokio::test]
819 async fn test_handle_risk_with_api_client() {
820 let mut server = mockito::Server::new_async().await;
821 let _mock = server
822 .mock("GET", mockito::Matcher::Any)
823 .with_status(200)
824 .with_body(mock_etherscan_response(&[]))
825 .create_async()
826 .await;
827
828 let client = make_mock_client(&server.url());
829 let args = RiskArgs {
830 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
831 chain: Some("ethereum".to_string()),
832 format: OutputFormat::Table,
833 detailed: true,
834 output: None,
835 };
836 let result = handle_risk_with_client(args, Some(client)).await;
837 assert!(result.is_ok());
838 }
839
840 #[tokio::test]
841 async fn test_handle_risk_with_api_client_json_export() {
842 let mut server = mockito::Server::new_async().await;
843 let _mock = server
844 .mock("GET", mockito::Matcher::Any)
845 .with_status(200)
846 .with_body(mock_etherscan_response(&[]))
847 .create_async()
848 .await;
849
850 let client = make_mock_client(&server.url());
851 let tmp = tempfile::NamedTempFile::new().unwrap();
852 let path = tmp.path().to_string_lossy().to_string();
853
854 let args = RiskArgs {
855 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
856 chain: Some("ethereum".to_string()),
857 format: OutputFormat::Table,
858 detailed: false,
859 output: Some(path.clone()),
860 };
861 let result = handle_risk_with_client(args, Some(client)).await;
862 assert!(result.is_ok());
863 assert!(std::path::Path::new(&path).exists());
864 }
865
866 #[tokio::test]
867 async fn test_handle_trace_with_api_client() {
868 let mut server = mockito::Server::new_async().await;
869 let _mock = server
870 .mock("GET", mockito::Matcher::Any)
871 .with_status(200)
872 .with_body(mock_etherscan_response(&[serde_json::json!({
873 "hash": "0xabc",
874 "from": "0x111",
875 "to": "0x222",
876 "value": "1000000000000000000",
877 "timeStamp": "1700000000",
878 "blockNumber": "18000000",
879 "gasUsed": "21000",
880 "gasPrice": "50000000000",
881 "isError": "0",
882 "input": "0x"
883 })]))
884 .create_async()
885 .await;
886
887 let client = make_mock_client(&server.url());
888 let args = TraceArgs {
889 tx_hash: "0xabc123def456".to_string(),
890 depth: 2,
891 flag_suspicious: true,
892 format: OutputFormat::Table,
893 };
894 let result = handle_trace_with_client(args, Some(client)).await;
895 assert!(result.is_ok());
896 }
897
898 #[tokio::test]
899 async fn test_handle_trace_with_api_client_connection_refused() {
900 let client = make_mock_client("http://127.0.0.1:1");
902 let args = TraceArgs {
903 tx_hash: "0xabc123".to_string(),
904 depth: 2,
905 flag_suspicious: false,
906 format: OutputFormat::Table,
907 };
908 let result = handle_trace_with_client(args, Some(client)).await;
909 assert!(result.is_ok()); }
911
912 #[tokio::test]
913 async fn test_handle_trace_with_api_client_error() {
914 let mut server = mockito::Server::new_async().await;
915 let _mock = server
916 .mock("GET", mockito::Matcher::Any)
917 .with_status(200)
918 .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
919 .create_async()
920 .await;
921
922 let client = make_mock_client(&server.url());
923 let args = TraceArgs {
924 tx_hash: "0xabc123def456".to_string(),
925 depth: 3,
926 flag_suspicious: false,
927 format: OutputFormat::Table,
928 };
929 let result = handle_trace_with_client(args, Some(client)).await;
931 assert!(result.is_ok());
932 }
933
934 #[tokio::test]
935 async fn test_handle_analyze_with_api_client() {
936 let mut server = mockito::Server::new_async().await;
937 let _mock = server
938 .mock("GET", mockito::Matcher::Any)
939 .with_status(200)
940 .with_body(mock_etherscan_response(&[serde_json::json!({
941 "hash": "0xabc",
942 "from": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
943 "to": "0x222",
944 "value": "1000000000000000000",
945 "timeStamp": "1700000000",
946 "blockNumber": "18000000",
947 "gasUsed": "21000",
948 "gasPrice": "50000000000",
949 "isError": "0",
950 "input": "0x"
951 })]))
952 .create_async()
953 .await;
954
955 let client = make_mock_client(&server.url());
956 let args = AnalyzeArgs {
957 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
958 patterns: vec![PatternType::Structuring, PatternType::Velocity],
959 range: "30d".to_string(),
960 format: OutputFormat::Table,
961 };
962 let result = handle_analyze_with_client(args, Some(client)).await;
963 assert!(result.is_ok());
964 }
965
966 #[tokio::test]
967 async fn test_handle_analyze_with_api_client_error() {
968 let mut server = mockito::Server::new_async().await;
969 let _mock = server
970 .mock("GET", mockito::Matcher::Any)
971 .with_status(200)
972 .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
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::Layering],
980 range: "7d".to_string(),
981 format: OutputFormat::Table,
982 };
983 let result = handle_analyze_with_client(args, Some(client)).await;
985 assert!(result.is_ok());
986 }
987
988 #[tokio::test]
989 async fn test_handle_analyze_with_detect_chain_failure() {
990 let mut server = mockito::Server::new_async().await;
991 let _mock = server
992 .mock("GET", mockito::Matcher::Any)
993 .with_status(200)
994 .with_body(mock_etherscan_response(&[]))
995 .create_async()
996 .await;
997
998 let client = make_mock_client(&server.url());
999 let args = AnalyzeArgs {
1001 address: "unknown_format_addr".to_string(),
1002 patterns: vec![PatternType::Integration],
1003 range: "1y".to_string(),
1004 format: OutputFormat::Json,
1005 };
1006 let result = handle_analyze_with_client(args, Some(client)).await;
1007 assert!(result.is_ok());
1008 }
1009
1010 #[tokio::test]
1011 async fn test_handle_risk_markdown_detailed() {
1012 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
1013 let args = RiskArgs {
1014 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1015 chain: Some("ethereum".to_string()),
1016 format: OutputFormat::Markdown,
1017 detailed: true,
1018 output: None,
1019 };
1020 let result = handle_risk(args).await;
1021 assert!(result.is_ok());
1022 }
1023
1024 #[tokio::test]
1025 async fn test_handle_trace_no_flag_suspicious() {
1026 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
1027 let args = TraceArgs {
1028 tx_hash: "0xdef456".to_string(),
1029 depth: 5,
1030 flag_suspicious: false,
1031 format: OutputFormat::Json,
1032 };
1033 let result = handle_trace(args).await;
1034 assert!(result.is_ok());
1035 }
1036
1037 #[tokio::test]
1038 async fn test_handle_analyze_all_patterns() {
1039 unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
1040 let args = AnalyzeArgs {
1041 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1042 patterns: vec![
1043 PatternType::Structuring,
1044 PatternType::Layering,
1045 PatternType::Integration,
1046 PatternType::Velocity,
1047 PatternType::RoundNumbers,
1048 ],
1049 range: "6m".to_string(),
1050 format: OutputFormat::Json,
1051 };
1052 let result = handle_analyze(args).await;
1053 assert!(result.is_ok());
1054 }
1055
1056 #[test]
1057 fn test_pattern_type_debug() {
1058 let patterns = [
1059 PatternType::Structuring,
1060 PatternType::Layering,
1061 PatternType::Integration,
1062 PatternType::Velocity,
1063 PatternType::RoundNumbers,
1064 ];
1065 for p in &patterns {
1066 let debug = format!("{:?}", p);
1067 assert!(!debug.is_empty());
1068 }
1069 }
1070
1071 #[test]
1072 fn test_jurisdiction_debug() {
1073 let jurisdictions = [
1074 Jurisdiction::US,
1075 Jurisdiction::EU,
1076 Jurisdiction::UK,
1077 Jurisdiction::Switzerland,
1078 Jurisdiction::Singapore,
1079 ];
1080 for j in &jurisdictions {
1081 let debug = format!("{:?}", j);
1082 assert!(!debug.is_empty());
1083 }
1084 }
1085
1086 #[test]
1087 fn test_report_type_debug() {
1088 let types = [
1089 ReportType::Summary,
1090 ReportType::Detailed,
1091 ReportType::SAR,
1092 ReportType::TravelRule,
1093 ];
1094 for t in &types {
1095 let debug = format!("{:?}", t);
1096 assert!(!debug.is_empty());
1097 }
1098 }
1099}