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 let http: std::sync::Arc<dyn crate::http::HttpClient> =
989 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
990 crate::chains::DefaultClientFactory {
991 chains_config: Default::default(),
992 http,
993 }
994 .create_dex_client()
995 }
996 }
997
998 #[tokio::test]
999 async fn test_fetch_transaction_report_mock() {
1000 let factory = MockTxFactory;
1001 let result = fetch_transaction_report(
1002 "0xabc123def456789012345678901234567890123456789012345678901234abcd",
1003 "ethereum",
1004 false,
1005 false,
1006 &factory,
1007 )
1008 .await;
1009 assert!(result.is_ok());
1010 let report = result.unwrap();
1011 assert_eq!(report.transaction.from, "0xfrom");
1012 assert!(report.transaction.status);
1013 }
1014
1015 #[tokio::test]
1016 async fn test_fetch_transaction_report_with_decode() {
1017 let factory = MockTxFactory;
1018 let result = fetch_transaction_report(
1019 "0xabc123def456789012345678901234567890123456789012345678901234abcd",
1020 "ethereum",
1021 true,
1022 false,
1023 &factory,
1024 )
1025 .await;
1026 assert!(result.is_ok());
1027 }
1028
1029 use crate::chains::mocks::MockClientFactory;
1034
1035 fn mock_factory() -> MockClientFactory {
1036 MockClientFactory::new()
1037 }
1038
1039 #[tokio::test]
1040 async fn test_run_ethereum_tx() {
1041 let config = Config::default();
1042 let factory = mock_factory();
1043 let args = TxArgs {
1044 hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1045 chain: "ethereum".to_string(),
1046 format: Some(OutputFormat::Json),
1047 trace: false,
1048 decode: false,
1049 };
1050 let result = super::run(args, &config, &factory).await;
1051 assert!(result.is_ok());
1052 }
1053
1054 #[tokio::test]
1055 async fn test_run_tx_with_decode() {
1056 let config = Config::default();
1057 let mut factory = mock_factory();
1058 factory.mock_client.transaction.input = "0xa9059cbb000000000000000000000000".to_string();
1059 let args = TxArgs {
1060 hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1061 chain: "ethereum".to_string(),
1062 format: Some(OutputFormat::Table),
1063 trace: false,
1064 decode: true,
1065 };
1066 let result = super::run(args, &config, &factory).await;
1067 assert!(result.is_ok());
1068 }
1069
1070 #[tokio::test]
1071 async fn test_run_tx_with_trace() {
1072 let config = Config::default();
1073 let factory = mock_factory();
1074 let args = TxArgs {
1075 hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1076 chain: "ethereum".to_string(),
1077 format: Some(OutputFormat::Csv),
1078 trace: true,
1079 decode: false,
1080 };
1081 let result = super::run(args, &config, &factory).await;
1082 assert!(result.is_ok());
1083 }
1084
1085 #[tokio::test]
1086 async fn test_run_tx_invalid_hash() {
1087 let config = Config::default();
1088 let factory = mock_factory();
1089 let args = TxArgs {
1090 hash: "invalid".to_string(),
1091 chain: "ethereum".to_string(),
1092 format: Some(OutputFormat::Json),
1093 trace: false,
1094 decode: false,
1095 };
1096 let result = super::run(args, &config, &factory).await;
1097 assert!(result.is_err());
1098 }
1099
1100 #[tokio::test]
1101 async fn test_run_tx_auto_detect_tron() {
1102 let config = Config::default();
1103 let factory = mock_factory();
1104 let args = TxArgs {
1105 hash: "abc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1107 chain: "ethereum".to_string(), format: Some(OutputFormat::Json),
1109 trace: false,
1110 decode: false,
1111 };
1112 let result = super::run(args, &config, &factory).await;
1113 assert!(result.is_ok());
1114 }
1115
1116 #[test]
1121 fn test_format_tx_markdown_basic() {
1122 let report = make_test_tx_report();
1123 let md = format_tx_markdown(&report);
1124 assert!(md.contains("# Transaction Analysis"));
1125 assert!(md.contains(&report.hash));
1126 assert!(md.contains(&report.chain));
1127 assert!(md.contains("Success"));
1128 assert!(md.contains(&report.transaction.from));
1129 }
1130
1131 #[test]
1132 fn test_format_tx_markdown_contract_creation() {
1133 let mut report = make_test_tx_report();
1134 report.transaction.to = None;
1135 let md = format_tx_markdown(&report);
1136 assert!(md.contains("Contract Creation"));
1137 }
1138
1139 #[test]
1140 fn test_format_tx_markdown_failed_tx() {
1141 let mut report = make_test_tx_report();
1142 report.transaction.status = false;
1143 let md = format_tx_markdown(&report);
1144 assert!(md.contains("Failed"));
1145 }
1146
1147 #[test]
1148 fn test_format_tx_markdown_with_decoded_input() {
1149 let report = make_test_tx_report();
1150 let md = format_tx_markdown(&report);
1151 assert!(md.contains("## Decoded Input"));
1152 assert!(md.contains("transfer"));
1153 assert!(md.contains("transfer(address,uint256)"));
1154 }
1155
1156 #[test]
1157 fn test_format_tx_markdown_with_internal_transactions() {
1158 let report = make_test_tx_report();
1159 let md = format_tx_markdown(&report);
1160 assert!(md.contains("## Internal Transactions"));
1161 assert!(md.contains("call"));
1162 }
1163
1164 #[test]
1165 fn test_format_tx_markdown_no_decoded_input() {
1166 let mut report = make_test_tx_report();
1167 report.decoded_input = None;
1168 let md = format_tx_markdown(&report);
1169 assert!(!md.contains("## Decoded Input"));
1170 }
1171
1172 #[test]
1173 fn test_format_tx_markdown_no_internal_transactions() {
1174 let mut report = make_test_tx_report();
1175 report.internal_transactions = None;
1176 let md = format_tx_markdown(&report);
1177 assert!(!md.contains("## Internal Transactions"));
1178 }
1179
1180 #[test]
1181 fn test_format_tx_markdown_empty_internal_transactions() {
1182 let mut report = make_test_tx_report();
1183 report.internal_transactions = Some(vec![]);
1184 let md = format_tx_markdown(&report);
1185 assert!(!md.contains("## Internal Transactions"));
1186 }
1187}