Skip to main content

scope/cli/
compliance.rs

1//! CLI commands for compliance and risk analysis
2
3use 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    /// Assess risk for a blockchain address
11    #[command(name = "risk")]
12    Risk(RiskArgs),
13
14    /// Trace transaction taint through multiple hops
15    #[command(name = "trace")]
16    Trace(TraceArgs),
17
18    /// Detect suspicious transaction patterns
19    #[command(name = "analyze")]
20    Analyze(AnalyzeArgs),
21
22    /// Generate compliance report
23    #[command(name = "compliance-report")]
24    ComplianceReport(ComplianceReportArgs),
25}
26
27#[derive(Debug, Args)]
28pub struct RiskArgs {
29    /// Address to assess
30    #[arg(value_name = "ADDRESS")]
31    pub address: String,
32
33    /// Blockchain (auto-detected if not specified)
34    #[arg(short, long)]
35    pub chain: Option<String>,
36
37    /// Output format
38    #[arg(short, long, value_enum, default_value = "table")]
39    pub format: OutputFormat,
40
41    /// Include detailed factor breakdown
42    #[arg(long)]
43    pub detailed: bool,
44
45    /// Export to file
46    #[arg(short, long)]
47    pub output: Option<String>,
48}
49
50#[derive(Debug, Args)]
51pub struct TraceArgs {
52    /// Transaction hash to trace
53    #[arg(value_name = "TX_HASH")]
54    pub tx_hash: String,
55
56    /// Trace depth (hops to follow)
57    #[arg(short, long, default_value = "3")]
58    pub depth: u32,
59
60    /// Flag suspicious addresses
61    #[arg(long)]
62    pub flag_suspicious: bool,
63
64    /// Output format
65    #[arg(short, long, value_enum, default_value = "table")]
66    pub format: OutputFormat,
67}
68
69#[derive(Debug, Args)]
70pub struct AnalyzeArgs {
71    /// Address to analyze
72    #[arg(value_name = "ADDRESS")]
73    pub address: String,
74
75    /// Pattern types to detect
76    #[arg(long, value_enum, default_values = &["structuring", "layering", "integration"])]
77    pub patterns: Vec<PatternType>,
78
79    /// Time range (e.g., "30d", "6m", "1y")
80    #[arg(short, long, default_value = "30d")]
81    pub range: String,
82
83    /// Output format
84    #[arg(short, long, value_enum, default_value = "table")]
85    pub format: OutputFormat,
86}
87
88#[derive(Debug, Args)]
89pub struct ComplianceReportArgs {
90    /// Address or addresses file
91    #[arg(value_name = "TARGET")]
92    pub target: String,
93
94    /// Jurisdiction for compliance
95    #[arg(short, long, value_enum)]
96    pub jurisdiction: Jurisdiction,
97
98    /// Report type
99    #[arg(short, long, value_enum, default_value = "summary")]
100    pub report_type: ReportType,
101
102    /// Output file
103    #[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, // Suspicious Activity Report
130    TravelRule,
131}
132
133/// Handle risk assessment command
134pub async fn handle_risk(args: RiskArgs) -> anyhow::Result<()> {
135    handle_risk_with_client(args, None).await
136}
137
138/// Handle risk assessment with an optional pre-built client (for testability).
139pub async fn handle_risk_with_client(
140    args: RiskArgs,
141    client: Option<BlockchainDataClient>,
142) -> anyhow::Result<()> {
143    // Auto-detect chain if not specified
144    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        // Try to load API key from environment
159        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    // Format and display output
176    let output = format_risk_report(&assessment, args.format, args.detailed);
177    println!("{}", output);
178
179    // Export to file if requested (respects format: json, yaml, markdown from path extension)
180    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
200/// Handle transaction tracing command
201pub async fn handle_trace(args: TraceArgs) -> anyhow::Result<()> {
202    handle_trace_with_client(args, None).await
203}
204
205/// Handle transaction tracing with an optional pre-built client (for testability).
206pub 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
252/// Handle pattern analysis command
253pub async fn handle_analyze(args: AnalyzeArgs) -> anyhow::Result<()> {
254    handle_analyze_with_client(args, None).await
255}
256
257/// Handle pattern analysis with an optional pre-built client (for testability).
258pub 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        // Auto-detect chain
277        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
305/// Handle compliance report generation
306pub 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
370/// Resolve target to (address, chain) pairs. Target can be a single address or path to file.
371fn 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
480/// Auto-detect blockchain from address format
481fn detect_chain(address: &str) -> anyhow::Result<String> {
482    if address.starts_with("0x") && address.len() == 42 {
483        // Could be any EVM chain, default to Ethereum
484        Ok("ethereum".to_string())
485    } else if address.len() == 32 || address.len() == 44 {
486        // Solana base58
487        Ok("solana".to_string())
488    } else if address.starts_with("T") && address.len() == 34 {
489        // Tron
490        Ok("tron".to_string())
491    } else if address.starts_with("bc1") || address.starts_with("1") || address.starts_with("3") {
492        // Bitcoin
493        Ok("bitcoin".to_string())
494    } else {
495        anyhow::bail!("Could not auto-detect chain from address: {}", address)
496    }
497}
498
499// ============================================================================
500// Unit Tests
501// ============================================================================
502
503#[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        // Solana 32-char address
517        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        // Solana 44-char address
525        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        // Should work without API key (basic scoring)
609        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, // auto-detect
696            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()); // No API key → prints message, doesn't error
715    }
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()); // Not yet implemented → prints message
740    }
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    // ========================================================================
805    // Tests with injected mockito client (covers API-present paths)
806    // ========================================================================
807
808    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        // Use invalid URL to trigger connection error so trace_transaction returns Err
905        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()); // Handler catches error, prints to stderr
914    }
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        // Error path: should print error but return Ok
934        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        // Error path in analyze
988        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        // Address that won't auto-detect → falls back to "ethereum"
1004        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        // Summary report should not include risk factor breakdown
1140        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        // Detailed report should include risk factor breakdown
1191        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        // Check for pattern analysis section
1240        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}