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 #[arg(long, value_name = "PATH")]
62 pub report: Option<std::path::PathBuf>,
63
64 #[arg(long, default_value_t = false)]
69 pub dossier: bool,
70}
71
72#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
74pub struct AddressReport {
75 pub address: String,
77
78 pub chain: String,
80
81 pub balance: Balance,
83
84 pub transaction_count: u64,
86
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub transactions: Option<Vec<TransactionSummary>>,
90
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub tokens: Option<Vec<TokenBalance>>,
94}
95
96#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
98pub struct Balance {
99 pub raw: String,
101
102 pub formatted: String,
104
105 #[serde(skip_serializing_if = "Option::is_none")]
107 pub usd: Option<f64>,
108}
109
110#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
112pub struct TransactionSummary {
113 pub hash: String,
115
116 pub block_number: u64,
118
119 pub timestamp: u64,
121
122 pub from: String,
124
125 pub to: Option<String>,
127
128 pub value: String,
130
131 pub status: bool,
133}
134
135#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
137pub struct TokenBalance {
138 pub contract_address: String,
140
141 pub symbol: String,
143
144 pub name: String,
146
147 pub decimals: u8,
149
150 pub balance: String,
152
153 pub formatted_balance: String,
155}
156
157pub async fn run(
173 mut args: AddressArgs,
174 config: &Config,
175 clients: &dyn ChainClientFactory,
176) -> Result<()> {
177 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(&args.address, &args.chain)?;
195
196 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 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 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 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
262pub async fn analyze_address(
265 args: &AddressArgs,
266 client: &dyn ChainClient,
267) -> Result<AddressReport> {
268 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 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 let transaction_count = transactions.as_ref().map(|t| t.len() as u64).unwrap_or(0);
305
306 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
341fn validate_address(address: &str, chain: &str) -> Result<()> {
343 match chain {
344 "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 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" => {
368 validate_solana_address(address)?;
369 }
370 "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
384fn 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 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#[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 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 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 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")); }
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 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 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 address: "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy".to_string(),
823 chain: "ethereum".to_string(), 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}