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