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