Skip to main content

scope/cli/
compliance.rs

1//! CLI commands for compliance and risk analysis
2
3use crate::compliance::datasource::{BlockchainDataClient, DataSources, analyze_patterns};
4use crate::compliance::risk::RiskEngine;
5use crate::display::{OutputFormat, format_risk_report};
6use clap::{Args, Subcommand};
7
8#[derive(Debug, Subcommand)]
9pub enum ComplianceCommands {
10    /// Assess risk for a blockchain address
11    #[command(name = "risk")]
12    Risk(RiskArgs),
13
14    /// Trace transaction taint through multiple hops
15    #[command(name = "trace")]
16    Trace(TraceArgs),
17
18    /// Detect suspicious transaction patterns
19    #[command(name = "analyze")]
20    Analyze(AnalyzeArgs),
21
22    /// Generate compliance report
23    #[command(name = "compliance-report")]
24    ComplianceReport(ComplianceReportArgs),
25}
26
27#[derive(Debug, Args)]
28pub struct RiskArgs {
29    /// Address to assess
30    #[arg(value_name = "ADDRESS")]
31    pub address: String,
32
33    /// Blockchain (auto-detected if not specified)
34    #[arg(short, long)]
35    pub chain: Option<String>,
36
37    /// Output format
38    #[arg(short, long, value_enum, default_value = "table")]
39    pub format: OutputFormat,
40
41    /// Include detailed factor breakdown
42    #[arg(long)]
43    pub detailed: bool,
44
45    /// Export to file
46    #[arg(short, long)]
47    pub output: Option<String>,
48}
49
50#[derive(Debug, Args)]
51pub struct TraceArgs {
52    /// Transaction hash to trace
53    #[arg(value_name = "TX_HASH")]
54    pub tx_hash: String,
55
56    /// Trace depth (hops to follow)
57    #[arg(short, long, default_value = "3")]
58    pub depth: u32,
59
60    /// Flag suspicious addresses
61    #[arg(long)]
62    pub flag_suspicious: bool,
63
64    /// Output format
65    #[arg(short, long, value_enum, default_value = "table")]
66    pub format: OutputFormat,
67}
68
69#[derive(Debug, Args)]
70pub struct AnalyzeArgs {
71    /// Address to analyze
72    #[arg(value_name = "ADDRESS")]
73    pub address: String,
74
75    /// Pattern types to detect
76    #[arg(long, value_enum, default_values = &["structuring", "layering", "integration"])]
77    pub patterns: Vec<PatternType>,
78
79    /// Time range (e.g., "30d", "6m", "1y")
80    #[arg(short, long, default_value = "30d")]
81    pub range: String,
82
83    /// Output format
84    #[arg(short, long, value_enum, default_value = "table")]
85    pub format: OutputFormat,
86}
87
88#[derive(Debug, Args)]
89pub struct ComplianceReportArgs {
90    /// Address or addresses file
91    #[arg(value_name = "TARGET")]
92    pub target: String,
93
94    /// Jurisdiction for compliance
95    #[arg(short, long, value_enum)]
96    pub jurisdiction: Jurisdiction,
97
98    /// Report type
99    #[arg(short, long, value_enum, default_value = "summary")]
100    pub report_type: ReportType,
101
102    /// Output file
103    #[arg(short, long, required = true)]
104    pub output: String,
105}
106
107#[derive(Clone, Copy, Debug, clap::ValueEnum)]
108pub enum PatternType {
109    Structuring,
110    Layering,
111    Integration,
112    Velocity,
113    RoundNumbers,
114}
115
116#[derive(Clone, Copy, Debug, clap::ValueEnum)]
117pub enum Jurisdiction {
118    US,
119    EU,
120    UK,
121    Switzerland,
122    Singapore,
123}
124
125#[derive(Clone, Copy, Debug, clap::ValueEnum)]
126pub enum ReportType {
127    Summary,
128    Detailed,
129    SAR, // Suspicious Activity Report
130    TravelRule,
131}
132
133/// Handle risk assessment command
134pub async fn handle_risk(args: RiskArgs) -> anyhow::Result<()> {
135    handle_risk_with_client(args, None).await
136}
137
138/// Handle risk assessment with an optional pre-built client (for testability).
139pub async fn handle_risk_with_client(
140    args: RiskArgs,
141    client: Option<BlockchainDataClient>,
142) -> anyhow::Result<()> {
143    // Auto-detect chain if not specified
144    let chain = match args.chain {
145        Some(c) => c,
146        None => detect_chain(&args.address)?,
147    };
148
149    println!("Assessing risk for {} on {}...", args.address, chain);
150
151    let engine = if let Some(c) = client {
152        println!("Using Etherscan API for enhanced analysis");
153        RiskEngine::with_data_client(c)
154    } else {
155        // Try to load API key from environment
156        let etherscan_key = std::env::var("ETHERSCAN_API_KEY").ok();
157
158        if let Some(key) = etherscan_key {
159            let sources = DataSources::new(key);
160            let client = BlockchainDataClient::new(sources);
161            println!("Using Etherscan API for enhanced analysis");
162            RiskEngine::with_data_client(client)
163        } else {
164            println!("Note: Set ETHERSCAN_API_KEY for enhanced analysis");
165            RiskEngine::new()
166        }
167    };
168
169    let assessment = engine.assess_address(&args.address, &chain).await?;
170
171    // Format and display output
172    let output = format_risk_report(&assessment, args.format, args.detailed);
173    println!("{}", output);
174
175    // Export to file if requested
176    if let Some(path) = args.output {
177        let json = serde_json::to_string_pretty(&assessment)?;
178        std::fs::write(&path, json)?;
179        println!("\nReport exported to: {}", path);
180    }
181
182    Ok(())
183}
184
185/// Handle transaction tracing command
186pub async fn handle_trace(args: TraceArgs) -> anyhow::Result<()> {
187    handle_trace_with_client(args, None).await
188}
189
190/// Handle transaction tracing with an optional pre-built client (for testability).
191pub async fn handle_trace_with_client(
192    args: TraceArgs,
193    client: Option<BlockchainDataClient>,
194) -> anyhow::Result<()> {
195    println!("Tracing transaction {}...", args.tx_hash);
196    println!("Depth: {} hops", args.depth);
197
198    if args.flag_suspicious {
199        println!("Flagging suspicious addresses enabled");
200    }
201
202    let resolved_client = if let Some(c) = client {
203        Some(c)
204    } else {
205        std::env::var("ETHERSCAN_API_KEY").ok().map(|key| {
206            let sources = DataSources::new(key);
207            BlockchainDataClient::new(sources)
208        })
209    };
210
211    if let Some(client) = resolved_client {
212        match client.trace_transaction(&args.tx_hash, args.depth).await {
213            Ok(trace) => {
214                println!("\nTransaction Trace");
215                println!("=================");
216                println!("Root: {}", trace.root_hash);
217                println!("Hops: {}", trace.hops.len());
218
219                for hop in &trace.hops {
220                    println!(
221                        "  Depth {}: {} ({} ETH)",
222                        hop.depth, hop.address, hop.amount
223                    );
224                }
225            }
226            Err(e) => {
227                eprintln!("Error tracing transaction: {}", e);
228            }
229        }
230    } else {
231        println!("Set ETHERSCAN_API_KEY to enable transaction tracing");
232    }
233
234    Ok(())
235}
236
237/// Handle pattern analysis command
238pub async fn handle_analyze(args: AnalyzeArgs) -> anyhow::Result<()> {
239    handle_analyze_with_client(args, None).await
240}
241
242/// Handle pattern analysis with an optional pre-built client (for testability).
243pub async fn handle_analyze_with_client(
244    args: AnalyzeArgs,
245    client: Option<BlockchainDataClient>,
246) -> anyhow::Result<()> {
247    println!("Analyzing patterns for {}...", args.address);
248    println!("Patterns: {:?}", args.patterns);
249    println!("Time range: {}", args.range);
250
251    let resolved_client = if let Some(c) = client {
252        Some(c)
253    } else {
254        std::env::var("ETHERSCAN_API_KEY").ok().map(|key| {
255            let sources = DataSources::new(key);
256            BlockchainDataClient::new(sources)
257        })
258    };
259
260    if let Some(client) = resolved_client {
261        // Auto-detect chain
262        let chain = match detect_chain(&args.address) {
263            Ok(c) => c,
264            Err(_) => "ethereum".to_string(),
265        };
266
267        match client.get_transactions(&args.address, &chain).await {
268            Ok(txs) => {
269                let analysis = analyze_patterns(&txs);
270
271                println!("\nPattern Analysis Results");
272                println!("========================");
273                println!("Total transactions: {}", analysis.total_transactions);
274                println!("Velocity: {:.2} tx/day", analysis.velocity_score);
275                println!("Structuring detected: {}", analysis.structuring_detected);
276                println!("Round number pattern: {}", analysis.round_number_pattern);
277                println!("Unusual hour transactions: {}", analysis.unusual_hours);
278            }
279            Err(e) => {
280                eprintln!("Error fetching transactions: {}", e);
281            }
282        }
283    } else {
284        println!("Set ETHERSCAN_API_KEY to enable pattern analysis");
285    }
286
287    Ok(())
288}
289
290/// Handle compliance report generation
291pub async fn handle_compliance_report(args: ComplianceReportArgs) -> anyhow::Result<()> {
292    println!("Compliance report generation is not yet implemented.");
293    println!(
294        "Planned features: {} report for {:?} jurisdiction",
295        format!("{:?}", args.report_type).to_lowercase(),
296        args.jurisdiction
297    );
298    println!("\nFor now, use 'scope risk' and 'scope analyze' for compliance checks.");
299
300    Ok(())
301}
302
303/// Auto-detect blockchain from address format
304fn detect_chain(address: &str) -> anyhow::Result<String> {
305    if address.starts_with("0x") && address.len() == 42 {
306        // Could be any EVM chain, default to Ethereum
307        Ok("ethereum".to_string())
308    } else if address.len() == 32 || address.len() == 44 {
309        // Solana base58
310        Ok("solana".to_string())
311    } else if address.starts_with("T") && address.len() == 34 {
312        // Tron
313        Ok("tron".to_string())
314    } else if address.starts_with("bc1") || address.starts_with("1") || address.starts_with("3") {
315        // Bitcoin
316        Ok("bitcoin".to_string())
317    } else {
318        anyhow::bail!("Could not auto-detect chain from address: {}", address)
319    }
320}
321
322// ============================================================================
323// Unit Tests
324// ============================================================================
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn test_detect_chain_ethereum() {
332        let result = detect_chain("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
333        assert!(result.is_ok());
334        assert_eq!(result.unwrap(), "ethereum");
335    }
336
337    #[test]
338    fn test_detect_chain_solana_short() {
339        // Solana 32-char address
340        let result = detect_chain("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC");
341        assert!(result.is_ok());
342        assert_eq!(result.unwrap(), "solana");
343    }
344
345    #[test]
346    fn test_detect_chain_solana_long() {
347        // Solana 44-char address
348        let result = detect_chain("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy");
349        assert!(result.is_ok());
350        assert_eq!(result.unwrap(), "solana");
351    }
352
353    #[test]
354    fn test_detect_chain_tron() {
355        let result = detect_chain("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf");
356        assert!(result.is_ok());
357        assert_eq!(result.unwrap(), "tron");
358    }
359
360    #[test]
361    fn test_detect_chain_bitcoin_bech32() {
362        let result = detect_chain("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4");
363        assert!(result.is_ok());
364        assert_eq!(result.unwrap(), "bitcoin");
365    }
366
367    #[test]
368    fn test_detect_chain_bitcoin_p2pkh() {
369        let result = detect_chain("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2");
370        assert!(result.is_ok());
371        assert_eq!(result.unwrap(), "bitcoin");
372    }
373
374    #[test]
375    fn test_detect_chain_bitcoin_p2sh() {
376        let result = detect_chain("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy");
377        assert!(result.is_ok());
378        assert_eq!(result.unwrap(), "bitcoin");
379    }
380
381    #[test]
382    fn test_detect_chain_unknown() {
383        let result = detect_chain("unknown_address_format_xyz");
384        assert!(result.is_err());
385    }
386
387    #[tokio::test]
388    async fn test_handle_risk_no_api_key() {
389        // Should work without API key (basic scoring)
390        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
391        let args = RiskArgs {
392            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
393            chain: Some("ethereum".to_string()),
394            format: OutputFormat::Table,
395            detailed: false,
396            output: None,
397        };
398        let result = handle_risk(args).await;
399        assert!(result.is_ok());
400    }
401
402    #[tokio::test]
403    async fn test_handle_risk_json_format() {
404        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
405        let args = RiskArgs {
406            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
407            chain: Some("ethereum".to_string()),
408            format: OutputFormat::Json,
409            detailed: true,
410            output: None,
411        };
412        let result = handle_risk(args).await;
413        assert!(result.is_ok());
414    }
415
416    #[tokio::test]
417    async fn test_handle_risk_with_export() {
418        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
419        let temp = tempfile::NamedTempFile::new().unwrap();
420        let path = temp.path().to_string_lossy().to_string();
421        let args = RiskArgs {
422            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
423            chain: Some("ethereum".to_string()),
424            format: OutputFormat::Table,
425            detailed: false,
426            output: Some(path.clone()),
427        };
428        let result = handle_risk(args).await;
429        assert!(result.is_ok());
430        assert!(std::path::Path::new(&path).exists());
431    }
432
433    #[tokio::test]
434    async fn test_handle_risk_auto_detect_chain() {
435        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
436        let args = RiskArgs {
437            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
438            chain: None, // auto-detect
439            format: OutputFormat::Table,
440            detailed: false,
441            output: None,
442        };
443        let result = handle_risk(args).await;
444        assert!(result.is_ok());
445    }
446
447    #[tokio::test]
448    async fn test_handle_trace_no_api_key() {
449        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
450        let args = TraceArgs {
451            tx_hash: "0xabc123".to_string(),
452            depth: 3,
453            flag_suspicious: true,
454            format: OutputFormat::Table,
455        };
456        let result = handle_trace(args).await;
457        assert!(result.is_ok()); // No API key → prints message, doesn't error
458    }
459
460    #[tokio::test]
461    async fn test_handle_analyze_no_api_key() {
462        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
463        let args = AnalyzeArgs {
464            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
465            patterns: vec![PatternType::Structuring, PatternType::Layering],
466            range: "30d".to_string(),
467            format: OutputFormat::Table,
468        };
469        let result = handle_analyze(args).await;
470        assert!(result.is_ok());
471    }
472
473    #[tokio::test]
474    async fn test_handle_compliance_report() {
475        let args = ComplianceReportArgs {
476            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
477            jurisdiction: Jurisdiction::US,
478            report_type: ReportType::Summary,
479            output: "/tmp/test_compliance.json".to_string(),
480        };
481        let result = handle_compliance_report(args).await;
482        assert!(result.is_ok()); // Not yet implemented → prints message
483    }
484
485    #[tokio::test]
486    async fn test_handle_compliance_report_eu_detailed() {
487        let args = ComplianceReportArgs {
488            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
489            jurisdiction: Jurisdiction::EU,
490            report_type: ReportType::Detailed,
491            output: "/tmp/test_compliance_eu.json".to_string(),
492        };
493        let result = handle_compliance_report(args).await;
494        assert!(result.is_ok());
495    }
496
497    #[tokio::test]
498    async fn test_handle_compliance_report_uk_sar() {
499        let args = ComplianceReportArgs {
500            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
501            jurisdiction: Jurisdiction::UK,
502            report_type: ReportType::SAR,
503            output: "/tmp/test_compliance_uk.json".to_string(),
504        };
505        let result = handle_compliance_report(args).await;
506        assert!(result.is_ok());
507    }
508
509    #[tokio::test]
510    async fn test_handle_compliance_report_singapore_travel_rule() {
511        let args = ComplianceReportArgs {
512            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
513            jurisdiction: Jurisdiction::Singapore,
514            report_type: ReportType::TravelRule,
515            output: "/tmp/test_compliance_sg.json".to_string(),
516        };
517        let result = handle_compliance_report(args).await;
518        assert!(result.is_ok());
519    }
520
521    #[tokio::test]
522    async fn test_handle_compliance_report_switzerland() {
523        let args = ComplianceReportArgs {
524            target: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
525            jurisdiction: Jurisdiction::Switzerland,
526            report_type: ReportType::Summary,
527            output: "/tmp/test_compliance_ch.json".to_string(),
528        };
529        let result = handle_compliance_report(args).await;
530        assert!(result.is_ok());
531    }
532
533    #[tokio::test]
534    async fn test_handle_risk_yaml_format() {
535        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
536        let args = RiskArgs {
537            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
538            chain: Some("ethereum".to_string()),
539            format: OutputFormat::Yaml,
540            detailed: false,
541            output: None,
542        };
543        let result = handle_risk(args).await;
544        assert!(result.is_ok());
545    }
546
547    // ========================================================================
548    // Tests with injected mockito client (covers API-present paths)
549    // ========================================================================
550
551    fn mock_etherscan_response(txs: &[serde_json::Value]) -> String {
552        serde_json::json!({
553            "status": "1",
554            "message": "OK",
555            "result": txs
556        })
557        .to_string()
558    }
559
560    fn make_mock_client(base_url: &str) -> BlockchainDataClient {
561        let sources = DataSources::new("test_api_key".to_string());
562        BlockchainDataClient::with_base_url(sources, base_url)
563    }
564
565    #[tokio::test]
566    async fn test_handle_risk_with_api_client() {
567        let mut server = mockito::Server::new_async().await;
568        let _mock = server
569            .mock("GET", mockito::Matcher::Any)
570            .with_status(200)
571            .with_body(mock_etherscan_response(&[]))
572            .create_async()
573            .await;
574
575        let client = make_mock_client(&server.url());
576        let args = RiskArgs {
577            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
578            chain: Some("ethereum".to_string()),
579            format: OutputFormat::Table,
580            detailed: true,
581            output: None,
582        };
583        let result = handle_risk_with_client(args, Some(client)).await;
584        assert!(result.is_ok());
585    }
586
587    #[tokio::test]
588    async fn test_handle_risk_with_api_client_json_export() {
589        let mut server = mockito::Server::new_async().await;
590        let _mock = server
591            .mock("GET", mockito::Matcher::Any)
592            .with_status(200)
593            .with_body(mock_etherscan_response(&[]))
594            .create_async()
595            .await;
596
597        let client = make_mock_client(&server.url());
598        let tmp = tempfile::NamedTempFile::new().unwrap();
599        let path = tmp.path().to_string_lossy().to_string();
600
601        let args = RiskArgs {
602            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
603            chain: Some("ethereum".to_string()),
604            format: OutputFormat::Table,
605            detailed: false,
606            output: Some(path.clone()),
607        };
608        let result = handle_risk_with_client(args, Some(client)).await;
609        assert!(result.is_ok());
610        assert!(std::path::Path::new(&path).exists());
611    }
612
613    #[tokio::test]
614    async fn test_handle_trace_with_api_client() {
615        let mut server = mockito::Server::new_async().await;
616        let _mock = server
617            .mock("GET", mockito::Matcher::Any)
618            .with_status(200)
619            .with_body(mock_etherscan_response(&[serde_json::json!({
620                "hash": "0xabc",
621                "from": "0x111",
622                "to": "0x222",
623                "value": "1000000000000000000",
624                "timeStamp": "1700000000",
625                "blockNumber": "18000000",
626                "gasUsed": "21000",
627                "gasPrice": "50000000000",
628                "isError": "0",
629                "input": "0x"
630            })]))
631            .create_async()
632            .await;
633
634        let client = make_mock_client(&server.url());
635        let args = TraceArgs {
636            tx_hash: "0xabc123def456".to_string(),
637            depth: 2,
638            flag_suspicious: true,
639            format: OutputFormat::Table,
640        };
641        let result = handle_trace_with_client(args, Some(client)).await;
642        assert!(result.is_ok());
643    }
644
645    #[tokio::test]
646    async fn test_handle_trace_with_api_client_error() {
647        let mut server = mockito::Server::new_async().await;
648        let _mock = server
649            .mock("GET", mockito::Matcher::Any)
650            .with_status(200)
651            .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
652            .create_async()
653            .await;
654
655        let client = make_mock_client(&server.url());
656        let args = TraceArgs {
657            tx_hash: "0xabc123def456".to_string(),
658            depth: 3,
659            flag_suspicious: false,
660            format: OutputFormat::Table,
661        };
662        // Error path: should print error but return Ok
663        let result = handle_trace_with_client(args, Some(client)).await;
664        assert!(result.is_ok());
665    }
666
667    #[tokio::test]
668    async fn test_handle_analyze_with_api_client() {
669        let mut server = mockito::Server::new_async().await;
670        let _mock = server
671            .mock("GET", mockito::Matcher::Any)
672            .with_status(200)
673            .with_body(mock_etherscan_response(&[serde_json::json!({
674                "hash": "0xabc",
675                "from": "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
676                "to": "0x222",
677                "value": "1000000000000000000",
678                "timeStamp": "1700000000",
679                "blockNumber": "18000000",
680                "gasUsed": "21000",
681                "gasPrice": "50000000000",
682                "isError": "0",
683                "input": "0x"
684            })]))
685            .create_async()
686            .await;
687
688        let client = make_mock_client(&server.url());
689        let args = AnalyzeArgs {
690            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
691            patterns: vec![PatternType::Structuring, PatternType::Velocity],
692            range: "30d".to_string(),
693            format: OutputFormat::Table,
694        };
695        let result = handle_analyze_with_client(args, Some(client)).await;
696        assert!(result.is_ok());
697    }
698
699    #[tokio::test]
700    async fn test_handle_analyze_with_api_client_error() {
701        let mut server = mockito::Server::new_async().await;
702        let _mock = server
703            .mock("GET", mockito::Matcher::Any)
704            .with_status(200)
705            .with_body(r#"{"status":"0","message":"NOTOK","result":"Error"}"#)
706            .create_async()
707            .await;
708
709        let client = make_mock_client(&server.url());
710        let args = AnalyzeArgs {
711            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
712            patterns: vec![PatternType::Layering],
713            range: "7d".to_string(),
714            format: OutputFormat::Table,
715        };
716        // Error path in analyze
717        let result = handle_analyze_with_client(args, Some(client)).await;
718        assert!(result.is_ok());
719    }
720
721    #[tokio::test]
722    async fn test_handle_analyze_with_detect_chain_failure() {
723        let mut server = mockito::Server::new_async().await;
724        let _mock = server
725            .mock("GET", mockito::Matcher::Any)
726            .with_status(200)
727            .with_body(mock_etherscan_response(&[]))
728            .create_async()
729            .await;
730
731        let client = make_mock_client(&server.url());
732        // Address that won't auto-detect → falls back to "ethereum"
733        let args = AnalyzeArgs {
734            address: "unknown_format_addr".to_string(),
735            patterns: vec![PatternType::Integration],
736            range: "1y".to_string(),
737            format: OutputFormat::Json,
738        };
739        let result = handle_analyze_with_client(args, Some(client)).await;
740        assert!(result.is_ok());
741    }
742
743    #[tokio::test]
744    async fn test_handle_risk_markdown_detailed() {
745        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
746        let args = RiskArgs {
747            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
748            chain: Some("ethereum".to_string()),
749            format: OutputFormat::Markdown,
750            detailed: true,
751            output: None,
752        };
753        let result = handle_risk(args).await;
754        assert!(result.is_ok());
755    }
756
757    #[tokio::test]
758    async fn test_handle_trace_no_flag_suspicious() {
759        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
760        let args = TraceArgs {
761            tx_hash: "0xdef456".to_string(),
762            depth: 5,
763            flag_suspicious: false,
764            format: OutputFormat::Json,
765        };
766        let result = handle_trace(args).await;
767        assert!(result.is_ok());
768    }
769
770    #[tokio::test]
771    async fn test_handle_analyze_all_patterns() {
772        unsafe { std::env::remove_var("ETHERSCAN_API_KEY") };
773        let args = AnalyzeArgs {
774            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
775            patterns: vec![
776                PatternType::Structuring,
777                PatternType::Layering,
778                PatternType::Integration,
779                PatternType::Velocity,
780                PatternType::RoundNumbers,
781            ],
782            range: "6m".to_string(),
783            format: OutputFormat::Json,
784        };
785        let result = handle_analyze(args).await;
786        assert!(result.is_ok());
787    }
788
789    #[test]
790    fn test_pattern_type_debug() {
791        let patterns = [
792            PatternType::Structuring,
793            PatternType::Layering,
794            PatternType::Integration,
795            PatternType::Velocity,
796            PatternType::RoundNumbers,
797        ];
798        for p in &patterns {
799            let debug = format!("{:?}", p);
800            assert!(!debug.is_empty());
801        }
802    }
803
804    #[test]
805    fn test_jurisdiction_debug() {
806        let jurisdictions = [
807            Jurisdiction::US,
808            Jurisdiction::EU,
809            Jurisdiction::UK,
810            Jurisdiction::Switzerland,
811            Jurisdiction::Singapore,
812        ];
813        for j in &jurisdictions {
814            let debug = format!("{:?}", j);
815            assert!(!debug.is_empty());
816        }
817    }
818
819    #[test]
820    fn test_report_type_debug() {
821        let types = [
822            ReportType::Summary,
823            ReportType::Detailed,
824            ReportType::SAR,
825            ReportType::TravelRule,
826        ];
827        for t in &types {
828            let debug = format!("{:?}", t);
829            assert!(!debug.is_empty());
830        }
831    }
832}