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)]
28#[command(after_help = "\x1b[1mExamples:\x1b[0m
29  scope compliance risk 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2
30  scope compliance risk @main-wallet                      \x1b[2m# address book shortcut\x1b[0m
31  scope compliance risk 0x742d... --detailed --format json
32  scope compliance risk 0x742d... --output risk_report.json")]
33pub struct RiskArgs {
34    /// Address to assess. Use @label for address book shortcut.
35    #[arg(value_name = "ADDRESS")]
36    pub address: String,
37
38    /// Blockchain (auto-detected if not specified)
39    #[arg(short, long)]
40    pub chain: Option<String>,
41
42    /// Output format
43    #[arg(short, long, value_enum, default_value = "table")]
44    pub format: OutputFormat,
45
46    /// Include detailed factor breakdown
47    #[arg(long)]
48    pub detailed: bool,
49
50    /// Export to file
51    #[arg(short, long)]
52    pub output: Option<String>,
53}
54
55#[derive(Debug, Args)]
56#[command(after_help = "\x1b[1mExamples:\x1b[0m
57  scope compliance trace 0xabc123def456...
58  scope compliance trace 0xabc123... --depth 5 --flag-suspicious
59  scope compliance trace 0xabc123... --format json")]
60pub struct TraceArgs {
61    /// Transaction hash to trace
62    #[arg(value_name = "TX_HASH")]
63    pub tx_hash: String,
64
65    /// Trace depth (hops to follow)
66    #[arg(short, long, default_value = "3")]
67    pub depth: u32,
68
69    /// Flag suspicious addresses
70    #[arg(long)]
71    pub flag_suspicious: bool,
72
73    /// Output format
74    #[arg(short, long, value_enum, default_value = "table")]
75    pub format: OutputFormat,
76}
77
78#[derive(Debug, Args)]
79#[command(after_help = "\x1b[1mExamples:\x1b[0m
80  scope compliance analyze 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2
81  scope compliance analyze 0x742d... --patterns structuring,layering --range 90d
82  scope compliance analyze 0x742d... --format json")]
83pub struct AnalyzeArgs {
84    /// Address to analyze
85    #[arg(value_name = "ADDRESS")]
86    pub address: String,
87
88    /// Pattern types to detect
89    #[arg(long, value_enum, default_values = &["structuring", "layering", "integration"])]
90    pub patterns: Vec<PatternType>,
91
92    /// Time range (e.g., "30d", "6m", "1y")
93    #[arg(short, long, default_value = "30d")]
94    pub range: String,
95
96    /// Output format
97    #[arg(short, long, value_enum, default_value = "table")]
98    pub format: OutputFormat,
99}
100
101#[derive(Debug, Args)]
102#[command(after_help = "\x1b[1mExamples:\x1b[0m
103  scope compliance compliance-report 0x742d... -j us -o report.json
104  scope compliance compliance-report addresses.txt -j eu --report-type detailed -o report.json")]
105pub struct ComplianceReportArgs {
106    /// Address or addresses file
107    #[arg(value_name = "TARGET")]
108    pub target: String,
109
110    /// Jurisdiction for compliance
111    #[arg(short, long, value_enum)]
112    pub jurisdiction: Jurisdiction,
113
114    /// Report type
115    #[arg(short, long, value_enum, default_value = "summary")]
116    pub report_type: ReportType,
117
118    /// Output file
119    #[arg(short, long, required = true)]
120    pub output: String,
121}
122
123#[derive(Clone, Copy, Debug, clap::ValueEnum)]
124pub enum PatternType {
125    Structuring,
126    Layering,
127    Integration,
128    Velocity,
129    RoundNumbers,
130}
131
132#[derive(Clone, Copy, Debug, clap::ValueEnum)]
133pub enum Jurisdiction {
134    US,
135    EU,
136    UK,
137    Switzerland,
138    Singapore,
139}
140
141#[derive(Clone, Copy, Debug, clap::ValueEnum)]
142pub enum ReportType {
143    Summary,
144    Detailed,
145    SAR, // Suspicious Activity Report
146    TravelRule,
147}
148
149/// Handle risk assessment command
150pub async fn handle_risk(args: RiskArgs) -> anyhow::Result<()> {
151    handle_risk_with_client(args, None).await
152}
153
154/// Handle risk assessment with an optional pre-built client (for testability).
155pub async fn handle_risk_with_client(
156    args: RiskArgs,
157    client: Option<BlockchainDataClient>,
158) -> anyhow::Result<()> {
159    // Auto-detect chain if not specified
160    let chain = match args.chain {
161        Some(c) => c,
162        None => detect_chain(&args.address)?,
163    };
164
165    let sp = crate::cli::progress::Spinner::new(&format!(
166        "Assessing risk for {} on {}...",
167        args.address, chain
168    ));
169
170    let engine = if let Some(c) = client {
171        sp.set_message("Using Etherscan API for enhanced analysis...");
172        RiskEngine::with_data_client(c)
173    } else {
174        // Try to load API key from environment
175        let etherscan_key = std::env::var("ETHERSCAN_API_KEY").ok();
176
177        if let Some(key) = etherscan_key {
178            let sources = DataSources::new(key);
179            let client = BlockchainDataClient::new(sources);
180            sp.set_message("Using Etherscan API for enhanced analysis...");
181            RiskEngine::with_data_client(client)
182        } else {
183            eprintln!("Note: Set ETHERSCAN_API_KEY for enhanced analysis");
184            RiskEngine::new()
185        }
186    };
187
188    let assessment = engine.assess_address(&args.address, &chain).await?;
189    sp.finish("Risk assessment complete.");
190
191    // Format and display output
192    let output = format_risk_report(&assessment, args.format, args.detailed);
193    println!("{}", output);
194
195    // Export to file if requested (respects format: json, yaml, markdown from path extension)
196    if let Some(path) = args.output {
197        let content = match std::path::Path::new(&path)
198            .extension()
199            .and_then(|e| e.to_str())
200        {
201            Some("md") | Some("markdown") => {
202                format_risk_report(&assessment, OutputFormat::Markdown, args.detailed)
203            }
204            Some("yaml") | Some("yml") => {
205                format_risk_report(&assessment, OutputFormat::Yaml, args.detailed)
206            }
207            _ => format_risk_report(&assessment, OutputFormat::Json, args.detailed),
208        };
209        std::fs::write(&path, content)?;
210        println!("\nReport exported to: {}", path);
211    }
212
213    Ok(())
214}
215
216/// Handle transaction tracing command
217pub async fn handle_trace(args: TraceArgs) -> anyhow::Result<()> {
218    handle_trace_with_client(args, None).await
219}
220
221/// Handle transaction tracing with an optional pre-built client (for testability).
222pub async fn handle_trace_with_client(
223    args: TraceArgs,
224    client: Option<BlockchainDataClient>,
225) -> anyhow::Result<()> {
226    println!("Tracing transaction {}...", args.tx_hash);
227    println!("Depth: {} hops", args.depth);
228
229    if args.flag_suspicious {
230        println!("Flagging suspicious addresses enabled");
231    }
232
233    let resolved_client = if let Some(c) = client {
234        Some(c)
235    } else {
236        std::env::var("ETHERSCAN_API_KEY").ok().map(|key| {
237            let sources = DataSources::new(key);
238            BlockchainDataClient::new(sources)
239        })
240    };
241
242    if let Some(client) = resolved_client {
243        match client.trace_transaction(&args.tx_hash, args.depth).await {
244            Ok(trace) => {
245                println!("\nTransaction Trace");
246                println!("=================");
247                println!("Root: {}", trace.root_hash);
248                println!("Hops: {}", trace.hops.len());
249
250                for hop in &trace.hops {
251                    println!(
252                        "  Depth {}: {} ({} ETH)",
253                        hop.depth, hop.address, hop.amount
254                    );
255                }
256            }
257            Err(e) => {
258                eprintln!("Error tracing transaction: {}", e);
259            }
260        }
261    } else {
262        println!("Set ETHERSCAN_API_KEY to enable transaction tracing");
263    }
264
265    Ok(())
266}
267
268/// Handle pattern analysis command
269pub async fn handle_analyze(args: AnalyzeArgs) -> anyhow::Result<()> {
270    handle_analyze_with_client(args, None).await
271}
272
273/// Handle pattern analysis with an optional pre-built client (for testability).
274pub async fn handle_analyze_with_client(
275    args: AnalyzeArgs,
276    client: Option<BlockchainDataClient>,
277) -> anyhow::Result<()> {
278    println!("Analyzing patterns for {}...", args.address);
279    println!("Patterns: {:?}", args.patterns);
280    println!("Time range: {}", args.range);
281
282    let resolved_client = if let Some(c) = client {
283        Some(c)
284    } else {
285        std::env::var("ETHERSCAN_API_KEY").ok().map(|key| {
286            let sources = DataSources::new(key);
287            BlockchainDataClient::new(sources)
288        })
289    };
290
291    if let Some(client) = resolved_client {
292        // Auto-detect chain
293        let chain = match detect_chain(&args.address) {
294            Ok(c) => c,
295            Err(_) => "ethereum".to_string(),
296        };
297
298        match client.get_transactions(&args.address, &chain).await {
299            Ok(txs) => {
300                let analysis = analyze_patterns(&txs);
301
302                println!("\nPattern Analysis Results");
303                println!("========================");
304                println!("Total transactions: {}", analysis.total_transactions);
305                println!("Velocity: {:.2} tx/day", analysis.velocity_score);
306                println!("Structuring detected: {}", analysis.structuring_detected);
307                println!("Round number pattern: {}", analysis.round_number_pattern);
308                println!("Unusual hour transactions: {}", analysis.unusual_hours);
309            }
310            Err(e) => {
311                eprintln!("  ⚠ Could not fetch transactions (use -v for details)");
312                tracing::debug!("Error fetching transactions: {}", e);
313            }
314        }
315    } else {
316        println!("Set ETHERSCAN_API_KEY to enable pattern analysis");
317    }
318
319    Ok(())
320}
321
322/// Handle compliance report generation
323pub async fn handle_compliance_report(args: ComplianceReportArgs) -> anyhow::Result<()> {
324    let addresses = resolve_compliance_targets(&args.target)?;
325    if addresses.is_empty() {
326        anyhow::bail!("No addresses to analyze");
327    }
328
329    println!(
330        "Generating {:?} compliance report for {} address(es) ({:?} jurisdiction)...",
331        args.report_type,
332        addresses.len(),
333        args.jurisdiction
334    );
335
336    let client = std::env::var("ETHERSCAN_API_KEY").ok().map(|key| {
337        let sources = DataSources::new(key);
338        BlockchainDataClient::new(sources)
339    });
340
341    let engine = match &client {
342        Some(c) => {
343            println!("Using Etherscan API for enhanced analysis");
344            RiskEngine::with_data_client(c.clone())
345        }
346        None => {
347            println!("Note: Set ETHERSCAN_API_KEY for enhanced analysis");
348            RiskEngine::new()
349        }
350    };
351
352    let mut risk_assessments = Vec::new();
353    let mut pattern_results: Vec<(
354        String,
355        String,
356        Option<crate::compliance::datasource::PatternAnalysis>,
357    )> = Vec::new();
358
359    for (addr, chain) in &addresses {
360        let assessment = engine.assess_address(addr, chain).await?;
361        risk_assessments.push(assessment.clone());
362
363        let pat = if let Some(ref c) = client {
364            c.get_transactions(addr, chain)
365                .await
366                .ok()
367                .map(|txs| crate::compliance::datasource::analyze_patterns(&txs))
368        } else {
369            None
370        };
371        pattern_results.push((addr.clone(), chain.clone(), pat));
372    }
373
374    let content = format_compliance_report(
375        &risk_assessments,
376        &pattern_results,
377        &args.jurisdiction,
378        &args.report_type,
379    );
380
381    std::fs::write(&args.output, &content)?;
382    println!("\nCompliance report saved to: {}", args.output);
383
384    Ok(())
385}
386
387/// Resolve target to (address, chain) pairs. Target can be a single address or path to file.
388fn resolve_compliance_targets(target: &str) -> anyhow::Result<Vec<(String, String)>> {
389    let path = std::path::Path::new(target);
390    if path.exists() && path.is_file() {
391        let content = std::fs::read_to_string(path)?;
392        let mut out = Vec::new();
393        for line in content.lines() {
394            let line = line.trim();
395            if line.is_empty() || line.starts_with('#') {
396                continue;
397            }
398            let (addr, chain) = parse_address_line(line);
399            out.push((addr.to_string(), chain.to_string()));
400        }
401        Ok(out)
402    } else {
403        let chain = detect_chain(target).unwrap_or_else(|_| "ethereum".to_string());
404        Ok(vec![(target.to_string(), chain)])
405    }
406}
407
408fn parse_address_line(line: &str) -> (&str, &str) {
409    if let Some((addr, rest)) = line.split_once(',') {
410        (addr.trim(), rest.trim())
411    } else {
412        (line, "ethereum")
413    }
414}
415
416fn format_compliance_report(
417    assessments: &[crate::compliance::risk::RiskAssessment],
418    patterns: &[(
419        String,
420        String,
421        Option<crate::compliance::datasource::PatternAnalysis>,
422    )],
423    jurisdiction: &Jurisdiction,
424    report_type: &ReportType,
425) -> String {
426    let mut md = format!(
427        "# Compliance Report\n\n\
428        **Jurisdiction:** {:?}  \n\
429        **Report Type:** {:?}  \n\
430        **Generated:** {}  \n\
431        **Addresses:** {}  \n\n",
432        jurisdiction,
433        report_type,
434        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
435        assessments.len()
436    );
437
438    for (i, assessment) in assessments.iter().enumerate() {
439        md.push_str(&format!(
440            "---\n\n## Address {}: `{}`\n\n",
441            i + 1,
442            assessment.address
443        ));
444        md.push_str(&format!(
445            "**Chain:** {}  \n**Risk Score:** {:.1}/10  \n**Risk Level:** {} {:?}  \n\n",
446            assessment.chain,
447            assessment.overall_score,
448            assessment.risk_level.emoji(),
449            assessment.risk_level
450        ));
451
452        if matches!(report_type, ReportType::Detailed | ReportType::SAR) {
453            md.push_str("### Risk Factor Breakdown\n\n");
454            for f in &assessment.factors {
455                md.push_str(&format!(
456                    "- **{}**: {:.1}/10 - {}\n",
457                    f.name, f.score, f.description
458                ));
459            }
460            if !assessment.recommendations.is_empty() {
461                md.push_str("\n### Recommendations\n\n");
462                for r in &assessment.recommendations {
463                    md.push_str(&format!("- {}\n", r));
464                }
465            }
466        }
467
468        if let Some((_, _, Some(pat))) = patterns
469            .iter()
470            .find(|(a, c, _)| a == &assessment.address && c == &assessment.chain)
471        {
472            md.push_str("\n### Pattern Analysis\n\n");
473            md.push_str(&format!(
474                "- Total transactions: {}\n",
475                pat.total_transactions
476            ));
477            md.push_str(&format!("- Velocity: {:.2} tx/day\n", pat.velocity_score));
478            md.push_str(&format!(
479                "- Structuring detected: {}\n",
480                pat.structuring_detected
481            ));
482            md.push_str(&format!(
483                "- Round number pattern: {}\n",
484                pat.round_number_pattern
485            ));
486            md.push_str(&format!(
487                "- Unusual hour transactions: {}\n",
488                pat.unusual_hours
489            ));
490        }
491    }
492
493    md.push_str(&crate::display::report::report_footer());
494    md
495}
496
497/// Auto-detect blockchain from address format
498fn detect_chain(address: &str) -> anyhow::Result<String> {
499    if address.starts_with("0x") && address.len() == 42 {
500        // Could be any EVM chain, default to Ethereum
501        Ok("ethereum".to_string())
502    } else if address.len() == 32 || address.len() == 44 {
503        // Solana base58
504        Ok("solana".to_string())
505    } else if address.starts_with("T") && address.len() == 34 {
506        // Tron
507        Ok("tron".to_string())
508    } else if address.starts_with("bc1") || address.starts_with("1") || address.starts_with("3") {
509        // Bitcoin
510        Ok("bitcoin".to_string())
511    } else {
512        anyhow::bail!("Could not auto-detect chain from address: {}", address)
513    }
514}
515
516// ============================================================================
517// Unit Tests
518// ============================================================================
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523
524    #[test]
525    fn test_detect_chain_ethereum() {
526        let result = detect_chain("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
527        assert!(result.is_ok());
528        assert_eq!(result.unwrap(), "ethereum");
529    }
530
531    #[test]
532    fn test_detect_chain_solana_short() {
533        // Solana 32-char address
534        let result = detect_chain("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC");
535        assert!(result.is_ok());
536        assert_eq!(result.unwrap(), "solana");
537    }
538
539    #[test]
540    fn test_detect_chain_solana_long() {
541        // Solana 44-char address
542        let result = detect_chain("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
543        assert!(result.is_ok());
544        assert_eq!(result.unwrap(), "solana");
545    }
546
547    #[test]
548    fn test_detect_chain_tron() {
549        let result = detect_chain("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf");
550        assert!(result.is_ok());
551        assert_eq!(result.unwrap(), "tron");
552    }
553
554    #[test]
555    fn test_detect_chain_bitcoin_bech32() {
556        let result = detect_chain("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
557        assert!(result.is_ok());
558        assert_eq!(result.unwrap(), "bitcoin");
559    }
560
561    #[test]
562    fn test_detect_chain_bitcoin_p2pkh() {
563        let result = detect_chain("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2");
564        assert!(result.is_ok());
565        assert_eq!(result.unwrap(), "bitcoin");
566    }
567
568    #[test]
569    fn test_detect_chain_bitcoin_p2sh() {
570        let result = detect_chain("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy");
571        assert!(result.is_ok());
572        assert_eq!(result.unwrap(), "bitcoin");
573    }
574
575    #[test]
576    fn test_parse_address_line_with_chain() {
577        let (addr, chain) = parse_address_line("0xabc, polygon");
578        assert_eq!(addr, "0xabc");
579        assert_eq!(chain, "polygon");
580    }
581
582    #[test]
583    fn test_parse_address_line_no_chain() {
584        let (addr, chain) = parse_address_line("0xabc");
585        assert_eq!(addr, "0xabc");
586        assert_eq!(chain, "ethereum");
587    }
588
589    #[test]
590    fn test_resolve_compliance_targets_single_address() {
591        let result =
592            resolve_compliance_targets("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2").unwrap();
593        assert_eq!(result.len(), 1);
594        assert_eq!(result[0].0, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
595        assert_eq!(result[0].1, "ethereum");
596    }
597
598    #[test]
599    fn test_resolve_compliance_targets_from_file() {
600        let dir = tempfile::tempdir().unwrap();
601        let path = dir.path().join("addresses.txt");
602        std::fs::write(
603            &path,
604            "0xabc123, ethereum\n0xdef456, polygon\n# comment\n\n0x789,solana",
605        )
606        .unwrap();
607        let result = resolve_compliance_targets(path.to_str().unwrap()).unwrap();
608        assert_eq!(result.len(), 3);
609        assert_eq!(result[0].0, "0xabc123");
610        assert_eq!(result[0].1, "ethereum");
611        assert_eq!(result[1].0, "0xdef456");
612        assert_eq!(result[1].1, "polygon");
613        assert_eq!(result[2].0, "0x789");
614        assert_eq!(result[2].1, "solana");
615    }
616
617    #[test]
618    fn test_detect_chain_unknown() {
619        let result = detect_chain("unknown_address_format_xyz");
620        assert!(result.is_err());
621    }
622
623    #[tokio::test]
624    async fn test_handle_risk_no_api_key() {
625        // Should work without API key (basic scoring)
626        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
627        let args = RiskArgs {
628            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
629            chain: Some("ethereum".to_string()),
630            format: OutputFormat::Table,
631            detailed: false,
632            output: None,
633        };
634        let result = handle_risk(args).await;
635        assert!(result.is_ok());
636    }
637
638    #[tokio::test]
639    async fn test_handle_risk_json_format() {
640        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
641        let args = RiskArgs {
642            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
643            chain: Some("ethereum".to_string()),
644            format: OutputFormat::Json,
645            detailed: true,
646            output: None,
647        };
648        let result = handle_risk(args).await;
649        assert!(result.is_ok());
650    }
651
652    #[tokio::test]
653    async fn test_handle_risk_with_export() {
654        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
655        let temp = tempfile::NamedTempFile::new().unwrap();
656        let path = temp.path().to_string_lossy().to_string();
657        let args = RiskArgs {
658            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
659            chain: Some("ethereum".to_string()),
660            format: OutputFormat::Table,
661            detailed: false,
662            output: Some(path.clone()),
663        };
664        let result = handle_risk(args).await;
665        assert!(result.is_ok());
666        assert!(std::path::Path::new(&path).exists());
667    }
668
669    #[tokio::test]
670    async fn test_handle_risk_export_markdown_extension() {
671        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
672        let dir = tempfile::tempdir().unwrap();
673        let path = dir.path().join("report.md");
674        let path_str = path.to_string_lossy().to_string();
675        let args = RiskArgs {
676            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
677            chain: Some("ethereum".to_string()),
678            format: OutputFormat::Table,
679            detailed: false,
680            output: Some(path_str.clone()),
681        };
682        let result = handle_risk(args).await;
683        assert!(result.is_ok());
684        let content = std::fs::read_to_string(&path).unwrap();
685        assert!(content.contains("Risk") || content.contains("risk"));
686    }
687
688    #[tokio::test]
689    async fn test_handle_risk_export_yaml_extension() {
690        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
691        let dir = tempfile::tempdir().unwrap();
692        let path = dir.path().join("report.yaml");
693        let path_str = path.to_string_lossy().to_string();
694        let args = RiskArgs {
695            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
696            chain: Some("ethereum".to_string()),
697            format: OutputFormat::Table,
698            detailed: false,
699            output: Some(path_str.clone()),
700        };
701        let result = handle_risk(args).await;
702        assert!(result.is_ok());
703        let content = std::fs::read_to_string(&path).unwrap();
704        assert!(content.contains("address") || content.contains("chain"));
705    }
706
707    #[tokio::test]
708    async fn test_handle_risk_auto_detect_chain() {
709        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
710        let args = RiskArgs {
711            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
712            chain: None, // auto-detect
713            format: OutputFormat::Table,
714            detailed: false,
715            output: None,
716        };
717        let result = handle_risk(args).await;
718        assert!(result.is_ok());
719    }
720
721    #[tokio::test]
722    async fn test_handle_trace_no_api_key() {
723        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
724        let args = TraceArgs {
725            tx_hash: "0xabc123".to_string(),
726            depth: 3,
727            flag_suspicious: true,
728            format: OutputFormat::Table,
729        };
730        let result = handle_trace(args).await;
731        assert!(result.is_ok()); // No API key → prints message, doesn't error
732    }
733
734    #[tokio::test]
735    async fn test_handle_analyze_no_api_key() {
736        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
737        let args = AnalyzeArgs {
738            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
739            patterns: vec![PatternType::Structuring, PatternType::Layering],
740            range: "30d".to_string(),
741            format: OutputFormat::Table,
742        };
743        let result = handle_analyze(args).await;
744        assert!(result.is_ok());
745    }
746
747    #[tokio::test]
748    async fn test_handle_compliance_report() {
749        let args = ComplianceReportArgs {
750            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
751            jurisdiction: Jurisdiction::US,
752            report_type: ReportType::Summary,
753            output: "/tmp/test_compliance.json".to_string(),
754        };
755        let result = handle_compliance_report(args).await;
756        assert!(result.is_ok()); // Not yet implemented → prints message
757    }
758
759    #[tokio::test]
760    async fn test_handle_compliance_report_eu_detailed() {
761        let args = ComplianceReportArgs {
762            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
763            jurisdiction: Jurisdiction::EU,
764            report_type: ReportType::Detailed,
765            output: "/tmp/test_compliance_eu.json".to_string(),
766        };
767        let result = handle_compliance_report(args).await;
768        assert!(result.is_ok());
769    }
770
771    #[tokio::test]
772    async fn test_handle_compliance_report_uk_sar() {
773        let args = ComplianceReportArgs {
774            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
775            jurisdiction: Jurisdiction::UK,
776            report_type: ReportType::SAR,
777            output: "/tmp/test_compliance_uk.json".to_string(),
778        };
779        let result = handle_compliance_report(args).await;
780        assert!(result.is_ok());
781    }
782
783    #[tokio::test]
784    async fn test_handle_compliance_report_singapore_travel_rule() {
785        let args = ComplianceReportArgs {
786            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
787            jurisdiction: Jurisdiction::Singapore,
788            report_type: ReportType::TravelRule,
789            output: "/tmp/test_compliance_sg.json".to_string(),
790        };
791        let result = handle_compliance_report(args).await;
792        assert!(result.is_ok());
793    }
794
795    #[tokio::test]
796    async fn test_handle_compliance_report_switzerland() {
797        let args = ComplianceReportArgs {
798            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
799            jurisdiction: Jurisdiction::Switzerland,
800            report_type: ReportType::Summary,
801            output: "/tmp/test_compliance_ch.json".to_string(),
802        };
803        let result = handle_compliance_report(args).await;
804        assert!(result.is_ok());
805    }
806
807    #[tokio::test]
808    async fn test_handle_risk_yaml_format() {
809        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
810        let args = RiskArgs {
811            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
812            chain: Some("ethereum".to_string()),
813            format: OutputFormat::Yaml,
814            detailed: false,
815            output: None,
816        };
817        let result = handle_risk(args).await;
818        assert!(result.is_ok());
819    }
820
821    // ========================================================================
822    // Tests with injected mockito client (covers API-present paths)
823    // ========================================================================
824
825    fn mock_etherscan_response(txs: &[serde_json::Value]) -> String {
826        serde_json::json!({
827            "status": "1",
828            "message": "OK",
829            "result": txs
830        })
831        .to_string()
832    }
833
834    fn make_mock_client(base_url: &str) -> BlockchainDataClient {
835        let sources = DataSources::new("test_api_key".to_string());
836        BlockchainDataClient::with_base_url(sources, base_url)
837    }
838
839    #[tokio::test]
840    async fn test_handle_risk_with_api_client() {
841        let mut server = mockito::Server::new_async().await;
842        let _mock = server
843            .mock("GET", mockito::Matcher::Any)
844            .with_status(200)
845            .with_body(mock_etherscan_response(&[]))
846            .create_async()
847            .await;
848
849        let client = make_mock_client(&server.url());
850        let args = RiskArgs {
851            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
852            chain: Some("ethereum".to_string()),
853            format: OutputFormat::Table,
854            detailed: true,
855            output: None,
856        };
857        let result = handle_risk_with_client(args, Some(client)).await;
858        assert!(result.is_ok());
859    }
860
861    #[tokio::test]
862    async fn test_handle_risk_with_api_client_json_export() {
863        let mut server = mockito::Server::new_async().await;
864        let _mock = server
865            .mock("GET", mockito::Matcher::Any)
866            .with_status(200)
867            .with_body(mock_etherscan_response(&[]))
868            .create_async()
869            .await;
870
871        let client = make_mock_client(&server.url());
872        let tmp = tempfile::NamedTempFile::new().unwrap();
873        let path = tmp.path().to_string_lossy().to_string();
874
875        let args = RiskArgs {
876            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
877            chain: Some("ethereum".to_string()),
878            format: OutputFormat::Table,
879            detailed: false,
880            output: Some(path.clone()),
881        };
882        let result = handle_risk_with_client(args, Some(client)).await;
883        assert!(result.is_ok());
884        assert!(std::path::Path::new(&path).exists());
885    }
886
887    #[tokio::test]
888    async fn test_handle_trace_with_api_client() {
889        let mut server = mockito::Server::new_async().await;
890        let _mock = server
891            .mock("GET", mockito::Matcher::Any)
892            .with_status(200)
893            .with_body(mock_etherscan_response(&[serde_json::json!({
894                "hash": "0xabc",
895                "from": "0x111",
896                "to": "0x222",
897                "value": "1000000000000000000",
898                "timeStamp": "1700000000",
899                "blockNumber": "18000000",
900                "gasUsed": "21000",
901                "gasPrice": "50000000000",
902                "isError": "0",
903                "input": "0x"
904            })]))
905            .create_async()
906            .await;
907
908        let client = make_mock_client(&server.url());
909        let args = TraceArgs {
910            tx_hash: "0xabc123def456".to_string(),
911            depth: 2,
912            flag_suspicious: true,
913            format: OutputFormat::Table,
914        };
915        let result = handle_trace_with_client(args, Some(client)).await;
916        assert!(result.is_ok());
917    }
918
919    #[tokio::test]
920    async fn test_handle_trace_with_api_client_connection_refused() {
921        // Use invalid URL to trigger connection error so trace_transaction returns Err
922        let client = make_mock_client("http://127.0.0.1:1");
923        let args = TraceArgs {
924            tx_hash: "0xabc123".to_string(),
925            depth: 2,
926            flag_suspicious: false,
927            format: OutputFormat::Table,
928        };
929        let result = handle_trace_with_client(args, Some(client)).await;
930        assert!(result.is_ok()); // Handler catches error, prints to stderr
931    }
932
933    #[tokio::test]
934    async fn test_handle_trace_with_api_client_error() {
935        let mut server = mockito::Server::new_async().await;
936        let _mock = server
937            .mock("GET", mockito::Matcher::Any)
938            .with_status(200)
939            .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
940            .create_async()
941            .await;
942
943        let client = make_mock_client(&server.url());
944        let args = TraceArgs {
945            tx_hash: "0xabc123def456".to_string(),
946            depth: 3,
947            flag_suspicious: false,
948            format: OutputFormat::Table,
949        };
950        // Error path: should print error but return Ok
951        let result = handle_trace_with_client(args, Some(client)).await;
952        assert!(result.is_ok());
953    }
954
955    #[tokio::test]
956    async fn test_handle_analyze_with_api_client() {
957        let mut server = mockito::Server::new_async().await;
958        let _mock = server
959            .mock("GET", mockito::Matcher::Any)
960            .with_status(200)
961            .with_body(mock_etherscan_response(&[serde_json::json!({
962                "hash": "0xabc",
963                "from": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
964                "to": "0x222",
965                "value": "1000000000000000000",
966                "timeStamp": "1700000000",
967                "blockNumber": "18000000",
968                "gasUsed": "21000",
969                "gasPrice": "50000000000",
970                "isError": "0",
971                "input": "0x"
972            })]))
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::Structuring, PatternType::Velocity],
980            range: "30d".to_string(),
981            format: OutputFormat::Table,
982        };
983        let result = handle_analyze_with_client(args, Some(client)).await;
984        assert!(result.is_ok());
985    }
986
987    #[tokio::test]
988    async fn test_handle_analyze_with_api_client_error() {
989        let mut server = mockito::Server::new_async().await;
990        let _mock = server
991            .mock("GET", mockito::Matcher::Any)
992            .with_status(200)
993            .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
994            .create_async()
995            .await;
996
997        let client = make_mock_client(&server.url());
998        let args = AnalyzeArgs {
999            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1000            patterns: vec![PatternType::Layering],
1001            range: "7d".to_string(),
1002            format: OutputFormat::Table,
1003        };
1004        // Error path in analyze
1005        let result = handle_analyze_with_client(args, Some(client)).await;
1006        assert!(result.is_ok());
1007    }
1008
1009    #[tokio::test]
1010    async fn test_handle_analyze_with_detect_chain_failure() {
1011        let mut server = mockito::Server::new_async().await;
1012        let _mock = server
1013            .mock("GET", mockito::Matcher::Any)
1014            .with_status(200)
1015            .with_body(mock_etherscan_response(&[]))
1016            .create_async()
1017            .await;
1018
1019        let client = make_mock_client(&server.url());
1020        // Address that won't auto-detect → falls back to "ethereum"
1021        let args = AnalyzeArgs {
1022            address: "unknown_format_addr".to_string(),
1023            patterns: vec![PatternType::Integration],
1024            range: "1y".to_string(),
1025            format: OutputFormat::Json,
1026        };
1027        let result = handle_analyze_with_client(args, Some(client)).await;
1028        assert!(result.is_ok());
1029    }
1030
1031    #[tokio::test]
1032    async fn test_handle_risk_markdown_detailed() {
1033        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
1034        let args = RiskArgs {
1035            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1036            chain: Some("ethereum".to_string()),
1037            format: OutputFormat::Markdown,
1038            detailed: true,
1039            output: None,
1040        };
1041        let result = handle_risk(args).await;
1042        assert!(result.is_ok());
1043    }
1044
1045    #[tokio::test]
1046    async fn test_handle_trace_no_flag_suspicious() {
1047        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
1048        let args = TraceArgs {
1049            tx_hash: "0xdef456".to_string(),
1050            depth: 5,
1051            flag_suspicious: false,
1052            format: OutputFormat::Json,
1053        };
1054        let result = handle_trace(args).await;
1055        assert!(result.is_ok());
1056    }
1057
1058    #[tokio::test]
1059    async fn test_handle_analyze_all_patterns() {
1060        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
1061        let args = AnalyzeArgs {
1062            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1063            patterns: vec![
1064                PatternType::Structuring,
1065                PatternType::Layering,
1066                PatternType::Integration,
1067                PatternType::Velocity,
1068                PatternType::RoundNumbers,
1069            ],
1070            range: "6m".to_string(),
1071            format: OutputFormat::Json,
1072        };
1073        let result = handle_analyze(args).await;
1074        assert!(result.is_ok());
1075    }
1076
1077    #[test]
1078    fn test_pattern_type_debug() {
1079        let patterns = [
1080            PatternType::Structuring,
1081            PatternType::Layering,
1082            PatternType::Integration,
1083            PatternType::Velocity,
1084            PatternType::RoundNumbers,
1085        ];
1086        for p in &patterns {
1087            let debug = format!("{:?}", p);
1088            assert!(!debug.is_empty());
1089        }
1090    }
1091
1092    #[test]
1093    fn test_jurisdiction_debug() {
1094        let jurisdictions = [
1095            Jurisdiction::US,
1096            Jurisdiction::EU,
1097            Jurisdiction::UK,
1098            Jurisdiction::Switzerland,
1099            Jurisdiction::Singapore,
1100        ];
1101        for j in &jurisdictions {
1102            let debug = format!("{:?}", j);
1103            assert!(!debug.is_empty());
1104        }
1105    }
1106
1107    #[test]
1108    fn test_report_type_debug() {
1109        let types = [
1110            ReportType::Summary,
1111            ReportType::Detailed,
1112            ReportType::SAR,
1113            ReportType::TravelRule,
1114        ];
1115        for t in &types {
1116            let debug = format!("{:?}", t);
1117            assert!(!debug.is_empty());
1118        }
1119    }
1120
1121    #[test]
1122    fn test_format_compliance_report_summary() {
1123        use crate::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
1124        let assessment = RiskAssessment {
1125            address: "0xabc".to_string(),
1126            chain: "ethereum".to_string(),
1127            overall_score: 3.5,
1128            risk_level: RiskLevel::Low,
1129            factors: vec![RiskFactor {
1130                name: "Address Age".to_string(),
1131                category: RiskCategory::Behavioral,
1132                score: 2.0,
1133                weight: 1.0,
1134                description: "Address is well-established".to_string(),
1135                evidence: vec![],
1136            }],
1137            recommendations: vec!["Continue monitoring".to_string()],
1138            assessed_at: chrono::Utc::now(),
1139        };
1140        let patterns: Vec<(
1141            String,
1142            String,
1143            Option<crate::compliance::datasource::PatternAnalysis>,
1144        )> = vec![];
1145        let report = format_compliance_report(
1146            &[assessment],
1147            &patterns,
1148            &Jurisdiction::US,
1149            &ReportType::Summary,
1150        );
1151        assert!(report.contains("Compliance Report"));
1152        assert!(report.contains("0xabc"));
1153        assert!(report.contains("ethereum"));
1154        assert!(report.contains("3.5"));
1155        assert!(report.contains("Low"));
1156        // Summary report should not include risk factor breakdown
1157        assert!(!report.contains("Risk Factor Breakdown"));
1158    }
1159
1160    #[test]
1161    fn test_format_compliance_report_detailed() {
1162        use crate::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
1163        let assessment = RiskAssessment {
1164            address: "0xdef".to_string(),
1165            chain: "ethereum".to_string(),
1166            overall_score: 5.5,
1167            risk_level: RiskLevel::Medium,
1168            factors: vec![
1169                RiskFactor {
1170                    name: "Address Age".to_string(),
1171                    category: RiskCategory::Behavioral,
1172                    score: 2.0,
1173                    weight: 1.0,
1174                    description: "Address is well-established".to_string(),
1175                    evidence: vec![],
1176                },
1177                RiskFactor {
1178                    name: "Transaction Velocity".to_string(),
1179                    category: RiskCategory::Behavioral,
1180                    score: 7.0,
1181                    weight: 0.8,
1182                    description: "High transaction frequency detected".to_string(),
1183                    evidence: vec![],
1184                },
1185            ],
1186            recommendations: vec![
1187                "Continue monitoring".to_string(),
1188                "Review transaction patterns".to_string(),
1189            ],
1190            assessed_at: chrono::Utc::now(),
1191        };
1192        let patterns: Vec<(
1193            String,
1194            String,
1195            Option<crate::compliance::datasource::PatternAnalysis>,
1196        )> = vec![];
1197        let report = format_compliance_report(
1198            &[assessment],
1199            &patterns,
1200            &Jurisdiction::EU,
1201            &ReportType::Detailed,
1202        );
1203        assert!(report.contains("Compliance Report"));
1204        assert!(report.contains("0xdef"));
1205        assert!(report.contains("5.5"));
1206        assert!(report.contains("Medium"));
1207        // Detailed report should include risk factor breakdown
1208        assert!(report.contains("Risk Factor Breakdown"));
1209        assert!(report.contains("Address Age"));
1210        assert!(report.contains("Transaction Velocity"));
1211        assert!(report.contains("Recommendations"));
1212        assert!(report.contains("Continue monitoring"));
1213        assert!(report.contains("Review transaction patterns"));
1214    }
1215
1216    #[test]
1217    fn test_format_compliance_report_with_pattern_analysis() {
1218        use crate::compliance::datasource::PatternAnalysis;
1219        use crate::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
1220        let assessment = RiskAssessment {
1221            address: "0x123".to_string(),
1222            chain: "ethereum".to_string(),
1223            overall_score: 3.5,
1224            risk_level: RiskLevel::Low,
1225            factors: vec![RiskFactor {
1226                name: "Address Age".to_string(),
1227                category: RiskCategory::Behavioral,
1228                score: 2.0,
1229                weight: 1.0,
1230                description: "Address is well-established".to_string(),
1231                evidence: vec![],
1232            }],
1233            recommendations: vec!["Continue monitoring".to_string()],
1234            assessed_at: chrono::Utc::now(),
1235        };
1236        let patterns: Vec<(String, String, Option<PatternAnalysis>)> = vec![(
1237            "0x123".to_string(),
1238            "ethereum".to_string(),
1239            Some(PatternAnalysis {
1240                total_transactions: 100,
1241                velocity_score: 2.5,
1242                structuring_detected: false,
1243                round_number_pattern: false,
1244                time_clustering: false,
1245                unusual_hours: 3,
1246            }),
1247        )];
1248        let report = format_compliance_report(
1249            &[assessment],
1250            &patterns,
1251            &Jurisdiction::UK,
1252            &ReportType::Detailed,
1253        );
1254        assert!(report.contains("Compliance Report"));
1255        assert!(report.contains("0x123"));
1256        // Check for pattern analysis section
1257        assert!(report.contains("Pattern Analysis"));
1258        assert!(report.contains("Total transactions: 100"));
1259        assert!(report.contains("Velocity: 2.50 tx/day"));
1260        assert!(report.contains("Structuring detected: false"));
1261        assert!(report.contains("Round number pattern: false"));
1262        assert!(report.contains("Unusual hour transactions: 3"));
1263    }
1264
1265    #[test]
1266    fn test_format_compliance_report_sar_type() {
1267        use crate::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
1268        let assessment = RiskAssessment {
1269            address: "0xsar".to_string(),
1270            chain: "ethereum".to_string(),
1271            overall_score: 7.5,
1272            risk_level: RiskLevel::High,
1273            factors: vec![RiskFactor {
1274                name: "Tx Velocity".to_string(),
1275                category: RiskCategory::Behavioral,
1276                score: 8.0,
1277                weight: 1.0,
1278                description: "High velocity".to_string(),
1279                evidence: vec![],
1280            }],
1281            recommendations: vec!["File SAR".to_string()],
1282            assessed_at: chrono::Utc::now(),
1283        };
1284        let patterns: Vec<(
1285            String,
1286            String,
1287            Option<crate::compliance::datasource::PatternAnalysis>,
1288        )> = vec![];
1289        let report = format_compliance_report(
1290            &[assessment],
1291            &patterns,
1292            &Jurisdiction::US,
1293            &ReportType::SAR,
1294        );
1295        assert!(report.contains("Compliance Report"));
1296        assert!(report.contains("Risk Factor Breakdown"));
1297        assert!(report.contains("Tx Velocity"));
1298        assert!(report.contains("File SAR"));
1299    }
1300
1301    #[test]
1302    fn test_format_compliance_report_travel_rule_type() {
1303        use crate::compliance::risk::{RiskAssessment, RiskCategory, RiskFactor, RiskLevel};
1304        let assessment = RiskAssessment {
1305            address: "0xtravel".to_string(),
1306            chain: "ethereum".to_string(),
1307            overall_score: 4.0,
1308            risk_level: RiskLevel::Medium,
1309            factors: vec![RiskFactor {
1310                name: "Travel Rule".to_string(),
1311                category: RiskCategory::Behavioral,
1312                score: 4.0,
1313                weight: 1.0,
1314                description: "Threshold check".to_string(),
1315                evidence: vec![],
1316            }],
1317            recommendations: vec![],
1318            assessed_at: chrono::Utc::now(),
1319        };
1320        let patterns: Vec<(
1321            String,
1322            String,
1323            Option<crate::compliance::datasource::PatternAnalysis>,
1324        )> = vec![];
1325        let report = format_compliance_report(
1326            &[assessment],
1327            &patterns,
1328            &Jurisdiction::Singapore,
1329            &ReportType::TravelRule,
1330        );
1331        assert!(report.contains("Compliance Report"));
1332        assert!(report.contains("0xtravel"));
1333        assert!(report.contains("4.0"));
1334    }
1335
1336    #[test]
1337    fn test_resolve_compliance_targets_file_empty_and_comments_only() {
1338        let dir = tempfile::tempdir().unwrap();
1339        let path = dir.path().join("empty.txt");
1340        std::fs::write(&path, "\n\n# comment only\n  \n").unwrap();
1341        let result = resolve_compliance_targets(path.to_str().unwrap()).unwrap();
1342        assert!(result.is_empty());
1343    }
1344
1345    #[test]
1346    fn test_parse_address_line_with_chain_trimmed() {
1347        let (addr, chain) = parse_address_line("0xabc , polygon ");
1348        assert_eq!(addr, "0xabc");
1349        assert_eq!(chain, "polygon");
1350    }
1351}