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