Skip to main content

scope_cli/cli/
compliance.rs

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