1use crate::cli::output::Output;
21use crate::outln;
22use clap::Args;
23use scope::chains::{ChainClientFactory, validate_solana_signature, validate_tron_tx_hash};
24use scope::config::{Config, OutputFormat};
25use scope::error::{Result, ScopeError};
26
27#[derive(Debug, Clone, Args)]
29#[command(after_help = "\x1b[1mExamples:\x1b[0m
30 scope tx 0xabc123def456...
31 scope tx 0xabc123... --chain polygon --trace
32 scope tx 0xabc123... --decode --format json
33
34\x1b[2mTip: All address/token inputs accept @label shortcuts from the address book.\x1b[0m")]
35pub struct TxArgs {
36 #[arg(value_name = "HASH")]
41 pub hash: String,
42
43 #[arg(short, long, default_value = scope::chains::DEFAULT_CHAIN, help = scope::chains::registry::chain_help())]
44 pub chain: String,
45
46 #[arg(short, long, value_name = "FORMAT")]
48 pub format: Option<OutputFormat>,
49
50 #[arg(long)]
52 pub trace: bool,
53
54 #[arg(long)]
56 pub decode: bool,
57}
58
59#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
61pub struct TransactionReport {
62 pub hash: String,
64
65 pub chain: String,
67
68 pub block: BlockInfo,
70
71 pub transaction: TransactionDetails,
73
74 pub gas: GasInfo,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub decoded_input: Option<DecodedInput>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub internal_transactions: Option<Vec<InternalTransaction>>,
84}
85
86#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
88pub struct BlockInfo {
89 pub number: u64,
91
92 pub timestamp: u64,
94
95 pub hash: String,
97}
98
99#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
101pub struct TransactionDetails {
102 pub from: String,
104
105 pub to: Option<String>,
107
108 pub value: String,
110
111 pub nonce: u64,
113
114 pub transaction_index: u64,
116
117 pub status: bool,
119
120 pub input: String,
122}
123
124#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
126pub struct GasInfo {
127 pub gas_limit: u64,
129
130 pub gas_used: u64,
132
133 pub gas_price: String,
135
136 pub transaction_fee: String,
138
139 #[serde(skip_serializing_if = "Option::is_none")]
141 pub effective_gas_price: Option<String>,
142}
143
144#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
146pub struct DecodedInput {
147 pub function_signature: String,
149
150 pub function_name: String,
152
153 pub parameters: Vec<DecodedParameter>,
155}
156
157#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
159pub struct DecodedParameter {
160 pub name: String,
162
163 pub param_type: String,
165
166 pub value: String,
168}
169
170#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
172pub struct InternalTransaction {
173 pub call_type: String,
175
176 pub from: String,
178
179 pub to: String,
181
182 pub value: String,
184
185 pub gas: u64,
187
188 pub input: String,
190
191 pub output: String,
193}
194
195pub async fn run(
211 mut args: TxArgs,
212 config: &Config,
213 clients: &dyn ChainClientFactory,
214 out: &Output,
215) -> Result<()> {
216 if args.chain == "ethereum"
218 && let Some(inferred) = scope::chains::infer_chain_from_hash(&args.hash)
219 && inferred != "ethereum"
220 {
221 tracing::info!("Auto-detected chain: {}", inferred);
222 outln!(out, "Auto-detected chain: {}", inferred)?;
223 args.chain = inferred.to_string();
224 }
225
226 tracing::info!(
227 hash = %args.hash,
228 chain = %args.chain,
229 "Starting transaction analysis"
230 );
231
232 validate_tx_hash(&args.hash, &args.chain)?;
234
235 let sp = crate::cli::progress::Spinner::new(
236 &format!("Analyzing transaction on {}...", args.chain),
237 out,
238 );
239
240 let report =
241 fetch_transaction_report(&args.hash, &args.chain, args.decode, args.trace, clients).await?;
242
243 sp.finish("Transaction loaded.");
244
245 let format = args.format.unwrap_or(config.output.format);
247 output_report(&report, format, out)?;
248
249 Ok(())
250}
251
252pub async fn fetch_transaction_report(
256 hash: &str,
257 chain: &str,
258 decode: bool,
259 trace: bool,
260 clients: &dyn ChainClientFactory,
261) -> Result<TransactionReport> {
262 validate_tx_hash(hash, chain)?;
263 let client = clients.create_chain_client(chain)?;
264 let tx = client.get_transaction(hash).await?;
265
266 let gas_price_val: u128 = tx.gas_price.parse().unwrap_or(0);
267 let gas_used_val = tx.gas_used.unwrap_or(0) as u128;
268 let fee_wei = gas_price_val * gas_used_val;
269 let chain_lower = chain.to_lowercase();
270 let fee_str = if chain_lower == "solana" || chain_lower == "sol" {
271 let fee_sol = tx.gas_price.parse::<f64>().unwrap_or(0.0) / 1_000_000_000.0;
272 format!("{:.9}", fee_sol)
273 } else {
274 fee_wei.to_string()
275 };
276
277 let report = TransactionReport {
278 hash: tx.hash.clone(),
279 chain: chain.to_string(),
280 block: BlockInfo {
281 number: tx.block_number.unwrap_or(0),
282 timestamp: tx.timestamp.unwrap_or(0),
283 hash: String::new(),
284 },
285 transaction: TransactionDetails {
286 from: tx.from.clone(),
287 to: tx.to.clone(),
288 value: tx.value.clone(),
289 nonce: tx.nonce,
290 transaction_index: 0,
291 status: tx.status.unwrap_or(true),
292 input: tx.input.clone(),
293 },
294 gas: GasInfo {
295 gas_limit: tx.gas_limit,
296 gas_used: tx.gas_used.unwrap_or(0),
297 gas_price: tx.gas_price.clone(),
298 transaction_fee: fee_str,
299 effective_gas_price: None,
300 },
301 decoded_input: if decode && !tx.input.is_empty() && tx.input != "0x" {
302 let selector = if tx.input.len() >= 10 {
303 &tx.input[..10]
304 } else {
305 &tx.input
306 };
307 Some(DecodedInput {
308 function_signature: format!("{}(...)", selector),
309 function_name: selector.to_string(),
310 parameters: vec![],
311 })
312 } else if decode {
313 Some(DecodedInput {
314 function_signature: "transfer()".to_string(),
315 function_name: "Native Transfer".to_string(),
316 parameters: vec![],
317 })
318 } else {
319 None
320 },
321 internal_transactions: if trace { Some(vec![]) } else { None },
322 };
323 Ok(report)
324}
325
326fn validate_tx_hash(hash: &str, chain: &str) -> Result<()> {
328 match scope::chains::registry::chain(chain).map(|c| c.kind) {
330 Some(scope::chains::registry::ChainKind::Evm) => {
332 if !hash.starts_with("0x") {
333 return Err(ScopeError::InvalidHash(format!(
334 "Transaction hash must start with '0x': {}",
335 hash
336 )));
337 }
338 if hash.len() != 66 {
339 return Err(ScopeError::InvalidHash(format!(
340 "Transaction hash must be 66 characters (0x + 64 hex): {}",
341 hash
342 )));
343 }
344 if !hash[2..].chars().all(|c| c.is_ascii_hexdigit()) {
345 return Err(ScopeError::InvalidHash(format!(
346 "Transaction hash contains invalid hex characters: {}",
347 hash
348 )));
349 }
350 }
351 Some(scope::chains::registry::ChainKind::Solana) => {
353 validate_solana_signature(hash)?;
354 }
355 Some(scope::chains::registry::ChainKind::Tron) => {
357 validate_tron_tx_hash(hash)?;
358 }
359 _ => {
360 return Err(ScopeError::InvalidInput(format!(
361 "Unsupported chain: {}. Supported: {}",
362 chain,
363 scope::chains::registry::client_chain_list()
364 )));
365 }
366 }
367 Ok(())
368}
369
370fn output_report(report: &TransactionReport, format: OutputFormat, out: &Output) -> Result<()> {
372 match format {
373 OutputFormat::Json => {
374 let json = serde_json::to_string_pretty(report)?;
375 outln!(out, "{}", json)?;
376 }
377 OutputFormat::Csv => {
378 outln!(out, "hash,chain,block,from,to,value,status,gas_used,fee")?;
379 outln!(
380 out,
381 "{},{},{},{},{},{},{},{},{}",
382 report.hash,
383 report.chain,
384 report.block.number,
385 report.transaction.from,
386 report.transaction.to.as_deref().unwrap_or(""),
387 report.transaction.value,
388 report.transaction.status,
389 report.gas.gas_used,
390 report.gas.transaction_fee
391 )?;
392 }
393 OutputFormat::Table => {
394 use scope::display::terminal as t;
395
396 outln!(out, "{}", t::section_header("Transaction Analysis"))?;
397 outln!(out, "{}", t::kv_row("Hash", &report.hash))?;
398 outln!(out, "{}", t::kv_row("Chain", &report.chain))?;
399 outln!(
400 out,
401 "{}",
402 t::kv_row("Block", &report.block.number.to_string())
403 )?;
404 if report.transaction.status {
405 outln!(out, "{}", t::check_pass("Success"))?;
406 } else {
407 outln!(out, "{}", t::check_fail("Failed"))?;
408 }
409 outln!(out, "{}", t::blank_row())?;
410 outln!(out, "{}", t::kv_row("From", &report.transaction.from))?;
411 outln!(
412 out,
413 "{}",
414 t::kv_row(
415 "To",
416 report
417 .transaction
418 .to
419 .as_deref()
420 .unwrap_or("Contract Creation")
421 )
422 )?;
423 outln!(out, "{}", t::kv_row("Value", &report.transaction.value))?;
424 outln!(out, "{}", t::blank_row())?;
425 outln!(
426 out,
427 "{}",
428 t::kv_row("Gas Limit", &report.gas.gas_limit.to_string())
429 )?;
430 outln!(
431 out,
432 "{}",
433 t::kv_row("Gas Used", &report.gas.gas_used.to_string())
434 )?;
435 outln!(out, "{}", t::kv_row("Gas Price", &report.gas.gas_price))?;
436 outln!(out, "{}", t::kv_row("Fee", &report.gas.transaction_fee))?;
437
438 if let Some(ref decoded) = report.decoded_input {
439 outln!(out, "{}", t::blank_row())?;
440 outln!(out, "{}", t::subsection_header("Decoded Input"))?;
441 outln!(out, "{}", t::kv_row("Function", &decoded.function_name))?;
442 outln!(
443 out,
444 "{}",
445 t::kv_row("Signature", &decoded.function_signature)
446 )?;
447 if !decoded.parameters.is_empty() {
448 for param in &decoded.parameters {
449 outln!(
450 out,
451 "{}",
452 t::bullet_row(&format!(
453 "{} ({}): {}",
454 param.name, param.param_type, param.value
455 ))
456 )?;
457 }
458 }
459 }
460
461 if let Some(ref traces) = report.internal_transactions
462 && !traces.is_empty()
463 {
464 outln!(out, "{}", t::blank_row())?;
465 outln!(
466 out,
467 "{}",
468 t::subsection_header(&format!("Internal Transactions ({})", traces.len()))
469 )?;
470 for (i, trace) in traces.iter().enumerate() {
471 outln!(
472 out,
473 "{}",
474 t::bullet_row(&format!(
475 "[{}] {} {} -> {}",
476 i, trace.call_type, trace.from, trace.to
477 ))
478 )?;
479 }
480 }
481
482 outln!(out, "{}", t::section_footer())?;
483 }
484 OutputFormat::Markdown => {
485 let md = format_tx_markdown(report);
486 outln!(out, "{}", md)?;
487 }
488 }
489 Ok(())
490}
491
492pub fn format_tx_markdown(report: &TransactionReport) -> String {
495 let mut md = String::new();
496 md.push_str("# Transaction Analysis\n\n");
497 md.push_str("| Field | Value |\n|-------|-------|\n");
498 md.push_str(&format!("| Hash | `{}` |\n", report.hash));
499 md.push_str(&format!("| Chain | {} |\n", report.chain));
500 md.push_str(&format!("| Block | {} |\n", report.block.number));
501 md.push_str(&format!(
502 "| Status | {} |\n",
503 if report.transaction.status {
504 "Success"
505 } else {
506 "Failed"
507 }
508 ));
509 md.push_str(&format!("| From | `{}` |\n", report.transaction.from));
510 md.push_str(&format!(
511 "| To | `{}` |\n",
512 report
513 .transaction
514 .to
515 .as_deref()
516 .unwrap_or("Contract Creation")
517 ));
518 md.push_str(&format!("| Value | {} |\n", report.transaction.value));
519 md.push_str(&format!("| Gas Used | {} |\n", report.gas.gas_used));
520 md.push_str(&format!("| Fee | {} |\n", report.gas.transaction_fee));
521 if let Some(ref decoded) = report.decoded_input {
522 md.push_str("\n## Decoded Input\n\n");
523 md.push_str(&format!("- **Function:** {}\n", decoded.function_name));
524 md.push_str(&format!(
525 "- **Signature:** `{}`\n",
526 decoded.function_signature
527 ));
528 if !decoded.parameters.is_empty() {
529 md.push_str("\n| Parameter | Type | Value |\n|-----------|------|-------|\n");
530 for param in &decoded.parameters {
531 md.push_str(&format!(
532 "| {} | {} | {} |\n",
533 param.name, param.param_type, param.value
534 ));
535 }
536 }
537 }
538 if let Some(ref traces) = report.internal_transactions
539 && !traces.is_empty()
540 {
541 md.push_str("\n## Internal Transactions\n\n");
542 md.push_str("| # | Type | From | To |\n|---|---|---|---|\n");
543 for (i, trace) in traces.iter().enumerate() {
544 md.push_str(&format!(
545 "| {} | {} | `{}` | `{}` |\n",
546 i + 1,
547 trace.call_type,
548 trace.from,
549 trace.to
550 ));
551 }
552 }
553 md
554}
555
556#[cfg(test)]
561mod tests {
562 use super::*;
563
564 fn quiet() -> crate::cli::output::Output {
565 crate::cli::output::Output::capture().0
566 }
567
568 const VALID_TX_HASH: &str =
569 "0xabc123def456789012345678901234567890123456789012345678901234abcd";
570
571 #[test]
572 fn test_validate_tx_hash_valid() {
573 let result = validate_tx_hash(VALID_TX_HASH, "ethereum");
574 assert!(result.is_ok());
575 }
576
577 #[test]
578 fn test_validate_tx_hash_valid_lowercase() {
579 let hash = "0xabc123def456789012345678901234567890123456789012345678901234abcd";
580 let result = validate_tx_hash(hash, "ethereum");
581 assert!(result.is_ok());
582 }
583
584 #[test]
585 fn test_validate_tx_hash_valid_polygon() {
586 let result = validate_tx_hash(VALID_TX_HASH, "polygon");
587 assert!(result.is_ok());
588 }
589
590 #[test]
591 fn test_validate_tx_hash_missing_prefix() {
592 let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
593 let result = validate_tx_hash(hash, "ethereum");
594 assert!(result.is_err());
595 assert!(result.unwrap_err().to_string().contains("0x"));
596 }
597
598 #[test]
599 fn test_validate_tx_hash_too_short() {
600 let hash = "0xabc123";
601 let result = validate_tx_hash(hash, "ethereum");
602 assert!(result.is_err());
603 assert!(result.unwrap_err().to_string().contains("66 characters"));
604 }
605
606 #[test]
607 fn test_validate_tx_hash_too_long() {
608 let hash = "0xabc123def456789012345678901234567890123456789012345678901234abcde";
609 let result = validate_tx_hash(hash, "ethereum");
610 assert!(result.is_err());
611 }
612
613 #[test]
614 fn test_validate_tx_hash_invalid_hex_cli() {
615 let hash = "0xabc123def456789012345678901234567890123456789012345678901234GHIJ";
616 let result = validate_tx_hash(hash, "ethereum");
617 assert!(result.is_err());
618 assert!(result.unwrap_err().to_string().contains("invalid hex"));
619 }
620
621 #[test]
622 fn test_validate_tx_hash_unsupported_chain() {
623 let result = validate_tx_hash(VALID_TX_HASH, "bitcoin");
624 assert!(result.is_err());
625 assert!(
626 result
627 .unwrap_err()
628 .to_string()
629 .contains("Unsupported chain")
630 );
631 }
632
633 #[test]
634 fn test_validate_tx_hash_valid_bsc() {
635 let result = validate_tx_hash(VALID_TX_HASH, "bsc");
636 assert!(result.is_ok());
637 }
638
639 #[test]
640 fn test_validate_tx_hash_valid_aegis() {
641 let result = validate_tx_hash(VALID_TX_HASH, "aegis");
642 assert!(result.is_ok());
643 }
644
645 #[test]
646 fn test_validate_tx_hash_valid_solana() {
647 let sig = "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
649 let result = validate_tx_hash(sig, "solana");
650 assert!(result.is_ok());
651 }
652
653 #[test]
654 fn test_validate_tx_hash_invalid_solana() {
655 let result = validate_tx_hash(VALID_TX_HASH, "solana");
657 assert!(result.is_err());
658 }
659
660 #[test]
661 fn test_validate_tx_hash_valid_tron() {
662 let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
664 let result = validate_tx_hash(hash, "tron");
665 assert!(result.is_ok());
666 }
667
668 #[test]
669 fn test_validate_tx_hash_invalid_tron() {
670 let result = validate_tx_hash(VALID_TX_HASH, "tron");
672 assert!(result.is_err());
673 }
674
675 #[test]
676 fn test_tx_args_default_values() {
677 use clap::Parser;
678
679 #[derive(Parser)]
680 struct TestCli {
681 #[command(flatten)]
682 args: TxArgs,
683 }
684
685 let cli = TestCli::try_parse_from(["test", VALID_TX_HASH]).unwrap();
686
687 assert_eq!(cli.args.chain, "ethereum");
688 assert!(!cli.args.trace);
689 assert!(!cli.args.decode);
690 assert!(cli.args.format.is_none());
691 }
692
693 #[test]
694 fn test_tx_args_with_options() {
695 use clap::Parser;
696
697 #[derive(Parser)]
698 struct TestCli {
699 #[command(flatten)]
700 args: TxArgs,
701 }
702
703 let cli = TestCli::try_parse_from([
704 "test",
705 VALID_TX_HASH,
706 "--chain",
707 "polygon",
708 "--trace",
709 "--decode",
710 "--format",
711 "json",
712 ])
713 .unwrap();
714
715 assert_eq!(cli.args.chain, "polygon");
716 assert!(cli.args.trace);
717 assert!(cli.args.decode);
718 assert_eq!(cli.args.format, Some(OutputFormat::Json));
719 }
720
721 #[test]
722 fn test_transaction_report_serialization() {
723 let report = TransactionReport {
724 hash: VALID_TX_HASH.to_string(),
725 chain: "ethereum".to_string(),
726 block: BlockInfo {
727 number: 12345678,
728 timestamp: 1700000000,
729 hash: "0xblock".to_string(),
730 },
731 transaction: TransactionDetails {
732 from: "0xfrom".to_string(),
733 to: Some("0xto".to_string()),
734 value: "1.0".to_string(),
735 nonce: 42,
736 transaction_index: 5,
737 status: true,
738 input: "0x".to_string(),
739 },
740 gas: GasInfo {
741 gas_limit: 100000,
742 gas_used: 21000,
743 gas_price: "20000000000".to_string(),
744 transaction_fee: "0.00042".to_string(),
745 effective_gas_price: None,
746 },
747 decoded_input: None,
748 internal_transactions: None,
749 };
750
751 let json = serde_json::to_string(&report).unwrap();
752 assert!(json.contains(VALID_TX_HASH));
753 assert!(json.contains("12345678"));
754 assert!(json.contains("21000"));
755 assert!(!json.contains("decoded_input"));
756 assert!(!json.contains("internal_transactions"));
757 }
758
759 #[test]
760 fn test_block_info_serialization() {
761 let block = BlockInfo {
762 number: 12345678,
763 timestamp: 1700000000,
764 hash: "0xblockhash".to_string(),
765 };
766
767 let json = serde_json::to_string(&block).unwrap();
768 assert!(json.contains("12345678"));
769 assert!(json.contains("1700000000"));
770 assert!(json.contains("0xblockhash"));
771 }
772
773 #[test]
774 fn test_gas_info_serialization() {
775 let gas = GasInfo {
776 gas_limit: 100000,
777 gas_used: 50000,
778 gas_price: "20000000000".to_string(),
779 transaction_fee: "0.001".to_string(),
780 effective_gas_price: Some("25000000000".to_string()),
781 };
782
783 let json = serde_json::to_string(&gas).unwrap();
784 assert!(json.contains("100000"));
785 assert!(json.contains("50000"));
786 assert!(json.contains("effective_gas_price"));
787 }
788
789 #[test]
790 fn test_decoded_input_serialization() {
791 let decoded = DecodedInput {
792 function_signature: "transfer(address,uint256)".to_string(),
793 function_name: "transfer".to_string(),
794 parameters: vec![
795 DecodedParameter {
796 name: "to".to_string(),
797 param_type: "address".to_string(),
798 value: "0xrecipient".to_string(),
799 },
800 DecodedParameter {
801 name: "amount".to_string(),
802 param_type: "uint256".to_string(),
803 value: "1000000".to_string(),
804 },
805 ],
806 };
807
808 let json = serde_json::to_string(&decoded).unwrap();
809 assert!(json.contains("transfer(address,uint256)"));
810 assert!(json.contains("0xrecipient"));
811 assert!(json.contains("1000000"));
812 }
813
814 #[test]
815 fn test_internal_transaction_serialization() {
816 let internal = InternalTransaction {
817 call_type: "call".to_string(),
818 from: "0xfrom".to_string(),
819 to: "0xto".to_string(),
820 value: "1.0".to_string(),
821 gas: 50000,
822 input: "0x".to_string(),
823 output: "0x".to_string(),
824 };
825
826 let json = serde_json::to_string(&internal).unwrap();
827 assert!(json.contains("call"));
828 assert!(json.contains("0xfrom"));
829 assert!(json.contains("50000"));
830 }
831
832 fn make_test_tx_report() -> TransactionReport {
837 TransactionReport {
838 hash: VALID_TX_HASH.to_string(),
839 chain: "ethereum".to_string(),
840 block: BlockInfo {
841 number: 12345678,
842 timestamp: 1700000000,
843 hash: "0xblock".to_string(),
844 },
845 transaction: TransactionDetails {
846 from: "0xfrom".to_string(),
847 to: Some("0xto".to_string()),
848 value: "1.0".to_string(),
849 nonce: 42,
850 transaction_index: 5,
851 status: true,
852 input: "0xa9059cbb0000000000".to_string(),
853 },
854 gas: GasInfo {
855 gas_limit: 100000,
856 gas_used: 21000,
857 gas_price: "20000000000".to_string(),
858 transaction_fee: "0.00042".to_string(),
859 effective_gas_price: None,
860 },
861 decoded_input: Some(DecodedInput {
862 function_signature: "transfer(address,uint256)".to_string(),
863 function_name: "transfer".to_string(),
864 parameters: vec![DecodedParameter {
865 name: "to".to_string(),
866 param_type: "address".to_string(),
867 value: "0xrecipient".to_string(),
868 }],
869 }),
870 internal_transactions: Some(vec![InternalTransaction {
871 call_type: "call".to_string(),
872 from: "0xfrom".to_string(),
873 to: "0xto".to_string(),
874 value: "0.5".to_string(),
875 gas: 30000,
876 input: "0x".to_string(),
877 output: "0x".to_string(),
878 }]),
879 }
880 }
881
882 #[test]
883 fn test_output_report_json() {
884 let report = make_test_tx_report();
885 let result = output_report(&report, OutputFormat::Json, &quiet());
886 assert!(result.is_ok());
887 }
888
889 #[test]
890 fn test_output_report_csv() {
891 let report = make_test_tx_report();
892 let result = output_report(&report, OutputFormat::Csv, &quiet());
893 assert!(result.is_ok());
894 }
895
896 #[test]
897 fn test_output_report_table() {
898 let report = make_test_tx_report();
899 let result = output_report(&report, OutputFormat::Table, &quiet());
900 assert!(result.is_ok());
901 }
902
903 #[test]
904 fn test_output_report_table_no_decoded() {
905 let mut report = make_test_tx_report();
906 report.decoded_input = None;
907 report.internal_transactions = None;
908 let result = output_report(&report, OutputFormat::Table, &quiet());
909 assert!(result.is_ok());
910 }
911
912 #[test]
913 fn test_output_report_table_failed_tx() {
914 let mut report = make_test_tx_report();
915 report.transaction.status = false;
916 report.transaction.to = None; let result = output_report(&report, OutputFormat::Table, &quiet());
918 assert!(result.is_ok());
919 }
920
921 #[test]
922 fn test_output_report_table_empty_traces() {
923 let mut report = make_test_tx_report();
924 report.internal_transactions = Some(vec![]);
925 let result = output_report(&report, OutputFormat::Table, &quiet());
926 assert!(result.is_ok());
927 }
928
929 #[test]
930 fn test_output_report_csv_no_to() {
931 let mut report = make_test_tx_report();
932 report.transaction.to = None;
933 let result = output_report(&report, OutputFormat::Csv, &quiet());
934 assert!(result.is_ok());
935 }
936
937 use async_trait::async_trait;
942 use scope::chains::{
943 Balance as ChainBalance, ChainClient, ChainClientFactory, DexDataSource,
944 TokenBalance as ChainTokenBalance, Transaction as ChainTransaction,
945 };
946
947 struct MockTxClient;
948
949 #[async_trait]
950 impl ChainClient for MockTxClient {
951 fn chain_name(&self) -> &str {
952 "ethereum"
953 }
954 fn native_token_symbol(&self) -> &str {
955 "ETH"
956 }
957 async fn get_balance(&self, _a: &str) -> scope::error::Result<ChainBalance> {
958 Ok(ChainBalance {
959 raw: "0".into(),
960 formatted: "0 ETH".into(),
961 decimals: 18,
962 symbol: "ETH".into(),
963 usd_value: None,
964 })
965 }
966 async fn enrich_balance_usd(&self, _b: &mut ChainBalance) {}
967 async fn get_transaction(&self, _h: &str) -> scope::error::Result<ChainTransaction> {
968 Ok(ChainTransaction {
969 hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".into(),
970 block_number: Some(12345678),
971 timestamp: Some(1700000000),
972 from: "0xfrom".into(),
973 to: Some("0xto".into()),
974 value: "1000000000000000000".into(),
975 gas_limit: 21000,
976 gas_used: Some(21000),
977 gas_price: "20000000000".into(),
978 nonce: 42,
979 input: "0xa9059cbb0000000000000000000000001234".into(),
980 status: Some(true),
981 })
982 }
983 async fn get_transactions(
984 &self,
985 _a: &str,
986 _l: u32,
987 ) -> scope::error::Result<Vec<ChainTransaction>> {
988 Ok(vec![])
989 }
990 async fn get_block_number(&self) -> scope::error::Result<u64> {
991 Ok(12345678)
992 }
993 async fn get_token_balances(
994 &self,
995 _a: &str,
996 ) -> scope::error::Result<Vec<ChainTokenBalance>> {
997 Ok(vec![])
998 }
999 async fn get_code(&self, _addr: &str) -> scope::error::Result<String> {
1000 Ok("0x".into())
1001 }
1002 }
1003
1004 struct MockTxFactory;
1005 impl ChainClientFactory for MockTxFactory {
1006 fn http(&self) -> std::sync::Arc<dyn scope::http::HttpClient> {
1007 scope::http::test_transport()
1008 }
1009 fn create_chain_client(&self, _chain: &str) -> scope::error::Result<Box<dyn ChainClient>> {
1010 Ok(Box::new(MockTxClient))
1011 }
1012 fn create_dex_client(&self) -> Box<dyn DexDataSource> {
1013 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1014 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1015 scope::chains::DefaultClientFactory {
1016 chains_config: Default::default(),
1017 http,
1018 }
1019 .create_dex_client()
1020 }
1021 }
1022
1023 #[tokio::test]
1024 async fn test_fetch_transaction_report_mock() {
1025 let factory = MockTxFactory;
1026 let result = fetch_transaction_report(
1027 "0xabc123def456789012345678901234567890123456789012345678901234abcd",
1028 "ethereum",
1029 false,
1030 false,
1031 &factory,
1032 )
1033 .await;
1034 assert!(result.is_ok());
1035 let report = result.unwrap();
1036 assert_eq!(report.transaction.from, "0xfrom");
1037 assert!(report.transaction.status);
1038 }
1039
1040 #[tokio::test]
1041 async fn test_fetch_transaction_report_with_decode() {
1042 let factory = MockTxFactory;
1043 let result = fetch_transaction_report(
1044 "0xabc123def456789012345678901234567890123456789012345678901234abcd",
1045 "ethereum",
1046 true,
1047 false,
1048 &factory,
1049 )
1050 .await;
1051 assert!(result.is_ok());
1052 }
1053
1054 use scope::chains::mocks::MockClientFactory;
1059
1060 fn mock_factory() -> MockClientFactory {
1061 MockClientFactory::new()
1062 }
1063
1064 #[tokio::test]
1065 async fn test_run_ethereum_tx() {
1066 let config = Config::default();
1067 let factory = mock_factory();
1068 let args = TxArgs {
1069 hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1070 chain: "ethereum".to_string(),
1071 format: Some(OutputFormat::Json),
1072 trace: false,
1073 decode: false,
1074 };
1075 let result = super::run(args, &config, &factory, &quiet()).await;
1076 assert!(result.is_ok());
1077 }
1078
1079 #[tokio::test]
1080 async fn test_run_tx_with_decode() {
1081 let config = Config::default();
1082 let mut factory = mock_factory();
1083 factory.mock_client.transaction.input = "0xa9059cbb000000000000000000000000".to_string();
1084 let args = TxArgs {
1085 hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1086 chain: "ethereum".to_string(),
1087 format: Some(OutputFormat::Table),
1088 trace: false,
1089 decode: true,
1090 };
1091 let result = super::run(args, &config, &factory, &quiet()).await;
1092 assert!(result.is_ok());
1093 }
1094
1095 #[tokio::test]
1096 async fn test_run_tx_with_trace() {
1097 let config = Config::default();
1098 let factory = mock_factory();
1099 let args = TxArgs {
1100 hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1101 chain: "ethereum".to_string(),
1102 format: Some(OutputFormat::Csv),
1103 trace: true,
1104 decode: false,
1105 };
1106 let result = super::run(args, &config, &factory, &quiet()).await;
1107 assert!(result.is_ok());
1108 }
1109
1110 #[tokio::test]
1111 async fn test_run_tx_invalid_hash() {
1112 let config = Config::default();
1113 let factory = mock_factory();
1114 let args = TxArgs {
1115 hash: "invalid".to_string(),
1116 chain: "ethereum".to_string(),
1117 format: Some(OutputFormat::Json),
1118 trace: false,
1119 decode: false,
1120 };
1121 let result = super::run(args, &config, &factory, &quiet()).await;
1122 assert!(result.is_err());
1123 }
1124
1125 #[tokio::test]
1126 async fn test_run_tx_auto_detect_tron() {
1127 let config = Config::default();
1128 let factory = mock_factory();
1129 let args = TxArgs {
1130 hash: "abc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1132 chain: "ethereum".to_string(), format: Some(OutputFormat::Json),
1134 trace: false,
1135 decode: false,
1136 };
1137 let result = super::run(args, &config, &factory, &quiet()).await;
1138 assert!(result.is_ok());
1139 }
1140
1141 #[test]
1146 fn test_format_tx_markdown_basic() {
1147 let report = make_test_tx_report();
1148 let md = format_tx_markdown(&report);
1149 assert!(md.contains("# Transaction Analysis"));
1150 assert!(md.contains(&report.hash));
1151 assert!(md.contains(&report.chain));
1152 assert!(md.contains("Success"));
1153 assert!(md.contains(&report.transaction.from));
1154 }
1155
1156 #[test]
1157 fn test_format_tx_markdown_contract_creation() {
1158 let mut report = make_test_tx_report();
1159 report.transaction.to = None;
1160 let md = format_tx_markdown(&report);
1161 assert!(md.contains("Contract Creation"));
1162 }
1163
1164 #[test]
1165 fn test_format_tx_markdown_failed_tx() {
1166 let mut report = make_test_tx_report();
1167 report.transaction.status = false;
1168 let md = format_tx_markdown(&report);
1169 assert!(md.contains("Failed"));
1170 }
1171
1172 #[test]
1173 fn test_format_tx_markdown_with_decoded_input() {
1174 let report = make_test_tx_report();
1175 let md = format_tx_markdown(&report);
1176 assert!(md.contains("## Decoded Input"));
1177 assert!(md.contains("transfer"));
1178 assert!(md.contains("transfer(address,uint256)"));
1179 }
1180
1181 #[test]
1182 fn test_format_tx_markdown_with_internal_transactions() {
1183 let report = make_test_tx_report();
1184 let md = format_tx_markdown(&report);
1185 assert!(md.contains("## Internal Transactions"));
1186 assert!(md.contains("call"));
1187 }
1188
1189 #[test]
1190 fn test_format_tx_markdown_no_decoded_input() {
1191 let mut report = make_test_tx_report();
1192 report.decoded_input = None;
1193 let md = format_tx_markdown(&report);
1194 assert!(!md.contains("## Decoded Input"));
1195 }
1196
1197 #[test]
1198 fn test_format_tx_markdown_no_internal_transactions() {
1199 let mut report = make_test_tx_report();
1200 report.internal_transactions = None;
1201 let md = format_tx_markdown(&report);
1202 assert!(!md.contains("## Internal Transactions"));
1203 }
1204
1205 #[test]
1206 fn test_format_tx_markdown_empty_internal_transactions() {
1207 let mut report = make_test_tx_report();
1208 report.internal_transactions = Some(vec![]);
1209 let md = format_tx_markdown(&report);
1210 assert!(!md.contains("## Internal Transactions"));
1211 }
1212}