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