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(
30    after_help = "\x1b[1mExamples:\x1b[0m
31  scope address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2
32  scope address @main-wallet                              \x1b[2m# address book shortcut\x1b[0m
33  scope address 0x742d... --include-txs --include-tokens
34  scope address 0x742d... --dossier --report dossier.md
35  scope address DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy --chain solana",
36    after_long_help = "\x1b[1mExamples:\x1b[0m
37
38  \x1b[1m$ scope address 0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2\x1b[0m
39
40  Address Analysis Report
41  =======================
42  Address:      0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2
43  Chain:        ethereum
44  Balance:      1.234567 ETH
45  Value (USD):  $3,456.78
46  Transactions: 142
47
48  \x1b[1m$ scope address 0x742d... --dossier --report dossier.md\x1b[0m
49
50  Address Analysis Report
51  =======================
52  Address:      0x742d35Cc...f1b3c2
53  Chain:        ethereum
54  Balance:      1.234567 ETH
55  ...
56  Risk Assessment
57  =======================
58  Risk Score:   35/100 (Low)
59  Factors:      No sanctions matches, moderate tx volume
60  Report saved to dossier.md
61
62  \x1b[1m$ scope address DRpbCBMx...TDt1v --chain solana\x1b[0m
63
64  Address Analysis Report
65  =======================
66  Address:      DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy
67  Chain:        solana
68  Balance:      42.500000 SOL
69  Value (USD):  $5,312.50
70  Transactions: 87"
71)]
72pub struct AddressArgs {
73    /// The blockchain address to analyze.
74    ///
75    /// Must be a valid address format for the target chain
76    /// (e.g., 0x-prefixed 40-character hex for Ethereum).
77    /// Use @label to resolve from the address book (e.g., @main-wallet).
78    #[arg(value_name = "ADDRESS")]
79    pub address: String,
80
81    /// Target blockchain network.
82    ///
83    /// EVM chains: ethereum, polygon, arbitrum, optimism, base, bsc
84    /// Non-EVM chains: solana, tron
85    #[arg(short, long, default_value = "ethereum")]
86    pub chain: String,
87
88    /// Override output format for this command.
89    #[arg(short, long, value_name = "FORMAT")]
90    pub format: Option<OutputFormat>,
91
92    /// Include full transaction history.
93    #[arg(long)]
94    pub include_txs: bool,
95
96    /// Include token balances (ERC-20, ERC-721).
97    #[arg(long)]
98    pub include_tokens: bool,
99
100    /// Maximum number of transactions to retrieve.
101    #[arg(long, default_value = "100")]
102    pub limit: u32,
103
104    /// Generate and save a markdown report to the specified path.
105    #[arg(long, value_name = "PATH")]
106    pub report: Option<std::path::PathBuf>,
107
108    /// Produce a combined dossier: address analysis + risk assessment.
109    ///
110    /// Implies --include-txs and --include-tokens. Uses ETHERSCAN_API_KEY
111    /// for enhanced risk analysis on Ethereum.
112    #[arg(long, default_value_t = false)]
113    pub dossier: bool,
114}
115
116/// Result of an address analysis.
117#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
118pub struct AddressReport {
119    /// The analyzed address.
120    pub address: String,
121
122    /// The blockchain network.
123    pub chain: String,
124
125    /// Native token balance.
126    pub balance: Balance,
127
128    /// Transaction count (nonce).
129    pub transaction_count: u64,
130
131    /// Recent transactions (if requested).
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub transactions: Option<Vec<TransactionSummary>>,
134
135    /// Token balances (if requested).
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub tokens: Option<Vec<TokenBalance>>,
138}
139
140/// Balance representation with multiple units.
141#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
142pub struct Balance {
143    /// Raw balance in smallest unit (e.g., wei for Ethereum).
144    pub raw: String,
145
146    /// Human-readable balance in native token (e.g., ETH).
147    pub formatted: String,
148
149    /// Balance in USD (if price available).
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub usd: Option<f64>,
152}
153
154/// Summary of a transaction.
155#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
156pub struct TransactionSummary {
157    /// Transaction hash.
158    pub hash: String,
159
160    /// Block number.
161    pub block_number: u64,
162
163    /// Timestamp (Unix epoch).
164    pub timestamp: u64,
165
166    /// Sender address.
167    pub from: String,
168
169    /// Recipient address.
170    pub to: Option<String>,
171
172    /// Value transferred.
173    pub value: String,
174
175    /// Transaction status (success/failure).
176    pub status: bool,
177}
178
179/// Token balance information.
180#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
181pub struct TokenBalance {
182    /// Token contract address.
183    pub contract_address: String,
184
185    /// Token symbol.
186    pub symbol: String,
187
188    /// Token name.
189    pub name: String,
190
191    /// Token decimals.
192    pub decimals: u8,
193
194    /// Raw balance.
195    pub balance: String,
196
197    /// Formatted balance.
198    pub formatted_balance: String,
199}
200
201/// Executes the address analysis command.
202///
203/// # Arguments
204///
205/// * `args` - The parsed command arguments
206/// * `config` - Application configuration
207///
208/// # Returns
209///
210/// Returns `Ok(())` on success, or an error if the analysis fails.
211///
212/// # Errors
213///
214/// Returns `ScopeError::InvalidAddress` if the address format is invalid.
215/// Returns `ScopeError::Request` if API calls fail.
216pub async fn run(
217    mut args: AddressArgs,
218    config: &Config,
219    clients: &dyn ChainClientFactory,
220) -> Result<()> {
221    // Resolve address book label → address + chain
222    if let Some((address, chain)) =
223        crate::cli::address_book::resolve_address_book_input(&args.address, config)?
224    {
225        args.address = address;
226        if args.chain == "ethereum" {
227            args.chain = chain;
228        }
229    }
230
231    // Auto-infer chain if using default and address format is recognizable
232    if args.chain == "ethereum"
233        && let Some(inferred) = crate::chains::infer_chain_from_address(&args.address)
234        && inferred != "ethereum"
235    {
236        tracing::info!("Auto-detected chain: {}", inferred);
237        println!("Auto-detected chain: {}", inferred);
238        args.chain = inferred.to_string();
239    }
240
241    tracing::info!(
242        address = %args.address,
243        chain = %args.chain,
244        "Starting address analysis"
245    );
246
247    // Validate address format
248    validate_address(&args.address, &args.chain)?;
249
250    // Dossier implies full picture: txs + tokens
251    let mut analysis_args = args.clone();
252    if args.dossier {
253        analysis_args.include_txs = true;
254        analysis_args.include_tokens = true;
255    }
256
257    let sp = crate::cli::progress::Spinner::new(&format!("Analyzing address on {}...", args.chain));
258
259    let client = clients.create_chain_client(&args.chain)?;
260    let report = analyze_address(&analysis_args, client.as_ref()).await?;
261
262    // Dossier: fetch risk assessment (uses ETHERSCAN_API_KEY for Ethereum)
263    let risk_assessment = if args.dossier {
264        sp.set_message("Running risk assessment...");
265        let engine = match crate::compliance::datasource::BlockchainDataClient::from_env_opt() {
266            Some(client) => crate::compliance::risk::RiskEngine::with_data_client(client),
267            None => crate::compliance::risk::RiskEngine::new(),
268        };
269        engine.assess_address(&args.address, &args.chain).await.ok()
270    } else {
271        None
272    };
273
274    sp.finish("Analysis complete.");
275
276    // Output based on format
277    let format = args.format.unwrap_or(config.output.format);
278    if format == OutputFormat::Markdown {
279        if args.dossier && risk_assessment.as_ref().is_some() {
280            let risk = risk_assessment.as_ref().unwrap();
281            println!(
282                "{}",
283                crate::cli::address_report::generate_dossier_report(&report, risk)
284            );
285        } else {
286            println!(
287                "{}",
288                crate::cli::address_report::generate_address_report(&report)
289            );
290        }
291    } else if args.dossier && risk_assessment.is_some() {
292        let risk = risk_assessment.as_ref().unwrap();
293        output_report(&report, format)?;
294        println!();
295        let risk_output =
296            crate::display::format_risk_report(risk, crate::display::OutputFormat::Table, true);
297        println!("{}", risk_output);
298    } else {
299        output_report(&report, format)?;
300    }
301
302    // Generate report if requested
303    if let Some(ref report_path) = args.report {
304        let markdown_report = if args.dossier {
305            risk_assessment
306                .as_ref()
307                .map(|r| crate::cli::address_report::generate_dossier_report(&report, r))
308                .unwrap_or_else(|| crate::cli::address_report::generate_address_report(&report))
309        } else {
310            crate::cli::address_report::generate_address_report(&report)
311        };
312        crate::cli::address_report::save_address_report(&markdown_report, report_path)?;
313        println!("\nReport saved to: {}", report_path.display());
314    }
315
316    Ok(())
317}
318
319/// Analyzes an address using a unified chain client.
320/// Exposed for use by batch report and other commands.
321pub async fn analyze_address(
322    args: &AddressArgs,
323    client: &dyn ChainClient,
324) -> Result<AddressReport> {
325    // Fetch balance
326    let mut chain_balance = client.get_balance(&args.address).await?;
327    client.enrich_balance_usd(&mut chain_balance).await;
328
329    let balance = Balance {
330        raw: chain_balance.raw.clone(),
331        formatted: chain_balance.formatted.clone(),
332        usd: chain_balance.usd_value,
333    };
334
335    // Fetch transactions if requested
336    let transactions = if args.include_txs {
337        match client.get_transactions(&args.address, args.limit).await {
338            Ok(txs) => Some(
339                txs.into_iter()
340                    .map(|tx| TransactionSummary {
341                        hash: tx.hash,
342                        block_number: tx.block_number.unwrap_or(0),
343                        timestamp: tx.timestamp.unwrap_or(0),
344                        from: tx.from,
345                        to: tx.to,
346                        value: tx.value,
347                        status: tx.status.unwrap_or(true),
348                    })
349                    .collect(),
350            ),
351            Err(e) => {
352                eprintln!("  ⚠ Transaction history unavailable (use -v for details)");
353                tracing::debug!("Failed to fetch transactions: {}", e);
354                Some(vec![])
355            }
356        }
357    } else {
358        None
359    };
360
361    // Transaction count is the number we fetched (or 0)
362    let transaction_count = transactions.as_ref().map(|t| t.len() as u64).unwrap_or(0);
363
364    // Fetch token balances if requested
365    let tokens = if args.include_tokens {
366        match client.get_token_balances(&args.address).await {
367            Ok(token_bals) => Some(
368                token_bals
369                    .into_iter()
370                    .map(|tb| TokenBalance {
371                        contract_address: tb.token.contract_address,
372                        symbol: tb.token.symbol,
373                        name: tb.token.name,
374                        decimals: tb.token.decimals,
375                        balance: tb.balance,
376                        formatted_balance: tb.formatted_balance,
377                    })
378                    .collect(),
379            ),
380            Err(e) => {
381                eprintln!("  ⚠ Token balances unavailable (use -v for details)");
382                tracing::debug!("Failed to fetch token balances: {}", e);
383                Some(vec![])
384            }
385        }
386    } else {
387        None
388    };
389
390    Ok(AddressReport {
391        address: args.address.clone(),
392        chain: args.chain.clone(),
393        balance,
394        transaction_count,
395        transactions,
396        tokens,
397    })
398}
399
400/// Validates an address format for the given chain.
401fn validate_address(address: &str, chain: &str) -> Result<()> {
402    match chain {
403        // EVM-compatible chains use 0x-prefixed 40-char hex addresses
404        "ethereum" | "polygon" | "arbitrum" | "optimism" | "base" | "bsc" | "aegis" => {
405            if !address.starts_with("0x") {
406                return Err(crate::error::ScopeError::InvalidAddress(format!(
407                    "Address must start with '0x': {}",
408                    address
409                )));
410            }
411            if address.len() != 42 {
412                return Err(crate::error::ScopeError::InvalidAddress(format!(
413                    "Address must be 42 characters (0x + 40 hex): {}",
414                    address
415                )));
416            }
417            // Validate hex characters
418            if !address[2..].chars().all(|c| c.is_ascii_hexdigit()) {
419                return Err(crate::error::ScopeError::InvalidAddress(format!(
420                    "Address contains invalid hex characters: {}",
421                    address
422                )));
423            }
424        }
425        // Solana uses base58-encoded 32-byte addresses
426        "solana" => {
427            validate_solana_address(address)?;
428        }
429        // Tron uses T-prefixed base58check addresses
430        "tron" => {
431            validate_tron_address(address)?;
432        }
433        _ => {
434            return Err(crate::error::ScopeError::Chain(format!(
435                "Unsupported chain: {}. Supported: ethereum, polygon, arbitrum, optimism, base, bsc, solana, tron",
436                chain
437            )));
438        }
439    }
440    Ok(())
441}
442
443/// Outputs the address report in the specified format.
444fn output_report(report: &AddressReport, format: OutputFormat) -> Result<()> {
445    match format {
446        OutputFormat::Json => {
447            let json = serde_json::to_string_pretty(report)?;
448            println!("{}", json);
449        }
450        OutputFormat::Csv => {
451            // CSV format for address is a single row
452            println!("address,chain,balance,transaction_count");
453            println!(
454                "{},{},{},{}",
455                report.address, report.chain, report.balance.formatted, report.transaction_count
456            );
457        }
458        OutputFormat::Table => {
459            println!("Address Analysis Report");
460            println!("=======================");
461            println!("Address:      {}", report.address);
462            println!("Chain:        {}", report.chain);
463            println!("Balance:      {}", report.balance.formatted);
464            if let Some(usd) = report.balance.usd {
465                println!("Value (USD):  ${:.2}", usd);
466            }
467            println!("Transactions: {}", report.transaction_count);
468
469            if let Some(ref tokens) = report.tokens
470                && !tokens.is_empty()
471            {
472                println!("\nToken Balances:");
473                for token in tokens {
474                    println!(
475                        "  {} ({}): {}",
476                        token.name, token.symbol, token.formatted_balance
477                    );
478                }
479            }
480        }
481        OutputFormat::Markdown => {
482            println!(
483                "{}",
484                crate::cli::address_report::generate_address_report(report)
485            );
486        }
487    }
488    Ok(())
489}
490
491// ============================================================================
492// Unit Tests
493// ============================================================================
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn test_validate_address_valid_ethereum() {
501        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum");
502        assert!(result.is_ok());
503    }
504
505    #[test]
506    fn test_validate_address_valid_lowercase() {
507        let result = validate_address("0x742d35cc6634c0532925a3b844bc9e7595f1b3c2", "ethereum");
508        assert!(result.is_ok());
509    }
510
511    #[test]
512    fn test_validate_address_valid_polygon() {
513        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "polygon");
514        assert!(result.is_ok());
515    }
516
517    #[test]
518    fn test_validate_address_missing_prefix() {
519        let result = validate_address("742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum");
520        assert!(result.is_err());
521        assert!(result.unwrap_err().to_string().contains("0x"));
522    }
523
524    #[test]
525    fn test_validate_address_too_short() {
526        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3", "ethereum");
527        assert!(result.is_err());
528        assert!(result.unwrap_err().to_string().contains("42 characters"));
529    }
530
531    #[test]
532    fn test_validate_address_too_long() {
533        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2a", "ethereum");
534        assert!(result.is_err());
535    }
536
537    #[test]
538    fn test_validate_address_invalid_hex() {
539        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1bXYZ", "ethereum");
540        assert!(result.is_err());
541        assert!(result.unwrap_err().to_string().contains("invalid hex"));
542    }
543
544    #[test]
545    fn test_validate_address_unsupported_chain() {
546        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "bitcoin");
547        assert!(result.is_err());
548        assert!(
549            result
550                .unwrap_err()
551                .to_string()
552                .contains("Unsupported chain")
553        );
554    }
555
556    #[test]
557    fn test_validate_address_valid_bsc() {
558        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "bsc");
559        assert!(result.is_ok());
560    }
561
562    #[test]
563    fn test_validate_address_valid_aegis() {
564        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "aegis");
565        assert!(result.is_ok());
566    }
567
568    #[test]
569    fn test_validate_address_valid_arbitrum() {
570        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "arbitrum");
571        assert!(result.is_ok());
572    }
573
574    #[test]
575    fn test_validate_address_valid_optimism() {
576        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "optimism");
577        assert!(result.is_ok());
578    }
579
580    #[test]
581    fn test_validate_address_valid_base() {
582        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "base");
583        assert!(result.is_ok());
584    }
585
586    #[test]
587    fn test_validate_address_valid_solana() {
588        let result = validate_address("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy", "solana");
589        assert!(result.is_ok());
590    }
591
592    #[test]
593    fn test_validate_address_invalid_solana() {
594        // EVM address should fail for Solana
595        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "solana");
596        assert!(result.is_err());
597    }
598
599    #[test]
600    fn test_validate_address_valid_tron() {
601        let result = validate_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "tron");
602        assert!(result.is_ok());
603    }
604
605    #[test]
606    fn test_validate_address_invalid_tron() {
607        // EVM address should fail for Tron
608        let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "tron");
609        assert!(result.is_err());
610    }
611
612    #[test]
613    fn test_address_args_default_values() {
614        use clap::Parser;
615
616        #[derive(Parser)]
617        struct TestCli {
618            #[command(flatten)]
619            args: AddressArgs,
620        }
621
622        let cli = TestCli::try_parse_from(["test", "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"])
623            .unwrap();
624
625        assert_eq!(cli.args.chain, "ethereum");
626        assert_eq!(cli.args.limit, 100);
627        assert!(!cli.args.include_txs);
628        assert!(!cli.args.include_tokens);
629        assert!(cli.args.format.is_none());
630    }
631
632    #[test]
633    fn test_address_args_with_options() {
634        use clap::Parser;
635
636        #[derive(Parser)]
637        struct TestCli {
638            #[command(flatten)]
639            args: AddressArgs,
640        }
641
642        let cli = TestCli::try_parse_from([
643            "test",
644            "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
645            "--chain",
646            "polygon",
647            "--include-txs",
648            "--include-tokens",
649            "--limit",
650            "50",
651            "--format",
652            "json",
653        ])
654        .unwrap();
655
656        assert_eq!(cli.args.chain, "polygon");
657        assert_eq!(cli.args.limit, 50);
658        assert!(cli.args.include_txs);
659        assert!(cli.args.include_tokens);
660        assert_eq!(cli.args.format, Some(OutputFormat::Json));
661    }
662
663    #[test]
664    fn test_address_report_serialization() {
665        let report = AddressReport {
666            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
667            chain: "ethereum".to_string(),
668            balance: Balance {
669                raw: "1000000000000000000".to_string(),
670                formatted: "1.0".to_string(),
671                usd: Some(3500.0),
672            },
673            transaction_count: 42,
674            transactions: None,
675            tokens: None,
676        };
677
678        let json = serde_json::to_string(&report).unwrap();
679        assert!(json.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
680        assert!(json.contains("ethereum"));
681        assert!(json.contains("3500"));
682
683        // Verify None fields are skipped
684        assert!(!json.contains("transactions"));
685        assert!(!json.contains("tokens"));
686    }
687
688    #[test]
689    fn test_balance_serialization() {
690        let balance = Balance {
691            raw: "1000000000000000000".to_string(),
692            formatted: "1.0 ETH".to_string(),
693            usd: None,
694        };
695
696        let json = serde_json::to_string(&balance).unwrap();
697        assert!(json.contains("1000000000000000000"));
698        assert!(json.contains("1.0 ETH"));
699        assert!(!json.contains("usd")); // None should be skipped
700    }
701
702    #[test]
703    fn test_transaction_summary_serialization() {
704        let tx = TransactionSummary {
705            hash: "0xabc123".to_string(),
706            block_number: 12345,
707            timestamp: 1700000000,
708            from: "0xfrom".to_string(),
709            to: Some("0xto".to_string()),
710            value: "1.0".to_string(),
711            status: true,
712        };
713
714        let json = serde_json::to_string(&tx).unwrap();
715        assert!(json.contains("0xabc123"));
716        assert!(json.contains("12345"));
717        assert!(json.contains("true"));
718    }
719
720    #[test]
721    fn test_transaction_summary_with_none_to_and_false_status() {
722        let tx = TransactionSummary {
723            hash: "0xcontract".to_string(),
724            block_number: 999,
725            timestamp: 1700001000,
726            from: "0xfrom".to_string(),
727            to: None,
728            value: "0".to_string(),
729            status: false,
730        };
731        let json = serde_json::to_string(&tx).unwrap();
732        assert!(json.contains("0xcontract"));
733        assert!(json.contains("\"status\":false"));
734        let deserialized: TransactionSummary = serde_json::from_str(&json).unwrap();
735        assert!(deserialized.to.is_none());
736        assert!(!deserialized.status);
737    }
738
739    #[test]
740    fn test_address_report_deserialization() {
741        let json = r#"{
742            "address": "0xabc123",
743            "chain": "polygon",
744            "balance": {"raw": "1000", "formatted": "0.001 MATIC"},
745            "transaction_count": 5
746        }"#;
747        let report: AddressReport = serde_json::from_str(json).unwrap();
748        assert_eq!(report.address, "0xabc123");
749        assert_eq!(report.chain, "polygon");
750        assert_eq!(report.balance.raw, "1000");
751        assert_eq!(report.transaction_count, 5);
752        assert!(report.transactions.is_none());
753        assert!(report.tokens.is_none());
754    }
755
756    #[test]
757    fn test_balance_clone() {
758        let b = Balance {
759            raw: "1000".to_string(),
760            formatted: "1.0".to_string(),
761            usd: Some(2500.0),
762        };
763        let c = b.clone();
764        assert_eq!(b.raw, c.raw);
765        assert_eq!(b.usd, c.usd);
766    }
767
768    #[test]
769    fn test_address_report_clone() {
770        let report = make_test_report();
771        let cloned = report.clone();
772        assert_eq!(report.address, cloned.address);
773        assert_eq!(report.transaction_count, cloned.transaction_count);
774    }
775
776    #[test]
777    fn test_address_args_clone() {
778        let args = AddressArgs {
779            address: "0xabc".to_string(),
780            chain: "ethereum".to_string(),
781            format: Some(OutputFormat::Json),
782            include_txs: true,
783            include_tokens: true,
784            limit: 50,
785            report: None,
786            dossier: true,
787        };
788        let cloned = args.clone();
789        assert_eq!(args.address, cloned.address);
790        assert_eq!(args.dossier, cloned.dossier);
791    }
792
793    #[test]
794    fn test_token_balance_serialization() {
795        let token = TokenBalance {
796            contract_address: "0xtoken".to_string(),
797            symbol: "USDC".to_string(),
798            name: "USD Coin".to_string(),
799            decimals: 6,
800            balance: "1000000".to_string(),
801            formatted_balance: "1.0".to_string(),
802        };
803
804        let json = serde_json::to_string(&token).unwrap();
805        assert!(json.contains("USDC"));
806        assert!(json.contains("USD Coin"));
807        assert!(json.contains("\"decimals\":6"));
808    }
809
810    // ========================================================================
811    // Output formatting tests
812    // ========================================================================
813
814    fn make_test_report() -> AddressReport {
815        AddressReport {
816            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
817            chain: "ethereum".to_string(),
818            balance: Balance {
819                raw: "1000000000000000000".to_string(),
820                formatted: "1.0 ETH".to_string(),
821                usd: Some(3500.0),
822            },
823            transaction_count: 42,
824            transactions: Some(vec![TransactionSummary {
825                hash: "0xabc".to_string(),
826                block_number: 12345,
827                timestamp: 1700000000,
828                from: "0xfrom".to_string(),
829                to: Some("0xto".to_string()),
830                value: "1.0".to_string(),
831                status: true,
832            }]),
833            tokens: Some(vec![TokenBalance {
834                contract_address: "0xusdc".to_string(),
835                symbol: "USDC".to_string(),
836                name: "USD Coin".to_string(),
837                decimals: 6,
838                balance: "1000000".to_string(),
839                formatted_balance: "1.0".to_string(),
840            }]),
841        }
842    }
843
844    #[test]
845    fn test_output_report_json() {
846        let report = make_test_report();
847        let result = output_report(&report, OutputFormat::Json);
848        assert!(result.is_ok());
849    }
850
851    #[test]
852    fn test_output_report_csv() {
853        let report = make_test_report();
854        let result = output_report(&report, OutputFormat::Csv);
855        assert!(result.is_ok());
856    }
857
858    #[test]
859    fn test_output_report_table() {
860        let report = make_test_report();
861        let result = output_report(&report, OutputFormat::Table);
862        assert!(result.is_ok());
863    }
864
865    #[test]
866    fn test_output_report_table_no_usd() {
867        let mut report = make_test_report();
868        report.balance.usd = None;
869        let result = output_report(&report, OutputFormat::Table);
870        assert!(result.is_ok());
871    }
872
873    #[test]
874    fn test_output_report_table_no_tokens() {
875        let mut report = make_test_report();
876        report.tokens = None;
877        let result = output_report(&report, OutputFormat::Table);
878        assert!(result.is_ok());
879    }
880
881    #[test]
882    fn test_output_report_table_empty_tokens() {
883        let mut report = make_test_report();
884        report.tokens = Some(vec![]);
885        let result = output_report(&report, OutputFormat::Table);
886        assert!(result.is_ok());
887    }
888
889    #[test]
890    fn test_output_report_markdown() {
891        let report = make_test_report();
892        let result = output_report(&report, OutputFormat::Markdown);
893        assert!(result.is_ok());
894    }
895
896    // ========================================================================
897    // Mock-based tests for analyze_address
898    // ========================================================================
899
900    use crate::chains::{
901        Balance as ChainBalance, ChainClient, Token as ChainToken,
902        TokenBalance as ChainTokenBalance, Transaction as ChainTransaction,
903    };
904    use async_trait::async_trait;
905
906    struct MockClient;
907
908    #[async_trait]
909    impl ChainClient for MockClient {
910        fn chain_name(&self) -> &str {
911            "ethereum"
912        }
913        fn native_token_symbol(&self) -> &str {
914            "ETH"
915        }
916        async fn get_balance(&self, _addr: &str) -> crate::error::Result<ChainBalance> {
917            Ok(ChainBalance {
918                raw: "1000000000000000000".into(),
919                formatted: "1.0 ETH".into(),
920                decimals: 18,
921                symbol: "ETH".into(),
922                usd_value: Some(2500.0),
923            })
924        }
925        async fn enrich_balance_usd(&self, b: &mut ChainBalance) {
926            b.usd_value = Some(2500.0);
927        }
928        async fn get_transaction(&self, _h: &str) -> crate::error::Result<ChainTransaction> {
929            Err(crate::error::ScopeError::NotFound("mock".into()))
930        }
931        async fn get_transactions(
932            &self,
933            _addr: &str,
934            _lim: u32,
935        ) -> crate::error::Result<Vec<ChainTransaction>> {
936            Ok(vec![ChainTransaction {
937                hash: "0x1234".into(),
938                block_number: Some(100),
939                timestamp: Some(1700000000),
940                from: "0xfrom".into(),
941                to: Some("0xto".into()),
942                value: "1000000000000000000".into(),
943                gas_limit: 21000,
944                gas_used: Some(21000),
945                gas_price: "20000000000".into(),
946                nonce: 1,
947                input: "0x".into(),
948                status: Some(true),
949            }])
950        }
951        async fn get_block_number(&self) -> crate::error::Result<u64> {
952            Ok(12345678)
953        }
954        async fn get_token_balances(
955            &self,
956            _addr: &str,
957        ) -> crate::error::Result<Vec<ChainTokenBalance>> {
958            Ok(vec![ChainTokenBalance {
959                token: ChainToken {
960                    contract_address: "0xtoken".into(),
961                    symbol: "USDC".into(),
962                    name: "USD Coin".into(),
963                    decimals: 6,
964                },
965                balance: "1000000".into(),
966                formatted_balance: "1.0".into(),
967                usd_value: Some(1.0),
968            }])
969        }
970        async fn get_code(&self, _addr: &str) -> crate::error::Result<String> {
971            Ok("0x".into())
972        }
973    }
974
975    #[tokio::test]
976    async fn test_analyze_address_with_mock() {
977        let args = AddressArgs {
978            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
979            chain: "ethereum".to_string(),
980            format: None,
981            include_txs: true,
982            include_tokens: true,
983            limit: 10,
984            report: None,
985            dossier: false,
986        };
987        let client = MockClient;
988        let result = analyze_address(&args, &client).await;
989        assert!(result.is_ok());
990        let report = result.unwrap();
991        assert_eq!(report.address, "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2");
992        assert_eq!(report.chain, "ethereum");
993        assert!(report.tokens.is_some());
994        assert!(report.transactions.is_some());
995    }
996
997    #[tokio::test]
998    async fn test_analyze_address_no_txs_no_tokens() {
999        let args = AddressArgs {
1000            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1001            chain: "ethereum".to_string(),
1002            format: None,
1003            include_txs: false,
1004            include_tokens: false,
1005            limit: 10,
1006            report: None,
1007            dossier: false,
1008        };
1009        let client = MockClient;
1010        let result = analyze_address(&args, &client).await;
1011        assert!(result.is_ok());
1012    }
1013
1014    /// Mock client that returns Err for get_transactions; analyze_address should fall back to empty vec.
1015    struct FailTxMockClient;
1016    #[async_trait]
1017    impl ChainClient for FailTxMockClient {
1018        fn chain_name(&self) -> &str {
1019            "ethereum"
1020        }
1021        fn native_token_symbol(&self) -> &str {
1022            "ETH"
1023        }
1024        async fn get_balance(&self, _addr: &str) -> crate::error::Result<ChainBalance> {
1025            Ok(ChainBalance {
1026                raw: "1000000000000000000".into(),
1027                formatted: "1.0 ETH".into(),
1028                decimals: 18,
1029                symbol: "ETH".into(),
1030                usd_value: Some(2500.0),
1031            })
1032        }
1033        async fn enrich_balance_usd(&self, _b: &mut ChainBalance) {}
1034        async fn get_transaction(&self, _h: &str) -> crate::error::Result<ChainTransaction> {
1035            Err(crate::error::ScopeError::NotFound("mock".into()))
1036        }
1037        async fn get_transactions(
1038            &self,
1039            _addr: &str,
1040            _lim: u32,
1041        ) -> crate::error::Result<Vec<ChainTransaction>> {
1042            Err(crate::error::ScopeError::Chain("tx fetch failed".into()))
1043        }
1044        async fn get_block_number(&self) -> crate::error::Result<u64> {
1045            Ok(12345678)
1046        }
1047        async fn get_token_balances(
1048            &self,
1049            _addr: &str,
1050        ) -> crate::error::Result<Vec<ChainTokenBalance>> {
1051            Ok(vec![])
1052        }
1053        async fn get_code(&self, _addr: &str) -> crate::error::Result<String> {
1054            Ok("0x".into())
1055        }
1056    }
1057
1058    /// Mock client that returns Err for get_token_balances; analyze_address should fall back to empty vec.
1059    struct FailTokenBalancesMockClient;
1060    #[async_trait]
1061    impl ChainClient for FailTokenBalancesMockClient {
1062        fn chain_name(&self) -> &str {
1063            "ethereum"
1064        }
1065        fn native_token_symbol(&self) -> &str {
1066            "ETH"
1067        }
1068        async fn get_balance(&self, _addr: &str) -> crate::error::Result<ChainBalance> {
1069            Ok(ChainBalance {
1070                raw: "1000000000000000000".into(),
1071                formatted: "1.0 ETH".into(),
1072                decimals: 18,
1073                symbol: "ETH".into(),
1074                usd_value: Some(2500.0),
1075            })
1076        }
1077        async fn enrich_balance_usd(&self, _b: &mut ChainBalance) {}
1078        async fn get_transaction(&self, _h: &str) -> crate::error::Result<ChainTransaction> {
1079            Err(crate::error::ScopeError::NotFound("mock".into()))
1080        }
1081        async fn get_transactions(
1082            &self,
1083            _addr: &str,
1084            _lim: u32,
1085        ) -> crate::error::Result<Vec<ChainTransaction>> {
1086            Ok(vec![])
1087        }
1088        async fn get_block_number(&self) -> crate::error::Result<u64> {
1089            Ok(12345678)
1090        }
1091        async fn get_token_balances(
1092            &self,
1093            _addr: &str,
1094        ) -> crate::error::Result<Vec<ChainTokenBalance>> {
1095            Err(crate::error::ScopeError::Chain(
1096                "token balances fetch failed".into(),
1097            ))
1098        }
1099        async fn get_code(&self, _addr: &str) -> crate::error::Result<String> {
1100            Ok("0x".into())
1101        }
1102    }
1103
1104    /// Mock client that returns a transaction with None for block_number, timestamp, status.
1105    struct PartialTxMockClient;
1106    #[async_trait]
1107    impl ChainClient for PartialTxMockClient {
1108        fn chain_name(&self) -> &str {
1109            "ethereum"
1110        }
1111        fn native_token_symbol(&self) -> &str {
1112            "ETH"
1113        }
1114        async fn get_balance(&self, _addr: &str) -> crate::error::Result<ChainBalance> {
1115            Ok(ChainBalance {
1116                raw: "0".into(),
1117                formatted: "0 ETH".into(),
1118                decimals: 18,
1119                symbol: "ETH".into(),
1120                usd_value: None,
1121            })
1122        }
1123        async fn enrich_balance_usd(&self, _b: &mut ChainBalance) {}
1124        async fn get_transaction(&self, _h: &str) -> crate::error::Result<ChainTransaction> {
1125            Err(crate::error::ScopeError::NotFound("mock".into()))
1126        }
1127        async fn get_transactions(
1128            &self,
1129            _addr: &str,
1130            _lim: u32,
1131        ) -> crate::error::Result<Vec<ChainTransaction>> {
1132            Ok(vec![ChainTransaction {
1133                hash: "0xpartial".into(),
1134                block_number: None,
1135                timestamp: None,
1136                from: "0xfrom".into(),
1137                to: None,
1138                value: "0.5".into(),
1139                gas_limit: 21000,
1140                gas_used: Some(21000),
1141                gas_price: "20000000000".into(),
1142                nonce: 1,
1143                input: "0x".into(),
1144                status: None,
1145            }])
1146        }
1147        async fn get_block_number(&self) -> crate::error::Result<u64> {
1148            Ok(1)
1149        }
1150        async fn get_token_balances(
1151            &self,
1152            _addr: &str,
1153        ) -> crate::error::Result<Vec<ChainTokenBalance>> {
1154            Ok(vec![])
1155        }
1156        async fn get_code(&self, _addr: &str) -> crate::error::Result<String> {
1157            Ok("0x".into())
1158        }
1159    }
1160
1161    #[tokio::test]
1162    async fn test_analyze_address_tx_fallback_on_error() {
1163        let args = AddressArgs {
1164            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1165            chain: "ethereum".to_string(),
1166            format: None,
1167            include_txs: true,
1168            include_tokens: false,
1169            limit: 10,
1170            report: None,
1171            dossier: false,
1172        };
1173        let client = FailTxMockClient;
1174        let result = analyze_address(&args, &client).await;
1175        assert!(result.is_ok());
1176        let report = result.unwrap();
1177        assert!(
1178            report
1179                .transactions
1180                .as_ref()
1181                .map(|v| v.is_empty())
1182                .unwrap_or(false)
1183        );
1184    }
1185
1186    #[tokio::test]
1187    async fn test_analyze_address_tokens_fallback_on_error() {
1188        let args = AddressArgs {
1189            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1190            chain: "ethereum".to_string(),
1191            format: None,
1192            include_txs: false,
1193            include_tokens: true,
1194            limit: 10,
1195            report: None,
1196            dossier: false,
1197        };
1198        let client = FailTokenBalancesMockClient;
1199        let result = analyze_address(&args, &client).await;
1200        assert!(result.is_ok());
1201        let report = result.unwrap();
1202        assert!(
1203            report
1204                .tokens
1205                .as_ref()
1206                .map(|v| v.is_empty())
1207                .unwrap_or(false)
1208        );
1209    }
1210
1211    #[tokio::test]
1212    async fn test_analyze_address_tx_with_none_fields() {
1213        let args = AddressArgs {
1214            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1215            chain: "ethereum".to_string(),
1216            format: None,
1217            include_txs: true,
1218            include_tokens: false,
1219            limit: 10,
1220            report: None,
1221            dossier: false,
1222        };
1223        let client = PartialTxMockClient;
1224        let result = analyze_address(&args, &client).await;
1225        assert!(result.is_ok());
1226        let report = result.unwrap();
1227        let txs = report.transactions.unwrap();
1228        assert_eq!(txs.len(), 1);
1229        assert_eq!(txs[0].block_number, 0);
1230        assert_eq!(txs[0].timestamp, 0);
1231        assert_eq!(txs[0].to, None);
1232        assert!(txs[0].status); // unwrap_or(true) when None
1233    }
1234
1235    // ========================================================================
1236    // End-to-end tests using MockClientFactory
1237    // ========================================================================
1238
1239    use crate::chains::mocks::{MockChainClient, MockClientFactory};
1240
1241    fn mock_factory() -> MockClientFactory {
1242        MockClientFactory::new()
1243    }
1244
1245    #[tokio::test]
1246    async fn test_run_ethereum_address() {
1247        let config = Config::default();
1248        let factory = mock_factory();
1249        let args = AddressArgs {
1250            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1251            chain: "ethereum".to_string(),
1252            format: Some(OutputFormat::Json),
1253            include_txs: false,
1254            include_tokens: false,
1255            limit: 10,
1256            report: None,
1257            dossier: false,
1258        };
1259        let result = super::run(args, &config, &factory).await;
1260        assert!(result.is_ok());
1261    }
1262
1263    #[tokio::test]
1264    async fn test_run_with_transactions() {
1265        let config = Config::default();
1266        let mut factory = mock_factory();
1267        factory.mock_client.transactions = vec![factory.mock_client.transaction.clone()];
1268        let args = AddressArgs {
1269            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1270            chain: "ethereum".to_string(),
1271            format: Some(OutputFormat::Json),
1272            include_txs: true,
1273            include_tokens: false,
1274            limit: 10,
1275            report: None,
1276            dossier: false,
1277        };
1278        let result = super::run(args, &config, &factory).await;
1279        assert!(result.is_ok());
1280    }
1281
1282    #[tokio::test]
1283    async fn test_run_with_tokens() {
1284        let config = Config::default();
1285        let mut factory = mock_factory();
1286        factory.mock_client.token_balances = vec![crate::chains::TokenBalance {
1287            token: crate::chains::Token {
1288                contract_address: "0xusdc".to_string(),
1289                symbol: "USDC".to_string(),
1290                name: "USD Coin".to_string(),
1291                decimals: 6,
1292            },
1293            balance: "1000000".to_string(),
1294            formatted_balance: "1.0".to_string(),
1295            usd_value: Some(1.0),
1296        }];
1297        let args = AddressArgs {
1298            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1299            chain: "ethereum".to_string(),
1300            format: Some(OutputFormat::Table),
1301            include_txs: false,
1302            include_tokens: true,
1303            limit: 10,
1304            report: None,
1305            dossier: false,
1306        };
1307        let result = super::run(args, &config, &factory).await;
1308        assert!(result.is_ok());
1309    }
1310
1311    #[tokio::test]
1312    async fn test_run_auto_detect_solana() {
1313        let config = Config::default();
1314        let mut factory = mock_factory();
1315        factory.mock_client = MockChainClient::new("solana", "SOL");
1316        let args = AddressArgs {
1317            // This is a Solana address format
1318            address: "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy".to_string(),
1319            chain: "ethereum".to_string(), // Will be auto-detected
1320            format: Some(OutputFormat::Json),
1321            include_txs: false,
1322            include_tokens: false,
1323            limit: 10,
1324            report: None,
1325            dossier: false,
1326        };
1327        let result = super::run(args, &config, &factory).await;
1328        assert!(result.is_ok());
1329    }
1330
1331    #[tokio::test]
1332    async fn test_run_csv_format() {
1333        let config = Config::default();
1334        let factory = mock_factory();
1335        let args = AddressArgs {
1336            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1337            chain: "ethereum".to_string(),
1338            format: Some(OutputFormat::Csv),
1339            include_txs: false,
1340            include_tokens: false,
1341            limit: 10,
1342            report: None,
1343            dossier: false,
1344        };
1345        let result = super::run(args, &config, &factory).await;
1346        assert!(result.is_ok());
1347    }
1348
1349    #[test]
1350    fn test_address_args_debug() {
1351        let args = AddressArgs {
1352            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1353            chain: "ethereum".to_string(),
1354            format: None,
1355            include_txs: false,
1356            include_tokens: false,
1357            limit: 100,
1358            report: None,
1359            dossier: false,
1360        };
1361        let debug = format!("{:?}", args);
1362        assert!(debug.contains("AddressArgs"));
1363    }
1364
1365    #[tokio::test]
1366    async fn test_run_all_features() {
1367        let config = Config::default();
1368        let mut factory = mock_factory();
1369        factory.mock_client.transactions = vec![factory.mock_client.transaction.clone()];
1370        factory.mock_client.token_balances = vec![crate::chains::TokenBalance {
1371            token: crate::chains::Token {
1372                contract_address: "0xtoken".to_string(),
1373                symbol: "TEST".to_string(),
1374                name: "Test Token".to_string(),
1375                decimals: 18,
1376            },
1377            balance: "1000000000000000000".to_string(),
1378            formatted_balance: "1.0".to_string(),
1379            usd_value: None,
1380        }];
1381        let args = AddressArgs {
1382            address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
1383            chain: "ethereum".to_string(),
1384            format: Some(OutputFormat::Table),
1385            include_txs: true,
1386            include_tokens: true,
1387            limit: 50,
1388            report: None,
1389            dossier: false,
1390        };
1391        let result = super::run(args, &config, &factory).await;
1392        assert!(result.is_ok());
1393    }
1394}