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