Skip to main content

scope/cli/
address.rs

1//! # Address Analysis Command
2//!
3//! This module implements the `scope address` command for analyzing
4//! blockchain addresses. It retrieves balance information, transaction
5//! history, and token holdings.
6//!
7//! ## Usage
8//!
9//! ```bash
10//! # Basic address analysis
11//! scope address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2
12//!
13//! # Specify chain
14//! scope address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2 --chain ethereum
15//!
16//! # Output as JSON
17//! scope address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2 --format json
18//! ```
19
20use crate::chains::{
21    ChainClient, ChainClientFactory, validate_solana_address, validate_tron_address,
22};
23use crate::config::{Config, OutputFormat};
24use crate::error::Result;
25use clap::Args;
26
27/// Arguments for the address analysis command.
28#[derive(Debug, Clone, Args)]
29#[command(after_help = "\x1b[1mExamples:\x1b[0m
30  scope address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2
31  scope address 0x742d... --include-txs --include-tokens
32  scope address 0x742d... --dossier --report dossier.md
33  scope address DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy --chain solana")]
34pub struct AddressArgs {
35    /// The blockchain address to analyze.
36    ///
37    /// Must be a valid address format for the target chain
38    /// (e.g., 0x-prefixed 40-character hex for Ethereum).
39    #[arg(value_name = "ADDRESS")]
40    pub address: String,
41
42    /// Target blockchain network.
43    ///
44    /// EVM chains: ethereum, polygon, arbitrum, optimism, base, bsc
45    /// Non-EVM chains: solana, tron
46    #[arg(short, long, default_value = "ethereum")]
47    pub chain: String,
48
49    /// Override output format for this command.
50    #[arg(short, long, value_name = "FORMAT")]
51    pub format: Option<OutputFormat>,
52
53    /// Include full transaction history.
54    #[arg(long)]
55    pub include_txs: bool,
56
57    /// Include token balances (ERC-20, ERC-721).
58    #[arg(long)]
59    pub include_tokens: bool,
60
61    /// Maximum number of transactions to retrieve.
62    #[arg(long, default_value = "100")]
63    pub limit: u32,
64
65    /// Generate and save a markdown report to the specified path.
66    #[arg(long, value_name = "PATH")]
67    pub report: Option<std::path::PathBuf>,
68
69    /// Produce a combined dossier: address analysis + risk assessment.
70    ///
71    /// Implies --include-txs and --include-tokens. Uses ETHERSCAN_API_KEY
72    /// for enhanced risk analysis on Ethereum.
73    #[arg(long, default_value_t = false)]
74    pub dossier: bool,
75}
76
77/// Result of an address analysis.
78#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
79pub struct AddressReport {
80    /// The analyzed address.
81    pub address: String,
82
83    /// The blockchain network.
84    pub chain: String,
85
86    /// Native token balance.
87    pub balance: Balance,
88
89    /// Transaction count (nonce).
90    pub transaction_count: u64,
91
92    /// Recent transactions (if requested).
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub transactions: Option<Vec<TransactionSummary>>,
95
96    /// Token balances (if requested).
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub tokens: Option<Vec<TokenBalance>>,
99}
100
101/// Balance representation with multiple units.
102#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
103pub struct Balance {
104    /// Raw balance in smallest unit (e.g., wei for Ethereum).
105    pub raw: String,
106
107    /// Human-readable balance in native token (e.g., ETH).
108    pub formatted: String,
109
110    /// Balance in USD (if price available).
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub usd: Option<f64>,
113}
114
115/// Summary of a transaction.
116#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
117pub struct TransactionSummary {
118    /// Transaction hash.
119    pub hash: String,
120
121    /// Block number.
122    pub block_number: u64,
123
124    /// Timestamp (Unix epoch).
125    pub timestamp: u64,
126
127    /// Sender address.
128    pub from: String,
129
130    /// Recipient address.
131    pub to: Option<String>,
132
133    /// Value transferred.
134    pub value: String,
135
136    /// Transaction status (success/failure).
137    pub status: bool,
138}
139
140/// Token balance information.
141#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
142pub struct TokenBalance {
143    /// Token contract address.
144    pub contract_address: String,
145
146    /// Token symbol.
147    pub symbol: String,
148
149    /// Token name.
150    pub name: String,
151
152    /// Token decimals.
153    pub decimals: u8,
154
155    /// Raw balance.
156    pub balance: String,
157
158    /// Formatted balance.
159    pub formatted_balance: String,
160}
161
162/// Executes the address analysis command.
163///
164/// # Arguments
165///
166/// * `args` - The parsed command arguments
167/// * `config` - Application configuration
168///
169/// # Returns
170///
171/// Returns `Ok(())` on success, or an error if the analysis fails.
172///
173/// # Errors
174///
175/// Returns `ScopeError::InvalidAddress` if the address format is invalid.
176/// Returns `ScopeError::Request` if API calls fail.
177pub async fn run(
178    mut args: AddressArgs,
179    config: &Config,
180    clients: &dyn ChainClientFactory,
181) -> Result<()> {
182    // Auto-infer chain if using default and address format is recognizable
183    if args.chain == "ethereum"
184        && let Some(inferred) = crate::chains::infer_chain_from_address(&args.address)
185        && inferred != "ethereum"
186    {
187        tracing::info!("Auto-detected chain: {}", inferred);
188        println!("Auto-detected chain: {}", inferred);
189        args.chain = inferred.to_string();
190    }
191
192    tracing::info!(
193        address = %args.address,
194        chain = %args.chain,
195        "Starting address analysis"
196    );
197
198    // Validate address format
199    validate_address(&args.address, &args.chain)?;
200
201    // Dossier implies full picture: txs + tokens
202    let mut analysis_args = args.clone();
203    if args.dossier {
204        analysis_args.include_txs = true;
205        analysis_args.include_tokens = true;
206    }
207
208    let sp = crate::cli::progress::Spinner::new(&format!("Analyzing address on {}...", args.chain));
209
210    let client = clients.create_chain_client(&args.chain)?;
211    let report = analyze_address(&analysis_args, client.as_ref()).await?;
212
213    // Dossier: fetch risk assessment (uses ETHERSCAN_API_KEY for Ethereum)
214    let risk_assessment = if args.dossier {
215        sp.set_message("Running risk assessment...");
216        let engine = match crate::compliance::datasource::BlockchainDataClient::from_env_opt() {
217            Some(client) => crate::compliance::risk::RiskEngine::with_data_client(client),
218            None => crate::compliance::risk::RiskEngine::new(),
219        };
220        engine.assess_address(&args.address, &args.chain).await.ok()
221    } else {
222        None
223    };
224
225    sp.finish("Analysis complete.");
226
227    // Output based on format
228    let format = args.format.unwrap_or(config.output.format);
229    if format == OutputFormat::Markdown {
230        if args.dossier && risk_assessment.as_ref().is_some() {
231            let risk = risk_assessment.as_ref().unwrap();
232            println!(
233                "{}",
234                crate::cli::address_report::generate_dossier_report(&report, risk)
235            );
236        } else {
237            println!(
238                "{}",
239                crate::cli::address_report::generate_address_report(&report)
240            );
241        }
242    } else if args.dossier && risk_assessment.is_some() {
243        let risk = risk_assessment.as_ref().unwrap();
244        output_report(&report, format)?;
245        println!();
246        let risk_output =
247            crate::display::format_risk_report(risk, crate::display::OutputFormat::Table, true);
248        println!("{}", risk_output);
249    } else {
250        output_report(&report, format)?;
251    }
252
253    // Generate report if requested
254    if let Some(ref report_path) = args.report {
255        let markdown_report = if args.dossier {
256            risk_assessment
257                .as_ref()
258                .map(|r| crate::cli::address_report::generate_dossier_report(&report, r))
259                .unwrap_or_else(|| crate::cli::address_report::generate_address_report(&report))
260        } else {
261            crate::cli::address_report::generate_address_report(&report)
262        };
263        crate::cli::address_report::save_address_report(&markdown_report, report_path)?;
264        println!("\nReport saved to: {}", report_path.display());
265    }
266
267    Ok(())
268}
269
270/// Analyzes an address using a unified chain client.
271/// Exposed for use by batch report and other commands.
272pub async fn analyze_address(
273    args: &AddressArgs,
274    client: &dyn ChainClient,
275) -> Result<AddressReport> {
276    // Fetch balance
277    let mut chain_balance = client.get_balance(&args.address).await?;
278    client.enrich_balance_usd(&mut chain_balance).await;
279
280    let balance = Balance {
281        raw: chain_balance.raw.clone(),
282        formatted: chain_balance.formatted.clone(),
283        usd: chain_balance.usd_value,
284    };
285
286    // Fetch transactions if requested
287    let transactions = if args.include_txs {
288        match client.get_transactions(&args.address, args.limit).await {
289            Ok(txs) => Some(
290                txs.into_iter()
291                    .map(|tx| TransactionSummary {
292                        hash: tx.hash,
293                        block_number: tx.block_number.unwrap_or(0),
294                        timestamp: tx.timestamp.unwrap_or(0),
295                        from: tx.from,
296                        to: tx.to,
297                        value: tx.value,
298                        status: tx.status.unwrap_or(true),
299                    })
300                    .collect(),
301            ),
302            Err(e) => {
303                tracing::warn!("Failed to fetch transactions: {}", e);
304                Some(vec![])
305            }
306        }
307    } else {
308        None
309    };
310
311    // Transaction count is the number we fetched (or 0)
312    let transaction_count = transactions.as_ref().map(|t| t.len() as u64).unwrap_or(0);
313
314    // Fetch token balances if requested
315    let tokens = if args.include_tokens {
316        match client.get_token_balances(&args.address).await {
317            Ok(token_bals) => Some(
318                token_bals
319                    .into_iter()
320                    .map(|tb| TokenBalance {
321                        contract_address: tb.token.contract_address,
322                        symbol: tb.token.symbol,
323                        name: tb.token.name,
324                        decimals: tb.token.decimals,
325                        balance: tb.balance,
326                        formatted_balance: tb.formatted_balance,
327                    })
328                    .collect(),
329            ),
330            Err(e) => {
331                tracing::warn!("Failed to fetch token balances: {}", e);
332                Some(vec![])
333            }
334        }
335    } else {
336        None
337    };
338
339    Ok(AddressReport {
340        address: args.address.clone(),
341        chain: args.chain.clone(),
342        balance,
343        transaction_count,
344        transactions,
345        tokens,
346    })
347}
348
349/// Validates an address format for the given chain.
350fn validate_address(address: &str, chain: &str) -> Result<()> {
351    match chain {
352        // EVM-compatible chains use 0x-prefixed 40-char hex addresses
353        "ethereum" | "polygon" | "arbitrum" | "optimism" | "base" | "bsc" | "aegis" => {
354            if !address.starts_with("0x") {
355                return Err(crate::error::ScopeError::InvalidAddress(format!(
356                    "Address must start with '0x': {}",
357                    address
358                )));
359            }
360            if address.len() != 42 {
361                return Err(crate::error::ScopeError::InvalidAddress(format!(
362                    "Address must be 42 characters (0x + 40 hex): {}",
363                    address
364                )));
365            }
366            // Validate hex characters
367            if !address[2..].chars().all(|c| c.is_ascii_hexdigit()) {
368                return Err(crate::error::ScopeError::InvalidAddress(format!(
369                    "Address contains invalid hex characters: {}",
370                    address
371                )));
372            }
373        }
374        // Solana uses base58-encoded 32-byte addresses
375        "solana" => {
376            validate_solana_address(address)?;
377        }
378        // Tron uses T-prefixed base58check addresses
379        "tron" => {
380            validate_tron_address(address)?;
381        }
382        _ => {
383            return Err(crate::error::ScopeError::Chain(format!(
384                "Unsupported chain: {}. Supported: ethereum, polygon, arbitrum, optimism, base, bsc, solana, tron",
385                chain
386            )));
387        }
388    }
389    Ok(())
390}
391
392/// Outputs the address report in the specified format.
393fn output_report(report: &AddressReport, format: OutputFormat) -> Result<()> {
394    match format {
395        OutputFormat::Json => {
396            let json = serde_json::to_string_pretty(report)?;
397            println!("{}", json);
398        }
399        OutputFormat::Csv => {
400            // CSV format for address is a single row
401            println!("address,chain,balance,transaction_count");
402            println!(
403                "{},{},{},{}",
404                report.address, report.chain, report.balance.formatted, report.transaction_count
405            );
406        }
407        OutputFormat::Table => {
408            println!("Address Analysis Report");
409            println!("=======================");
410            println!("Address:      {}", report.address);
411            println!("Chain:        {}", report.chain);
412            println!("Balance:      {}", report.balance.formatted);
413            if let Some(usd) = report.balance.usd {
414                println!("Value (USD):  ${:.2}", usd);
415            }
416            println!("Transactions: {}", report.transaction_count);
417
418            if let Some(ref tokens) = report.tokens
419                && !tokens.is_empty()
420            {
421                println!("\nToken Balances:");
422                for token in tokens {
423                    println!(
424                        "  {} ({}): {}",
425                        token.name, token.symbol, token.formatted_balance
426                    );
427                }
428            }
429        }
430        OutputFormat::Markdown => {
431            println!(
432                "{}",
433                crate::cli::address_report::generate_address_report(report)
434            );
435        }
436    }
437    Ok(())
438}
439
440// ============================================================================
441// Unit Tests
442// ============================================================================
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn test_validate_address_valid_ethereum() {
450        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum");
451        assert!(result.is_ok());
452    }
453
454    #[test]
455    fn test_validate_address_valid_lowercase() {
456        let result = validate_address("0x742d35cc6634c0532925a3b844bc9e7595f1b3c2", "ethereum");
457        assert!(result.is_ok());
458    }
459
460    #[test]
461    fn test_validate_address_valid_polygon() {
462        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "polygon");
463        assert!(result.is_ok());
464    }
465
466    #[test]
467    fn test_validate_address_missing_prefix() {
468        let result = validate_address("742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum");
469        assert!(result.is_err());
470        assert!(result.unwrap_err().to_string().contains("0x"));
471    }
472
473    #[test]
474    fn test_validate_address_too_short() {
475        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3", "ethereum");
476        assert!(result.is_err());
477        assert!(result.unwrap_err().to_string().contains("42 characters"));
478    }
479
480    #[test]
481    fn test_validate_address_too_long() {
482        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2a", "ethereum");
483        assert!(result.is_err());
484    }
485
486    #[test]
487    fn test_validate_address_invalid_hex() {
488        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1bXYZ", "ethereum");
489        assert!(result.is_err());
490        assert!(result.unwrap_err().to_string().contains("invalid hex"));
491    }
492
493    #[test]
494    fn test_validate_address_unsupported_chain() {
495        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "bitcoin");
496        assert!(result.is_err());
497        assert!(
498            result
499                .unwrap_err()
500                .to_string()
501                .contains("Unsupported chain")
502        );
503    }
504
505    #[test]
506    fn test_validate_address_valid_bsc() {
507        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "bsc");
508        assert!(result.is_ok());
509    }
510
511    #[test]
512    fn test_validate_address_valid_aegis() {
513        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "aegis");
514        assert!(result.is_ok());
515    }
516
517    #[test]
518    fn test_validate_address_valid_solana() {
519        let result = validate_address("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy", "solana");
520        assert!(result.is_ok());
521    }
522
523    #[test]
524    fn test_validate_address_invalid_solana() {
525        // EVM address should fail for Solana
526        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "solana");
527        assert!(result.is_err());
528    }
529
530    #[test]
531    fn test_validate_address_valid_tron() {
532        let result = validate_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "tron");
533        assert!(result.is_ok());
534    }
535
536    #[test]
537    fn test_validate_address_invalid_tron() {
538        // EVM address should fail for Tron
539        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "tron");
540        assert!(result.is_err());
541    }
542
543    #[test]
544    fn test_address_args_default_values() {
545        use clap::Parser;
546
547        #[derive(Parser)]
548        struct TestCli {
549            #[command(flatten)]
550            args: AddressArgs,
551        }
552
553        let cli = TestCli::try_parse_from(["test", "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"])
554            .unwrap();
555
556        assert_eq!(cli.args.chain, "ethereum");
557        assert_eq!(cli.args.limit, 100);
558        assert!(!cli.args.include_txs);
559        assert!(!cli.args.include_tokens);
560        assert!(cli.args.format.is_none());
561    }
562
563    #[test]
564    fn test_address_args_with_options() {
565        use clap::Parser;
566
567        #[derive(Parser)]
568        struct TestCli {
569            #[command(flatten)]
570            args: AddressArgs,
571        }
572
573        let cli = TestCli::try_parse_from([
574            "test",
575            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
576            "--chain",
577            "polygon",
578            "--include-txs",
579            "--include-tokens",
580            "--limit",
581            "50",
582            "--format",
583            "json",
584        ])
585        .unwrap();
586
587        assert_eq!(cli.args.chain, "polygon");
588        assert_eq!(cli.args.limit, 50);
589        assert!(cli.args.include_txs);
590        assert!(cli.args.include_tokens);
591        assert_eq!(cli.args.format, Some(OutputFormat::Json));
592    }
593
594    #[test]
595    fn test_address_report_serialization() {
596        let report = AddressReport {
597            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
598            chain: "ethereum".to_string(),
599            balance: Balance {
600                raw: "1000000000000000000".to_string(),
601                formatted: "1.0".to_string(),
602                usd: Some(3500.0),
603            },
604            transaction_count: 42,
605            transactions: None,
606            tokens: None,
607        };
608
609        let json = serde_json::to_string(&report).unwrap();
610        assert!(json.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
611        assert!(json.contains("ethereum"));
612        assert!(json.contains("3500"));
613
614        // Verify None fields are skipped
615        assert!(!json.contains("transactions"));
616        assert!(!json.contains("tokens"));
617    }
618
619    #[test]
620    fn test_balance_serialization() {
621        let balance = Balance {
622            raw: "1000000000000000000".to_string(),
623            formatted: "1.0 ETH".to_string(),
624            usd: None,
625        };
626
627        let json = serde_json::to_string(&balance).unwrap();
628        assert!(json.contains("1000000000000000000"));
629        assert!(json.contains("1.0 ETH"));
630        assert!(!json.contains("usd")); // None should be skipped
631    }
632
633    #[test]
634    fn test_transaction_summary_serialization() {
635        let tx = TransactionSummary {
636            hash: "0xabc123".to_string(),
637            block_number: 12345,
638            timestamp: 1700000000,
639            from: "0xfrom".to_string(),
640            to: Some("0xto".to_string()),
641            value: "1.0".to_string(),
642            status: true,
643        };
644
645        let json = serde_json::to_string(&tx).unwrap();
646        assert!(json.contains("0xabc123"));
647        assert!(json.contains("12345"));
648        assert!(json.contains("true"));
649    }
650
651    #[test]
652    fn test_token_balance_serialization() {
653        let token = TokenBalance {
654            contract_address: "0xtoken".to_string(),
655            symbol: "USDC".to_string(),
656            name: "USD Coin".to_string(),
657            decimals: 6,
658            balance: "1000000".to_string(),
659            formatted_balance: "1.0".to_string(),
660        };
661
662        let json = serde_json::to_string(&token).unwrap();
663        assert!(json.contains("USDC"));
664        assert!(json.contains("USD Coin"));
665        assert!(json.contains("\"decimals\":6"));
666    }
667
668    // ========================================================================
669    // Output formatting tests
670    // ========================================================================
671
672    fn make_test_report() -> AddressReport {
673        AddressReport {
674            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
675            chain: "ethereum".to_string(),
676            balance: Balance {
677                raw: "1000000000000000000".to_string(),
678                formatted: "1.0 ETH".to_string(),
679                usd: Some(3500.0),
680            },
681            transaction_count: 42,
682            transactions: Some(vec![TransactionSummary {
683                hash: "0xabc".to_string(),
684                block_number: 12345,
685                timestamp: 1700000000,
686                from: "0xfrom".to_string(),
687                to: Some("0xto".to_string()),
688                value: "1.0".to_string(),
689                status: true,
690            }]),
691            tokens: Some(vec![TokenBalance {
692                contract_address: "0xusdc".to_string(),
693                symbol: "USDC".to_string(),
694                name: "USD Coin".to_string(),
695                decimals: 6,
696                balance: "1000000".to_string(),
697                formatted_balance: "1.0".to_string(),
698            }]),
699        }
700    }
701
702    #[test]
703    fn test_output_report_json() {
704        let report = make_test_report();
705        let result = output_report(&report, OutputFormat::Json);
706        assert!(result.is_ok());
707    }
708
709    #[test]
710    fn test_output_report_csv() {
711        let report = make_test_report();
712        let result = output_report(&report, OutputFormat::Csv);
713        assert!(result.is_ok());
714    }
715
716    #[test]
717    fn test_output_report_table() {
718        let report = make_test_report();
719        let result = output_report(&report, OutputFormat::Table);
720        assert!(result.is_ok());
721    }
722
723    #[test]
724    fn test_output_report_table_no_usd() {
725        let mut report = make_test_report();
726        report.balance.usd = None;
727        let result = output_report(&report, OutputFormat::Table);
728        assert!(result.is_ok());
729    }
730
731    #[test]
732    fn test_output_report_table_no_tokens() {
733        let mut report = make_test_report();
734        report.tokens = None;
735        let result = output_report(&report, OutputFormat::Table);
736        assert!(result.is_ok());
737    }
738
739    #[test]
740    fn test_output_report_table_empty_tokens() {
741        let mut report = make_test_report();
742        report.tokens = Some(vec![]);
743        let result = output_report(&report, OutputFormat::Table);
744        assert!(result.is_ok());
745    }
746
747    // ========================================================================
748    // Mock-based tests for analyze_address
749    // ========================================================================
750
751    use crate::chains::{
752        Balance as ChainBalance, ChainClient, Token as ChainToken,
753        TokenBalance as ChainTokenBalance, Transaction as ChainTransaction,
754    };
755    use async_trait::async_trait;
756
757    struct MockClient;
758
759    #[async_trait]
760    impl ChainClient for MockClient {
761        fn chain_name(&self) -> &str {
762            "ethereum"
763        }
764        fn native_token_symbol(&self) -> &str {
765            "ETH"
766        }
767        async fn get_balance(&self, _addr: &str) -> crate::error::Result<ChainBalance> {
768            Ok(ChainBalance {
769                raw: "1000000000000000000".into(),
770                formatted: "1.0 ETH".into(),
771                decimals: 18,
772                symbol: "ETH".into(),
773                usd_value: Some(2500.0),
774            })
775        }
776        async fn enrich_balance_usd(&self, b: &mut ChainBalance) {
777            b.usd_value = Some(2500.0);
778        }
779        async fn get_transaction(&self, _h: &str) -> crate::error::Result<ChainTransaction> {
780            Err(crate::error::ScopeError::NotFound("mock".into()))
781        }
782        async fn get_transactions(
783            &self,
784            _addr: &str,
785            _lim: u32,
786        ) -> crate::error::Result<Vec<ChainTransaction>> {
787            Ok(vec![ChainTransaction {
788                hash: "0x1234".into(),
789                block_number: Some(100),
790                timestamp: Some(1700000000),
791                from: "0xfrom".into(),
792                to: Some("0xto".into()),
793                value: "1000000000000000000".into(),
794                gas_limit: 21000,
795                gas_used: Some(21000),
796                gas_price: "20000000000".into(),
797                nonce: 1,
798                input: "0x".into(),
799                status: Some(true),
800            }])
801        }
802        async fn get_block_number(&self) -> crate::error::Result<u64> {
803            Ok(12345678)
804        }
805        async fn get_token_balances(
806            &self,
807            _addr: &str,
808        ) -> crate::error::Result<Vec<ChainTokenBalance>> {
809            Ok(vec![ChainTokenBalance {
810                token: ChainToken {
811                    contract_address: "0xtoken".into(),
812                    symbol: "USDC".into(),
813                    name: "USD Coin".into(),
814                    decimals: 6,
815                },
816                balance: "1000000".into(),
817                formatted_balance: "1.0".into(),
818                usd_value: Some(1.0),
819            }])
820        }
821        async fn get_code(&self, _addr: &str) -> crate::error::Result<String> {
822            Ok("0x".into())
823        }
824    }
825
826    #[tokio::test]
827    async fn test_analyze_address_with_mock() {
828        let args = AddressArgs {
829            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
830            chain: "ethereum".to_string(),
831            format: None,
832            include_txs: true,
833            include_tokens: true,
834            limit: 10,
835            report: None,
836            dossier: false,
837        };
838        let client = MockClient;
839        let result = analyze_address(&args, &client).await;
840        assert!(result.is_ok());
841        let report = result.unwrap();
842        assert_eq!(report.address, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
843        assert_eq!(report.chain, "ethereum");
844        assert!(report.tokens.is_some());
845        assert!(report.transactions.is_some());
846    }
847
848    #[tokio::test]
849    async fn test_analyze_address_no_txs_no_tokens() {
850        let args = AddressArgs {
851            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
852            chain: "ethereum".to_string(),
853            format: None,
854            include_txs: false,
855            include_tokens: false,
856            limit: 10,
857            report: None,
858            dossier: false,
859        };
860        let client = MockClient;
861        let result = analyze_address(&args, &client).await;
862        assert!(result.is_ok());
863    }
864
865    // ========================================================================
866    // End-to-end tests using MockClientFactory
867    // ========================================================================
868
869    use crate::chains::mocks::{MockChainClient, MockClientFactory};
870
871    fn mock_factory() -> MockClientFactory {
872        MockClientFactory::new()
873    }
874
875    #[tokio::test]
876    async fn test_run_ethereum_address() {
877        let config = Config::default();
878        let factory = mock_factory();
879        let args = AddressArgs {
880            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
881            chain: "ethereum".to_string(),
882            format: Some(OutputFormat::Json),
883            include_txs: false,
884            include_tokens: false,
885            limit: 10,
886            report: None,
887            dossier: false,
888        };
889        let result = super::run(args, &config, &factory).await;
890        assert!(result.is_ok());
891    }
892
893    #[tokio::test]
894    async fn test_run_with_transactions() {
895        let config = Config::default();
896        let mut factory = mock_factory();
897        factory.mock_client.transactions = vec![factory.mock_client.transaction.clone()];
898        let args = AddressArgs {
899            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
900            chain: "ethereum".to_string(),
901            format: Some(OutputFormat::Json),
902            include_txs: true,
903            include_tokens: false,
904            limit: 10,
905            report: None,
906            dossier: false,
907        };
908        let result = super::run(args, &config, &factory).await;
909        assert!(result.is_ok());
910    }
911
912    #[tokio::test]
913    async fn test_run_with_tokens() {
914        let config = Config::default();
915        let mut factory = mock_factory();
916        factory.mock_client.token_balances = vec![crate::chains::TokenBalance {
917            token: crate::chains::Token {
918                contract_address: "0xusdc".to_string(),
919                symbol: "USDC".to_string(),
920                name: "USD Coin".to_string(),
921                decimals: 6,
922            },
923            balance: "1000000".to_string(),
924            formatted_balance: "1.0".to_string(),
925            usd_value: Some(1.0),
926        }];
927        let args = AddressArgs {
928            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
929            chain: "ethereum".to_string(),
930            format: Some(OutputFormat::Table),
931            include_txs: false,
932            include_tokens: true,
933            limit: 10,
934            report: None,
935            dossier: false,
936        };
937        let result = super::run(args, &config, &factory).await;
938        assert!(result.is_ok());
939    }
940
941    #[tokio::test]
942    async fn test_run_auto_detect_solana() {
943        let config = Config::default();
944        let mut factory = mock_factory();
945        factory.mock_client = MockChainClient::new("solana", "SOL");
946        let args = AddressArgs {
947            // This is a Solana address format
948            address: "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy".to_string(),
949            chain: "ethereum".to_string(), // Will be auto-detected
950            format: Some(OutputFormat::Json),
951            include_txs: false,
952            include_tokens: false,
953            limit: 10,
954            report: None,
955            dossier: false,
956        };
957        let result = super::run(args, &config, &factory).await;
958        assert!(result.is_ok());
959    }
960
961    #[tokio::test]
962    async fn test_run_csv_format() {
963        let config = Config::default();
964        let factory = mock_factory();
965        let args = AddressArgs {
966            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
967            chain: "ethereum".to_string(),
968            format: Some(OutputFormat::Csv),
969            include_txs: false,
970            include_tokens: false,
971            limit: 10,
972            report: None,
973            dossier: false,
974        };
975        let result = super::run(args, &config, &factory).await;
976        assert!(result.is_ok());
977    }
978
979    #[tokio::test]
980    async fn test_run_all_features() {
981        let config = Config::default();
982        let mut factory = mock_factory();
983        factory.mock_client.transactions = vec![factory.mock_client.transaction.clone()];
984        factory.mock_client.token_balances = vec![crate::chains::TokenBalance {
985            token: crate::chains::Token {
986                contract_address: "0xtoken".to_string(),
987                symbol: "TEST".to_string(),
988                name: "Test Token".to_string(),
989                decimals: 18,
990            },
991            balance: "1000000000000000000".to_string(),
992            formatted_balance: "1.0".to_string(),
993            usd_value: None,
994        }];
995        let args = AddressArgs {
996            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
997            chain: "ethereum".to_string(),
998            format: Some(OutputFormat::Table),
999            include_txs: true,
1000            include_tokens: true,
1001            limit: 50,
1002            report: None,
1003            dossier: false,
1004        };
1005        let result = super::run(args, &config, &factory).await;
1006        assert!(result.is_ok());
1007    }
1008}