1use 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#[derive(Debug, Clone, Args)]
29pub struct AddressArgs {
30 #[arg(value_name = "ADDRESS")]
35 pub address: String,
36
37 #[arg(short, long, default_value = "ethereum")]
42 pub chain: String,
43
44 #[arg(short, long, value_name = "FORMAT")]
46 pub format: Option<OutputFormat>,
47
48 #[arg(long)]
50 pub include_txs: bool,
51
52 #[arg(long)]
54 pub include_tokens: bool,
55
56 #[arg(long, default_value = "100")]
58 pub limit: u32,
59}
60
61#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63pub struct AddressReport {
64 pub address: String,
66
67 pub chain: String,
69
70 pub balance: Balance,
72
73 pub transaction_count: u64,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub transactions: Option<Vec<TransactionSummary>>,
79
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub tokens: Option<Vec<TokenBalance>>,
83}
84
85#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
87pub struct Balance {
88 pub raw: String,
90
91 pub formatted: String,
93
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub usd: Option<f64>,
97}
98
99#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
101pub struct TransactionSummary {
102 pub hash: String,
104
105 pub block_number: u64,
107
108 pub timestamp: u64,
110
111 pub from: String,
113
114 pub to: Option<String>,
116
117 pub value: String,
119
120 pub status: bool,
122}
123
124#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
126pub struct TokenBalance {
127 pub contract_address: String,
129
130 pub symbol: String,
132
133 pub name: String,
135
136 pub decimals: u8,
138
139 pub balance: String,
141
142 pub formatted_balance: String,
144}
145
146pub async fn run(
162 mut args: AddressArgs,
163 config: &Config,
164 clients: &dyn ChainClientFactory,
165) -> Result<()> {
166 if args.chain == "ethereum"
168 && let Some(inferred) = crate::chains::infer_chain_from_address(&args.address)
169 && inferred != "ethereum"
170 {
171 tracing::info!("Auto-detected chain: {}", inferred);
172 println!("Auto-detected chain: {}", inferred);
173 args.chain = inferred.to_string();
174 }
175
176 tracing::info!(
177 address = %args.address,
178 chain = %args.chain,
179 "Starting address analysis"
180 );
181
182 validate_address(&args.address, &args.chain)?;
184
185 println!("Analyzing address on {}...", args.chain);
186
187 let client = clients.create_chain_client(&args.chain)?;
188 let report = analyze_address(&args, client.as_ref()).await?;
189
190 let format = args.format.unwrap_or(config.output.format);
192 output_report(&report, format)?;
193
194 Ok(())
195}
196
197async fn analyze_address(args: &AddressArgs, client: &dyn ChainClient) -> Result<AddressReport> {
199 let mut chain_balance = client.get_balance(&args.address).await?;
201 client.enrich_balance_usd(&mut chain_balance).await;
202
203 let balance = Balance {
204 raw: chain_balance.raw.clone(),
205 formatted: chain_balance.formatted.clone(),
206 usd: chain_balance.usd_value,
207 };
208
209 let transactions = if args.include_txs {
211 match client.get_transactions(&args.address, args.limit).await {
212 Ok(txs) => Some(
213 txs.into_iter()
214 .map(|tx| TransactionSummary {
215 hash: tx.hash,
216 block_number: tx.block_number.unwrap_or(0),
217 timestamp: tx.timestamp.unwrap_or(0),
218 from: tx.from,
219 to: tx.to,
220 value: tx.value,
221 status: tx.status.unwrap_or(true),
222 })
223 .collect(),
224 ),
225 Err(e) => {
226 tracing::warn!("Failed to fetch transactions: {}", e);
227 Some(vec![])
228 }
229 }
230 } else {
231 None
232 };
233
234 let transaction_count = transactions.as_ref().map(|t| t.len() as u64).unwrap_or(0);
236
237 let tokens = if args.include_tokens {
239 match client.get_token_balances(&args.address).await {
240 Ok(token_bals) => Some(
241 token_bals
242 .into_iter()
243 .map(|tb| TokenBalance {
244 contract_address: tb.token.contract_address,
245 symbol: tb.token.symbol,
246 name: tb.token.name,
247 decimals: tb.token.decimals,
248 balance: tb.balance,
249 formatted_balance: tb.formatted_balance,
250 })
251 .collect(),
252 ),
253 Err(e) => {
254 tracing::warn!("Failed to fetch token balances: {}", e);
255 Some(vec![])
256 }
257 }
258 } else {
259 None
260 };
261
262 Ok(AddressReport {
263 address: args.address.clone(),
264 chain: args.chain.clone(),
265 balance,
266 transaction_count,
267 transactions,
268 tokens,
269 })
270}
271
272fn validate_address(address: &str, chain: &str) -> Result<()> {
274 match chain {
275 "ethereum" | "polygon" | "arbitrum" | "optimism" | "base" | "bsc" | "aegis" => {
277 if !address.starts_with("0x") {
278 return Err(crate::error::ScopeError::InvalidAddress(format!(
279 "Address must start with '0x': {}",
280 address
281 )));
282 }
283 if address.len() != 42 {
284 return Err(crate::error::ScopeError::InvalidAddress(format!(
285 "Address must be 42 characters (0x + 40 hex): {}",
286 address
287 )));
288 }
289 if !address[2..].chars().all(|c| c.is_ascii_hexdigit()) {
291 return Err(crate::error::ScopeError::InvalidAddress(format!(
292 "Address contains invalid hex characters: {}",
293 address
294 )));
295 }
296 }
297 "solana" => {
299 validate_solana_address(address)?;
300 }
301 "tron" => {
303 validate_tron_address(address)?;
304 }
305 _ => {
306 return Err(crate::error::ScopeError::Chain(format!(
307 "Unsupported chain: {}. Supported: ethereum, polygon, arbitrum, optimism, base, bsc, aegis, solana, tron",
308 chain
309 )));
310 }
311 }
312 Ok(())
313}
314
315fn output_report(report: &AddressReport, format: OutputFormat) -> Result<()> {
317 match format {
318 OutputFormat::Json => {
319 let json = serde_json::to_string_pretty(report)?;
320 println!("{}", json);
321 }
322 OutputFormat::Csv => {
323 println!("address,chain,balance,transaction_count");
325 println!(
326 "{},{},{},{}",
327 report.address, report.chain, report.balance.formatted, report.transaction_count
328 );
329 }
330 OutputFormat::Table => {
331 println!("Address Analysis Report");
332 println!("=======================");
333 println!("Address: {}", report.address);
334 println!("Chain: {}", report.chain);
335 println!("Balance: {}", report.balance.formatted);
336 if let Some(usd) = report.balance.usd {
337 println!("Value (USD): ${:.2}", usd);
338 }
339 println!("Transactions: {}", report.transaction_count);
340
341 if let Some(ref tokens) = report.tokens
342 && !tokens.is_empty()
343 {
344 println!("\nToken Balances:");
345 for token in tokens {
346 println!(
347 " {} ({}): {}",
348 token.name, token.symbol, token.formatted_balance
349 );
350 }
351 }
352 }
353 }
354 Ok(())
355}
356
357#[cfg(test)]
362mod tests {
363 use super::*;
364
365 #[test]
366 fn test_validate_address_valid_ethereum() {
367 let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum");
368 assert!(result.is_ok());
369 }
370
371 #[test]
372 fn test_validate_address_valid_lowercase() {
373 let result = validate_address("0x742d35cc6634c0532925a3b844bc9e7595f1b3c2", "ethereum");
374 assert!(result.is_ok());
375 }
376
377 #[test]
378 fn test_validate_address_valid_polygon() {
379 let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "polygon");
380 assert!(result.is_ok());
381 }
382
383 #[test]
384 fn test_validate_address_missing_prefix() {
385 let result = validate_address("742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "ethereum");
386 assert!(result.is_err());
387 assert!(result.unwrap_err().to_string().contains("0x"));
388 }
389
390 #[test]
391 fn test_validate_address_too_short() {
392 let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3", "ethereum");
393 assert!(result.is_err());
394 assert!(result.unwrap_err().to_string().contains("42 characters"));
395 }
396
397 #[test]
398 fn test_validate_address_too_long() {
399 let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2a", "ethereum");
400 assert!(result.is_err());
401 }
402
403 #[test]
404 fn test_validate_address_invalid_hex() {
405 let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1bXYZ", "ethereum");
406 assert!(result.is_err());
407 assert!(result.unwrap_err().to_string().contains("invalid hex"));
408 }
409
410 #[test]
411 fn test_validate_address_unsupported_chain() {
412 let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "bitcoin");
413 assert!(result.is_err());
414 assert!(
415 result
416 .unwrap_err()
417 .to_string()
418 .contains("Unsupported chain")
419 );
420 }
421
422 #[test]
423 fn test_validate_address_valid_bsc() {
424 let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "bsc");
425 assert!(result.is_ok());
426 }
427
428 #[test]
429 fn test_validate_address_valid_aegis() {
430 let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "aegis");
431 assert!(result.is_ok());
432 }
433
434 #[test]
435 fn test_validate_address_valid_solana() {
436 let result = validate_address("DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy", "solana");
437 assert!(result.is_ok());
438 }
439
440 #[test]
441 fn test_validate_address_invalid_solana() {
442 let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "solana");
444 assert!(result.is_err());
445 }
446
447 #[test]
448 fn test_validate_address_valid_tron() {
449 let result = validate_address("TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf", "tron");
450 assert!(result.is_ok());
451 }
452
453 #[test]
454 fn test_validate_address_invalid_tron() {
455 let result = validate_address("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2", "tron");
457 assert!(result.is_err());
458 }
459
460 #[test]
461 fn test_address_args_default_values() {
462 use clap::Parser;
463
464 #[derive(Parser)]
465 struct TestCli {
466 #[command(flatten)]
467 args: AddressArgs,
468 }
469
470 let cli = TestCli::try_parse_from(["test", "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"])
471 .unwrap();
472
473 assert_eq!(cli.args.chain, "ethereum");
474 assert_eq!(cli.args.limit, 100);
475 assert!(!cli.args.include_txs);
476 assert!(!cli.args.include_tokens);
477 assert!(cli.args.format.is_none());
478 }
479
480 #[test]
481 fn test_address_args_with_options() {
482 use clap::Parser;
483
484 #[derive(Parser)]
485 struct TestCli {
486 #[command(flatten)]
487 args: AddressArgs,
488 }
489
490 let cli = TestCli::try_parse_from([
491 "test",
492 "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2",
493 "--chain",
494 "polygon",
495 "--include-txs",
496 "--include-tokens",
497 "--limit",
498 "50",
499 "--format",
500 "json",
501 ])
502 .unwrap();
503
504 assert_eq!(cli.args.chain, "polygon");
505 assert_eq!(cli.args.limit, 50);
506 assert!(cli.args.include_txs);
507 assert!(cli.args.include_tokens);
508 assert_eq!(cli.args.format, Some(OutputFormat::Json));
509 }
510
511 #[test]
512 fn test_address_report_serialization() {
513 let report = AddressReport {
514 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
515 chain: "ethereum".to_string(),
516 balance: Balance {
517 raw: "1000000000000000000".to_string(),
518 formatted: "1.0".to_string(),
519 usd: Some(3500.0),
520 },
521 transaction_count: 42,
522 transactions: None,
523 tokens: None,
524 };
525
526 let json = serde_json::to_string(&report).unwrap();
527 assert!(json.contains("0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2"));
528 assert!(json.contains("ethereum"));
529 assert!(json.contains("3500"));
530
531 assert!(!json.contains("transactions"));
533 assert!(!json.contains("tokens"));
534 }
535
536 #[test]
537 fn test_balance_serialization() {
538 let balance = Balance {
539 raw: "1000000000000000000".to_string(),
540 formatted: "1.0 ETH".to_string(),
541 usd: None,
542 };
543
544 let json = serde_json::to_string(&balance).unwrap();
545 assert!(json.contains("1000000000000000000"));
546 assert!(json.contains("1.0 ETH"));
547 assert!(!json.contains("usd")); }
549
550 #[test]
551 fn test_transaction_summary_serialization() {
552 let tx = TransactionSummary {
553 hash: "0xabc123".to_string(),
554 block_number: 12345,
555 timestamp: 1700000000,
556 from: "0xfrom".to_string(),
557 to: Some("0xto".to_string()),
558 value: "1.0".to_string(),
559 status: true,
560 };
561
562 let json = serde_json::to_string(&tx).unwrap();
563 assert!(json.contains("0xabc123"));
564 assert!(json.contains("12345"));
565 assert!(json.contains("true"));
566 }
567
568 #[test]
569 fn test_token_balance_serialization() {
570 let token = TokenBalance {
571 contract_address: "0xtoken".to_string(),
572 symbol: "USDC".to_string(),
573 name: "USD Coin".to_string(),
574 decimals: 6,
575 balance: "1000000".to_string(),
576 formatted_balance: "1.0".to_string(),
577 };
578
579 let json = serde_json::to_string(&token).unwrap();
580 assert!(json.contains("USDC"));
581 assert!(json.contains("USD Coin"));
582 assert!(json.contains("\"decimals\":6"));
583 }
584
585 fn make_test_report() -> AddressReport {
590 AddressReport {
591 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
592 chain: "ethereum".to_string(),
593 balance: Balance {
594 raw: "1000000000000000000".to_string(),
595 formatted: "1.0 ETH".to_string(),
596 usd: Some(3500.0),
597 },
598 transaction_count: 42,
599 transactions: Some(vec![TransactionSummary {
600 hash: "0xabc".to_string(),
601 block_number: 12345,
602 timestamp: 1700000000,
603 from: "0xfrom".to_string(),
604 to: Some("0xto".to_string()),
605 value: "1.0".to_string(),
606 status: true,
607 }]),
608 tokens: Some(vec![TokenBalance {
609 contract_address: "0xusdc".to_string(),
610 symbol: "USDC".to_string(),
611 name: "USD Coin".to_string(),
612 decimals: 6,
613 balance: "1000000".to_string(),
614 formatted_balance: "1.0".to_string(),
615 }]),
616 }
617 }
618
619 #[test]
620 fn test_output_report_json() {
621 let report = make_test_report();
622 let result = output_report(&report, OutputFormat::Json);
623 assert!(result.is_ok());
624 }
625
626 #[test]
627 fn test_output_report_csv() {
628 let report = make_test_report();
629 let result = output_report(&report, OutputFormat::Csv);
630 assert!(result.is_ok());
631 }
632
633 #[test]
634 fn test_output_report_table() {
635 let report = make_test_report();
636 let result = output_report(&report, OutputFormat::Table);
637 assert!(result.is_ok());
638 }
639
640 #[test]
641 fn test_output_report_table_no_usd() {
642 let mut report = make_test_report();
643 report.balance.usd = None;
644 let result = output_report(&report, OutputFormat::Table);
645 assert!(result.is_ok());
646 }
647
648 #[test]
649 fn test_output_report_table_no_tokens() {
650 let mut report = make_test_report();
651 report.tokens = None;
652 let result = output_report(&report, OutputFormat::Table);
653 assert!(result.is_ok());
654 }
655
656 #[test]
657 fn test_output_report_table_empty_tokens() {
658 let mut report = make_test_report();
659 report.tokens = Some(vec![]);
660 let result = output_report(&report, OutputFormat::Table);
661 assert!(result.is_ok());
662 }
663
664 use crate::chains::mocks::{MockChainClient, MockClientFactory};
669
670 fn mock_factory() -> MockClientFactory {
671 MockClientFactory::new()
672 }
673
674 #[tokio::test]
675 async fn test_run_ethereum_address() {
676 let config = Config::default();
677 let factory = mock_factory();
678 let args = AddressArgs {
679 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
680 chain: "ethereum".to_string(),
681 format: Some(OutputFormat::Json),
682 include_txs: false,
683 include_tokens: false,
684 limit: 10,
685 };
686 let result = super::run(args, &config, &factory).await;
687 assert!(result.is_ok());
688 }
689
690 #[tokio::test]
691 async fn test_run_with_transactions() {
692 let config = Config::default();
693 let mut factory = mock_factory();
694 factory.mock_client.transactions = vec![factory.mock_client.transaction.clone()];
695 let args = AddressArgs {
696 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
697 chain: "ethereum".to_string(),
698 format: Some(OutputFormat::Json),
699 include_txs: true,
700 include_tokens: false,
701 limit: 10,
702 };
703 let result = super::run(args, &config, &factory).await;
704 assert!(result.is_ok());
705 }
706
707 #[tokio::test]
708 async fn test_run_with_tokens() {
709 let config = Config::default();
710 let mut factory = mock_factory();
711 factory.mock_client.token_balances = vec![crate::chains::TokenBalance {
712 token: crate::chains::Token {
713 contract_address: "0xusdc".to_string(),
714 symbol: "USDC".to_string(),
715 name: "USD Coin".to_string(),
716 decimals: 6,
717 },
718 balance: "1000000".to_string(),
719 formatted_balance: "1.0".to_string(),
720 usd_value: Some(1.0),
721 }];
722 let args = AddressArgs {
723 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
724 chain: "ethereum".to_string(),
725 format: Some(OutputFormat::Table),
726 include_txs: false,
727 include_tokens: true,
728 limit: 10,
729 };
730 let result = super::run(args, &config, &factory).await;
731 assert!(result.is_ok());
732 }
733
734 #[tokio::test]
735 async fn test_run_auto_detect_solana() {
736 let config = Config::default();
737 let mut factory = mock_factory();
738 factory.mock_client = MockChainClient::new("solana", "SOL");
739 let args = AddressArgs {
740 address: "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy".to_string(),
742 chain: "ethereum".to_string(), format: Some(OutputFormat::Json),
744 include_txs: false,
745 include_tokens: false,
746 limit: 10,
747 };
748 let result = super::run(args, &config, &factory).await;
749 assert!(result.is_ok());
750 }
751
752 #[tokio::test]
753 async fn test_run_csv_format() {
754 let config = Config::default();
755 let factory = mock_factory();
756 let args = AddressArgs {
757 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
758 chain: "ethereum".to_string(),
759 format: Some(OutputFormat::Csv),
760 include_txs: false,
761 include_tokens: false,
762 limit: 10,
763 };
764 let result = super::run(args, &config, &factory).await;
765 assert!(result.is_ok());
766 }
767
768 #[tokio::test]
769 async fn test_run_all_features() {
770 let config = Config::default();
771 let mut factory = mock_factory();
772 factory.mock_client.transactions = vec![factory.mock_client.transaction.clone()];
773 factory.mock_client.token_balances = vec![crate::chains::TokenBalance {
774 token: crate::chains::Token {
775 contract_address: "0xtoken".to_string(),
776 symbol: "TEST".to_string(),
777 name: "Test Token".to_string(),
778 decimals: 18,
779 },
780 balance: "1000000000000000000".to_string(),
781 formatted_balance: "1.0".to_string(),
782 usd_value: None,
783 }];
784 let args = AddressArgs {
785 address: "0x742d35Cc6634C0532925a3b844Bc9e7595f1b3c2".to_string(),
786 chain: "ethereum".to_string(),
787 format: Some(OutputFormat::Table),
788 include_txs: true,
789 include_tokens: true,
790 limit: 50,
791 };
792 let result = super::run(args, &config, &factory).await;
793 assert!(result.is_ok());
794 }
795}