Skip to main content

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