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 println!("Transaction Analysis Report");
391 println!("===========================");
392 println!("Hash: {}", report.hash);
393 println!("Chain: {}", report.chain);
394 println!("Block: {}", report.block.number);
395 println!(
396 "Status: {}",
397 if report.transaction.status {
398 "Success"
399 } else {
400 "Failed"
401 }
402 );
403 println!();
404 println!("From: {}", report.transaction.from);
405 println!(
406 "To: {}",
407 report
408 .transaction
409 .to
410 .as_deref()
411 .unwrap_or("Contract Creation")
412 );
413 println!("Value: {}", report.transaction.value);
414 println!();
415 println!("Gas Limit: {}", report.gas.gas_limit);
416 println!("Gas Used: {}", report.gas.gas_used);
417 println!("Gas Price: {}", report.gas.gas_price);
418 println!("Fee: {}", report.gas.transaction_fee);
419
420 if let Some(ref decoded) = report.decoded_input {
421 println!();
422 println!("Function: {}", decoded.function_name);
423 println!("Signature: {}", decoded.function_signature);
424 if !decoded.parameters.is_empty() {
425 println!("Parameters:");
426 for param in &decoded.parameters {
427 println!(" {} ({}): {}", param.name, param.param_type, param.value);
428 }
429 }
430 }
431
432 if let Some(ref traces) = report.internal_transactions
433 && !traces.is_empty()
434 {
435 println!();
436 println!("Internal Transactions: {}", traces.len());
437 for (i, trace) in traces.iter().enumerate() {
438 println!(
439 " [{}] {} {} -> {}",
440 i, trace.call_type, trace.from, trace.to
441 );
442 }
443 }
444 }
445 OutputFormat::Markdown => {
446 let md = format_tx_markdown(report);
447 println!("{}", md);
448 }
449 }
450 Ok(())
451}
452
453pub fn format_tx_markdown(report: &TransactionReport) -> String {
456 let mut md = String::new();
457 md.push_str("# Transaction Analysis\n\n");
458 md.push_str("| Field | Value |\n|-------|-------|\n");
459 md.push_str(&format!("| Hash | `{}` |\n", report.hash));
460 md.push_str(&format!("| Chain | {} |\n", report.chain));
461 md.push_str(&format!("| Block | {} |\n", report.block.number));
462 md.push_str(&format!(
463 "| Status | {} |\n",
464 if report.transaction.status {
465 "Success"
466 } else {
467 "Failed"
468 }
469 ));
470 md.push_str(&format!("| From | `{}` |\n", report.transaction.from));
471 md.push_str(&format!(
472 "| To | `{}` |\n",
473 report
474 .transaction
475 .to
476 .as_deref()
477 .unwrap_or("Contract Creation")
478 ));
479 md.push_str(&format!("| Value | {} |\n", report.transaction.value));
480 md.push_str(&format!("| Gas Used | {} |\n", report.gas.gas_used));
481 md.push_str(&format!("| Fee | {} |\n", report.gas.transaction_fee));
482 if let Some(ref decoded) = report.decoded_input {
483 md.push_str("\n## Decoded Input\n\n");
484 md.push_str(&format!("- **Function:** {}\n", decoded.function_name));
485 md.push_str(&format!(
486 "- **Signature:** `{}`\n",
487 decoded.function_signature
488 ));
489 if !decoded.parameters.is_empty() {
490 md.push_str("\n| Parameter | Type | Value |\n|-----------|------|-------|\n");
491 for param in &decoded.parameters {
492 md.push_str(&format!(
493 "| {} | {} | {} |\n",
494 param.name, param.param_type, param.value
495 ));
496 }
497 }
498 }
499 if let Some(ref traces) = report.internal_transactions
500 && !traces.is_empty()
501 {
502 md.push_str("\n## Internal Transactions\n\n");
503 md.push_str("| # | Type | From | To |\n|---|---|---|---|\n");
504 for (i, trace) in traces.iter().enumerate() {
505 md.push_str(&format!(
506 "| {} | {} | `{}` | `{}` |\n",
507 i + 1,
508 trace.call_type,
509 trace.from,
510 trace.to
511 ));
512 }
513 }
514 md
515}
516
517#[cfg(test)]
522mod tests {
523 use super::*;
524
525 const VALID_TX_HASH: &str =
526 "0xabc123def456789012345678901234567890123456789012345678901234abcd";
527
528 #[test]
529 fn test_validate_tx_hash_valid() {
530 let result = validate_tx_hash(VALID_TX_HASH, "ethereum");
531 assert!(result.is_ok());
532 }
533
534 #[test]
535 fn test_validate_tx_hash_valid_lowercase() {
536 let hash = "0xabc123def456789012345678901234567890123456789012345678901234abcd";
537 let result = validate_tx_hash(hash, "ethereum");
538 assert!(result.is_ok());
539 }
540
541 #[test]
542 fn test_validate_tx_hash_valid_polygon() {
543 let result = validate_tx_hash(VALID_TX_HASH, "polygon");
544 assert!(result.is_ok());
545 }
546
547 #[test]
548 fn test_validate_tx_hash_missing_prefix() {
549 let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
550 let result = validate_tx_hash(hash, "ethereum");
551 assert!(result.is_err());
552 assert!(result.unwrap_err().to_string().contains("0x"));
553 }
554
555 #[test]
556 fn test_validate_tx_hash_too_short() {
557 let hash = "0xabc123";
558 let result = validate_tx_hash(hash, "ethereum");
559 assert!(result.is_err());
560 assert!(result.unwrap_err().to_string().contains("66 characters"));
561 }
562
563 #[test]
564 fn test_validate_tx_hash_too_long() {
565 let hash = "0xabc123def456789012345678901234567890123456789012345678901234abcde";
566 let result = validate_tx_hash(hash, "ethereum");
567 assert!(result.is_err());
568 }
569
570 #[test]
571 fn test_validate_tx_hash_invalid_hex_cli() {
572 let hash = "0xabc123def456789012345678901234567890123456789012345678901234GHIJ";
573 let result = validate_tx_hash(hash, "ethereum");
574 assert!(result.is_err());
575 assert!(result.unwrap_err().to_string().contains("invalid hex"));
576 }
577
578 #[test]
579 fn test_validate_tx_hash_unsupported_chain() {
580 let result = validate_tx_hash(VALID_TX_HASH, "bitcoin");
581 assert!(result.is_err());
582 assert!(
583 result
584 .unwrap_err()
585 .to_string()
586 .contains("Unsupported chain")
587 );
588 }
589
590 #[test]
591 fn test_validate_tx_hash_valid_bsc() {
592 let result = validate_tx_hash(VALID_TX_HASH, "bsc");
593 assert!(result.is_ok());
594 }
595
596 #[test]
597 fn test_validate_tx_hash_valid_aegis() {
598 let result = validate_tx_hash(VALID_TX_HASH, "aegis");
599 assert!(result.is_ok());
600 }
601
602 #[test]
603 fn test_validate_tx_hash_valid_solana() {
604 let sig = "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
606 let result = validate_tx_hash(sig, "solana");
607 assert!(result.is_ok());
608 }
609
610 #[test]
611 fn test_validate_tx_hash_invalid_solana() {
612 let result = validate_tx_hash(VALID_TX_HASH, "solana");
614 assert!(result.is_err());
615 }
616
617 #[test]
618 fn test_validate_tx_hash_valid_tron() {
619 let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
621 let result = validate_tx_hash(hash, "tron");
622 assert!(result.is_ok());
623 }
624
625 #[test]
626 fn test_validate_tx_hash_invalid_tron() {
627 let result = validate_tx_hash(VALID_TX_HASH, "tron");
629 assert!(result.is_err());
630 }
631
632 #[test]
633 fn test_tx_args_default_values() {
634 use clap::Parser;
635
636 #[derive(Parser)]
637 struct TestCli {
638 #[command(flatten)]
639 args: TxArgs,
640 }
641
642 let cli = TestCli::try_parse_from(["test", VALID_TX_HASH]).unwrap();
643
644 assert_eq!(cli.args.chain, "ethereum");
645 assert!(!cli.args.trace);
646 assert!(!cli.args.decode);
647 assert!(cli.args.format.is_none());
648 }
649
650 #[test]
651 fn test_tx_args_with_options() {
652 use clap::Parser;
653
654 #[derive(Parser)]
655 struct TestCli {
656 #[command(flatten)]
657 args: TxArgs,
658 }
659
660 let cli = TestCli::try_parse_from([
661 "test",
662 VALID_TX_HASH,
663 "--chain",
664 "polygon",
665 "--trace",
666 "--decode",
667 "--format",
668 "json",
669 ])
670 .unwrap();
671
672 assert_eq!(cli.args.chain, "polygon");
673 assert!(cli.args.trace);
674 assert!(cli.args.decode);
675 assert_eq!(cli.args.format, Some(OutputFormat::Json));
676 }
677
678 #[test]
679 fn test_transaction_report_serialization() {
680 let report = TransactionReport {
681 hash: VALID_TX_HASH.to_string(),
682 chain: "ethereum".to_string(),
683 block: BlockInfo {
684 number: 12345678,
685 timestamp: 1700000000,
686 hash: "0xblock".to_string(),
687 },
688 transaction: TransactionDetails {
689 from: "0xfrom".to_string(),
690 to: Some("0xto".to_string()),
691 value: "1.0".to_string(),
692 nonce: 42,
693 transaction_index: 5,
694 status: true,
695 input: "0x".to_string(),
696 },
697 gas: GasInfo {
698 gas_limit: 100000,
699 gas_used: 21000,
700 gas_price: "20000000000".to_string(),
701 transaction_fee: "0.00042".to_string(),
702 effective_gas_price: None,
703 },
704 decoded_input: None,
705 internal_transactions: None,
706 };
707
708 let json = serde_json::to_string(&report).unwrap();
709 assert!(json.contains(VALID_TX_HASH));
710 assert!(json.contains("12345678"));
711 assert!(json.contains("21000"));
712 assert!(!json.contains("decoded_input"));
713 assert!(!json.contains("internal_transactions"));
714 }
715
716 #[test]
717 fn test_block_info_serialization() {
718 let block = BlockInfo {
719 number: 12345678,
720 timestamp: 1700000000,
721 hash: "0xblockhash".to_string(),
722 };
723
724 let json = serde_json::to_string(&block).unwrap();
725 assert!(json.contains("12345678"));
726 assert!(json.contains("1700000000"));
727 assert!(json.contains("0xblockhash"));
728 }
729
730 #[test]
731 fn test_gas_info_serialization() {
732 let gas = GasInfo {
733 gas_limit: 100000,
734 gas_used: 50000,
735 gas_price: "20000000000".to_string(),
736 transaction_fee: "0.001".to_string(),
737 effective_gas_price: Some("25000000000".to_string()),
738 };
739
740 let json = serde_json::to_string(&gas).unwrap();
741 assert!(json.contains("100000"));
742 assert!(json.contains("50000"));
743 assert!(json.contains("effective_gas_price"));
744 }
745
746 #[test]
747 fn test_decoded_input_serialization() {
748 let decoded = DecodedInput {
749 function_signature: "transfer(address,uint256)".to_string(),
750 function_name: "transfer".to_string(),
751 parameters: vec![
752 DecodedParameter {
753 name: "to".to_string(),
754 param_type: "address".to_string(),
755 value: "0xrecipient".to_string(),
756 },
757 DecodedParameter {
758 name: "amount".to_string(),
759 param_type: "uint256".to_string(),
760 value: "1000000".to_string(),
761 },
762 ],
763 };
764
765 let json = serde_json::to_string(&decoded).unwrap();
766 assert!(json.contains("transfer(address,uint256)"));
767 assert!(json.contains("0xrecipient"));
768 assert!(json.contains("1000000"));
769 }
770
771 #[test]
772 fn test_internal_transaction_serialization() {
773 let internal = InternalTransaction {
774 call_type: "call".to_string(),
775 from: "0xfrom".to_string(),
776 to: "0xto".to_string(),
777 value: "1.0".to_string(),
778 gas: 50000,
779 input: "0x".to_string(),
780 output: "0x".to_string(),
781 };
782
783 let json = serde_json::to_string(&internal).unwrap();
784 assert!(json.contains("call"));
785 assert!(json.contains("0xfrom"));
786 assert!(json.contains("50000"));
787 }
788
789 fn make_test_tx_report() -> TransactionReport {
794 TransactionReport {
795 hash: VALID_TX_HASH.to_string(),
796 chain: "ethereum".to_string(),
797 block: BlockInfo {
798 number: 12345678,
799 timestamp: 1700000000,
800 hash: "0xblock".to_string(),
801 },
802 transaction: TransactionDetails {
803 from: "0xfrom".to_string(),
804 to: Some("0xto".to_string()),
805 value: "1.0".to_string(),
806 nonce: 42,
807 transaction_index: 5,
808 status: true,
809 input: "0xa9059cbb0000000000".to_string(),
810 },
811 gas: GasInfo {
812 gas_limit: 100000,
813 gas_used: 21000,
814 gas_price: "20000000000".to_string(),
815 transaction_fee: "0.00042".to_string(),
816 effective_gas_price: None,
817 },
818 decoded_input: Some(DecodedInput {
819 function_signature: "transfer(address,uint256)".to_string(),
820 function_name: "transfer".to_string(),
821 parameters: vec![DecodedParameter {
822 name: "to".to_string(),
823 param_type: "address".to_string(),
824 value: "0xrecipient".to_string(),
825 }],
826 }),
827 internal_transactions: Some(vec![InternalTransaction {
828 call_type: "call".to_string(),
829 from: "0xfrom".to_string(),
830 to: "0xto".to_string(),
831 value: "0.5".to_string(),
832 gas: 30000,
833 input: "0x".to_string(),
834 output: "0x".to_string(),
835 }]),
836 }
837 }
838
839 #[test]
840 fn test_output_report_json() {
841 let report = make_test_tx_report();
842 let result = output_report(&report, OutputFormat::Json);
843 assert!(result.is_ok());
844 }
845
846 #[test]
847 fn test_output_report_csv() {
848 let report = make_test_tx_report();
849 let result = output_report(&report, OutputFormat::Csv);
850 assert!(result.is_ok());
851 }
852
853 #[test]
854 fn test_output_report_table() {
855 let report = make_test_tx_report();
856 let result = output_report(&report, OutputFormat::Table);
857 assert!(result.is_ok());
858 }
859
860 #[test]
861 fn test_output_report_table_no_decoded() {
862 let mut report = make_test_tx_report();
863 report.decoded_input = None;
864 report.internal_transactions = None;
865 let result = output_report(&report, OutputFormat::Table);
866 assert!(result.is_ok());
867 }
868
869 #[test]
870 fn test_output_report_table_failed_tx() {
871 let mut report = make_test_tx_report();
872 report.transaction.status = false;
873 report.transaction.to = None; let result = output_report(&report, OutputFormat::Table);
875 assert!(result.is_ok());
876 }
877
878 #[test]
879 fn test_output_report_table_empty_traces() {
880 let mut report = make_test_tx_report();
881 report.internal_transactions = Some(vec![]);
882 let result = output_report(&report, OutputFormat::Table);
883 assert!(result.is_ok());
884 }
885
886 #[test]
887 fn test_output_report_csv_no_to() {
888 let mut report = make_test_tx_report();
889 report.transaction.to = None;
890 let result = output_report(&report, OutputFormat::Csv);
891 assert!(result.is_ok());
892 }
893
894 use crate::chains::{
899 Balance as ChainBalance, ChainClient, ChainClientFactory, DexDataSource,
900 TokenBalance as ChainTokenBalance, Transaction as ChainTransaction,
901 };
902 use async_trait::async_trait;
903
904 struct MockTxClient;
905
906 #[async_trait]
907 impl ChainClient for MockTxClient {
908 fn chain_name(&self) -> &str {
909 "ethereum"
910 }
911 fn native_token_symbol(&self) -> &str {
912 "ETH"
913 }
914 async fn get_balance(&self, _a: &str) -> crate::error::Result<ChainBalance> {
915 Ok(ChainBalance {
916 raw: "0".into(),
917 formatted: "0 ETH".into(),
918 decimals: 18,
919 symbol: "ETH".into(),
920 usd_value: None,
921 })
922 }
923 async fn enrich_balance_usd(&self, _b: &mut ChainBalance) {}
924 async fn get_transaction(&self, _h: &str) -> crate::error::Result<ChainTransaction> {
925 Ok(ChainTransaction {
926 hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".into(),
927 block_number: Some(12345678),
928 timestamp: Some(1700000000),
929 from: "0xfrom".into(),
930 to: Some("0xto".into()),
931 value: "1000000000000000000".into(),
932 gas_limit: 21000,
933 gas_used: Some(21000),
934 gas_price: "20000000000".into(),
935 nonce: 42,
936 input: "0xa9059cbb0000000000000000000000001234".into(),
937 status: Some(true),
938 })
939 }
940 async fn get_transactions(
941 &self,
942 _a: &str,
943 _l: u32,
944 ) -> crate::error::Result<Vec<ChainTransaction>> {
945 Ok(vec![])
946 }
947 async fn get_block_number(&self) -> crate::error::Result<u64> {
948 Ok(12345678)
949 }
950 async fn get_token_balances(
951 &self,
952 _a: &str,
953 ) -> crate::error::Result<Vec<ChainTokenBalance>> {
954 Ok(vec![])
955 }
956 async fn get_code(&self, _addr: &str) -> crate::error::Result<String> {
957 Ok("0x".into())
958 }
959 }
960
961 struct MockTxFactory;
962 impl ChainClientFactory for MockTxFactory {
963 fn create_chain_client(&self, _chain: &str) -> crate::error::Result<Box<dyn ChainClient>> {
964 Ok(Box::new(MockTxClient))
965 }
966 fn create_dex_client(&self) -> Box<dyn DexDataSource> {
967 crate::chains::DefaultClientFactory {
968 chains_config: Default::default(),
969 }
970 .create_dex_client()
971 }
972 }
973
974 #[tokio::test]
975 async fn test_fetch_transaction_report_mock() {
976 let factory = MockTxFactory;
977 let result = fetch_transaction_report(
978 "0xabc123def456789012345678901234567890123456789012345678901234abcd",
979 "ethereum",
980 false,
981 false,
982 &factory,
983 )
984 .await;
985 assert!(result.is_ok());
986 let report = result.unwrap();
987 assert_eq!(report.transaction.from, "0xfrom");
988 assert!(report.transaction.status);
989 }
990
991 #[tokio::test]
992 async fn test_fetch_transaction_report_with_decode() {
993 let factory = MockTxFactory;
994 let result = fetch_transaction_report(
995 "0xabc123def456789012345678901234567890123456789012345678901234abcd",
996 "ethereum",
997 true,
998 false,
999 &factory,
1000 )
1001 .await;
1002 assert!(result.is_ok());
1003 }
1004
1005 use crate::chains::mocks::MockClientFactory;
1010
1011 fn mock_factory() -> MockClientFactory {
1012 MockClientFactory::new()
1013 }
1014
1015 #[tokio::test]
1016 async fn test_run_ethereum_tx() {
1017 let config = Config::default();
1018 let factory = mock_factory();
1019 let args = TxArgs {
1020 hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1021 chain: "ethereum".to_string(),
1022 format: Some(OutputFormat::Json),
1023 trace: false,
1024 decode: false,
1025 };
1026 let result = super::run(args, &config, &factory).await;
1027 assert!(result.is_ok());
1028 }
1029
1030 #[tokio::test]
1031 async fn test_run_tx_with_decode() {
1032 let config = Config::default();
1033 let mut factory = mock_factory();
1034 factory.mock_client.transaction.input = "0xa9059cbb000000000000000000000000".to_string();
1035 let args = TxArgs {
1036 hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1037 chain: "ethereum".to_string(),
1038 format: Some(OutputFormat::Table),
1039 trace: false,
1040 decode: true,
1041 };
1042 let result = super::run(args, &config, &factory).await;
1043 assert!(result.is_ok());
1044 }
1045
1046 #[tokio::test]
1047 async fn test_run_tx_with_trace() {
1048 let config = Config::default();
1049 let factory = mock_factory();
1050 let args = TxArgs {
1051 hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1052 chain: "ethereum".to_string(),
1053 format: Some(OutputFormat::Csv),
1054 trace: true,
1055 decode: false,
1056 };
1057 let result = super::run(args, &config, &factory).await;
1058 assert!(result.is_ok());
1059 }
1060
1061 #[tokio::test]
1062 async fn test_run_tx_invalid_hash() {
1063 let config = Config::default();
1064 let factory = mock_factory();
1065 let args = TxArgs {
1066 hash: "invalid".to_string(),
1067 chain: "ethereum".to_string(),
1068 format: Some(OutputFormat::Json),
1069 trace: false,
1070 decode: false,
1071 };
1072 let result = super::run(args, &config, &factory).await;
1073 assert!(result.is_err());
1074 }
1075
1076 #[tokio::test]
1077 async fn test_run_tx_auto_detect_tron() {
1078 let config = Config::default();
1079 let factory = mock_factory();
1080 let args = TxArgs {
1081 hash: "abc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1083 chain: "ethereum".to_string(), format: Some(OutputFormat::Json),
1085 trace: false,
1086 decode: false,
1087 };
1088 let result = super::run(args, &config, &factory).await;
1089 assert!(result.is_ok());
1090 }
1091
1092 #[test]
1097 fn test_format_tx_markdown_basic() {
1098 let report = make_test_tx_report();
1099 let md = format_tx_markdown(&report);
1100 assert!(md.contains("# Transaction Analysis"));
1101 assert!(md.contains(&report.hash));
1102 assert!(md.contains(&report.chain));
1103 assert!(md.contains("Success"));
1104 assert!(md.contains(&report.transaction.from));
1105 }
1106
1107 #[test]
1108 fn test_format_tx_markdown_contract_creation() {
1109 let mut report = make_test_tx_report();
1110 report.transaction.to = None;
1111 let md = format_tx_markdown(&report);
1112 assert!(md.contains("Contract Creation"));
1113 }
1114
1115 #[test]
1116 fn test_format_tx_markdown_failed_tx() {
1117 let mut report = make_test_tx_report();
1118 report.transaction.status = false;
1119 let md = format_tx_markdown(&report);
1120 assert!(md.contains("Failed"));
1121 }
1122
1123 #[test]
1124 fn test_format_tx_markdown_with_decoded_input() {
1125 let report = make_test_tx_report();
1126 let md = format_tx_markdown(&report);
1127 assert!(md.contains("## Decoded Input"));
1128 assert!(md.contains("transfer"));
1129 assert!(md.contains("transfer(address,uint256)"));
1130 }
1131
1132 #[test]
1133 fn test_format_tx_markdown_with_internal_transactions() {
1134 let report = make_test_tx_report();
1135 let md = format_tx_markdown(&report);
1136 assert!(md.contains("## Internal Transactions"));
1137 assert!(md.contains("call"));
1138 }
1139
1140 #[test]
1141 fn test_format_tx_markdown_no_decoded_input() {
1142 let mut report = make_test_tx_report();
1143 report.decoded_input = None;
1144 let md = format_tx_markdown(&report);
1145 assert!(!md.contains("## Decoded Input"));
1146 }
1147
1148 #[test]
1149 fn test_format_tx_markdown_no_internal_transactions() {
1150 let mut report = make_test_tx_report();
1151 report.internal_transactions = None;
1152 let md = format_tx_markdown(&report);
1153 assert!(!md.contains("## Internal Transactions"));
1154 }
1155
1156 #[test]
1157 fn test_format_tx_markdown_empty_internal_transactions() {
1158 let mut report = make_test_tx_report();
1159 report.internal_transactions = Some(vec![]);
1160 let md = format_tx_markdown(&report);
1161 assert!(!md.contains("## Internal Transactions"));
1162 }
1163}