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!("  ⚠ 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
306/// Handle compliance report generation
307pub 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
371/// Resolve target to (address, chain) pairs. Target can be a single address or path to file.
372fn 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
481/// Auto-detect blockchain from address format
482fn detect_chain(address: &str) -> anyhow::Result<String> {
483    if address.starts_with("0x") && address.len() == 42 {
484        // Could be any EVM chain, default to Ethereum
485        Ok("ethereum".to_string())
486    } else if address.len() == 32 || address.len() == 44 {
487        // Solana base58
488        Ok("solana".to_string())
489    } else if address.starts_with("T") && address.len() == 34 {
490        // Tron
491        Ok("tron".to_string())
492    } else if address.starts_with("bc1") || address.starts_with("1") || address.starts_with("3") {
493        // Bitcoin
494        Ok("bitcoin".to_string())
495    } else {
496        anyhow::bail!("Could not auto-detect chain from address: {}", address)
497    }
498}
499
500// ============================================================================
501// Unit Tests
502// ============================================================================
503
504#[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        // Solana 32-char address
518        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        // Solana 44-char address
526        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        // Should work without API key (basic scoring)
610        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, // auto-detect
697            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()); // No API key → prints message, doesn't error
716    }
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()); // Not yet implemented → prints message
741    }
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    // ========================================================================
806    // Tests with injected mockito client (covers API-present paths)
807    // ========================================================================
808
809    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        // Use invalid URL to trigger connection error so trace_transaction returns Err
906        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()); // Handler catches error, prints to stderr
915    }
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        // Error path: should print error but return Ok
935        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        // Error path in analyze
989        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        // Address that won't auto-detect → falls back to "ethereum"
1005        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        // Summary report should not include risk factor breakdown
1141        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        // Detailed report should include risk factor breakdown
1192        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        // Check for pattern analysis section
1241        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}