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