Skip to main content

scope/cli/
address.rs

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