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