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)]
27pub struct TxArgs {
28 #[arg(value_name = "HASH")]
33 pub hash: String,
34
35 #[arg(short, long, default_value = "ethereum")]
40 pub chain: String,
41
42 #[arg(short, long, value_name = "FORMAT")]
44 pub format: Option<OutputFormat>,
45
46 #[arg(long)]
48 pub trace: bool,
49
50 #[arg(long)]
52 pub decode: bool,
53}
54
55#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
57pub struct TransactionReport {
58 pub hash: String,
60
61 pub chain: String,
63
64 pub block: BlockInfo,
66
67 pub transaction: TransactionDetails,
69
70 pub gas: GasInfo,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub decoded_input: Option<DecodedInput>,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub internal_transactions: Option<Vec<InternalTransaction>>,
80}
81
82#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
84pub struct BlockInfo {
85 pub number: u64,
87
88 pub timestamp: u64,
90
91 pub hash: String,
93}
94
95#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
97pub struct TransactionDetails {
98 pub from: String,
100
101 pub to: Option<String>,
103
104 pub value: String,
106
107 pub nonce: u64,
109
110 pub transaction_index: u64,
112
113 pub status: bool,
115
116 pub input: String,
118}
119
120#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
122pub struct GasInfo {
123 pub gas_limit: u64,
125
126 pub gas_used: u64,
128
129 pub gas_price: String,
131
132 pub transaction_fee: String,
134
135 #[serde(skip_serializing_if = "Option::is_none")]
137 pub effective_gas_price: Option<String>,
138}
139
140#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
142pub struct DecodedInput {
143 pub function_signature: String,
145
146 pub function_name: String,
148
149 pub parameters: Vec<DecodedParameter>,
151}
152
153#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
155pub struct DecodedParameter {
156 pub name: String,
158
159 pub param_type: String,
161
162 pub value: String,
164}
165
166#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
168pub struct InternalTransaction {
169 pub call_type: String,
171
172 pub from: String,
174
175 pub to: String,
177
178 pub value: String,
180
181 pub gas: u64,
183
184 pub input: String,
186
187 pub output: String,
189}
190
191pub async fn run(
207 mut args: TxArgs,
208 config: &Config,
209 clients: &dyn ChainClientFactory,
210) -> Result<()> {
211 if args.chain == "ethereum"
213 && let Some(inferred) = crate::chains::infer_chain_from_hash(&args.hash)
214 && inferred != "ethereum"
215 {
216 tracing::info!("Auto-detected chain: {}", inferred);
217 println!("Auto-detected chain: {}", inferred);
218 args.chain = inferred.to_string();
219 }
220
221 tracing::info!(
222 hash = %args.hash,
223 chain = %args.chain,
224 "Starting transaction analysis"
225 );
226
227 validate_tx_hash(&args.hash, &args.chain)?;
229
230 println!("Analyzing transaction on {}...", args.chain);
231
232 let client = clients.create_chain_client(&args.chain)?;
233 let tx = client.get_transaction(&args.hash).await?;
234
235 let gas_price_val: u128 = tx.gas_price.parse().unwrap_or(0);
237 let gas_used_val = tx.gas_used.unwrap_or(0) as u128;
238 let fee_wei = gas_price_val * gas_used_val;
239 let chain_lower = args.chain.to_lowercase();
240 let fee_str = if chain_lower == "solana" || chain_lower == "sol" {
241 let fee_sol = tx.gas_price.parse::<f64>().unwrap_or(0.0) / 1_000_000_000.0;
243 format!("{:.9}", fee_sol)
244 } else {
245 fee_wei.to_string()
246 };
247
248 let report = TransactionReport {
249 hash: tx.hash.clone(),
250 chain: args.chain.clone(),
251 block: BlockInfo {
252 number: tx.block_number.unwrap_or(0),
253 timestamp: tx.timestamp.unwrap_or(0),
254 hash: String::new(), },
256 transaction: TransactionDetails {
257 from: tx.from.clone(),
258 to: tx.to.clone(),
259 value: tx.value.clone(),
260 nonce: tx.nonce,
261 transaction_index: 0,
262 status: tx.status.unwrap_or(true),
263 input: tx.input.clone(),
264 },
265 gas: GasInfo {
266 gas_limit: tx.gas_limit,
267 gas_used: tx.gas_used.unwrap_or(0),
268 gas_price: tx.gas_price.clone(),
269 transaction_fee: fee_str,
270 effective_gas_price: None,
271 },
272 decoded_input: if args.decode && !tx.input.is_empty() && tx.input != "0x" {
273 let selector = if tx.input.len() >= 10 {
275 &tx.input[..10]
276 } else {
277 &tx.input
278 };
279 Some(DecodedInput {
280 function_signature: format!("{}(...)", selector),
281 function_name: selector.to_string(),
282 parameters: vec![],
283 })
284 } else if args.decode {
285 Some(DecodedInput {
286 function_signature: "transfer()".to_string(),
287 function_name: "Native Transfer".to_string(),
288 parameters: vec![],
289 })
290 } else {
291 None
292 },
293 internal_transactions: if args.trace { Some(vec![]) } else { None },
294 };
295
296 let format = args.format.unwrap_or(config.output.format);
298 output_report(&report, format)?;
299
300 Ok(())
301}
302
303fn validate_tx_hash(hash: &str, chain: &str) -> Result<()> {
305 match chain {
306 "ethereum" | "polygon" | "arbitrum" | "optimism" | "base" | "bsc" | "aegis" => {
308 if !hash.starts_with("0x") {
309 return Err(ScopeError::InvalidHash(format!(
310 "Transaction hash must start with '0x': {}",
311 hash
312 )));
313 }
314 if hash.len() != 66 {
315 return Err(ScopeError::InvalidHash(format!(
316 "Transaction hash must be 66 characters (0x + 64 hex): {}",
317 hash
318 )));
319 }
320 if !hash[2..].chars().all(|c| c.is_ascii_hexdigit()) {
321 return Err(ScopeError::InvalidHash(format!(
322 "Transaction hash contains invalid hex characters: {}",
323 hash
324 )));
325 }
326 }
327 "solana" => {
329 validate_solana_signature(hash)?;
330 }
331 "tron" => {
333 validate_tron_tx_hash(hash)?;
334 }
335 _ => {
336 return Err(ScopeError::Chain(format!(
337 "Unsupported chain: {}. Supported: ethereum, polygon, arbitrum, optimism, base, bsc, aegis, solana, tron",
338 chain
339 )));
340 }
341 }
342 Ok(())
343}
344
345fn output_report(report: &TransactionReport, format: OutputFormat) -> Result<()> {
347 match format {
348 OutputFormat::Json => {
349 let json = serde_json::to_string_pretty(report)?;
350 println!("{}", json);
351 }
352 OutputFormat::Csv => {
353 println!("hash,chain,block,from,to,value,status,gas_used,fee");
354 println!(
355 "{},{},{},{},{},{},{},{},{}",
356 report.hash,
357 report.chain,
358 report.block.number,
359 report.transaction.from,
360 report.transaction.to.as_deref().unwrap_or(""),
361 report.transaction.value,
362 report.transaction.status,
363 report.gas.gas_used,
364 report.gas.transaction_fee
365 );
366 }
367 OutputFormat::Table => {
368 println!("Transaction Analysis Report");
369 println!("===========================");
370 println!("Hash: {}", report.hash);
371 println!("Chain: {}", report.chain);
372 println!("Block: {}", report.block.number);
373 println!(
374 "Status: {}",
375 if report.transaction.status {
376 "Success"
377 } else {
378 "Failed"
379 }
380 );
381 println!();
382 println!("From: {}", report.transaction.from);
383 println!(
384 "To: {}",
385 report
386 .transaction
387 .to
388 .as_deref()
389 .unwrap_or("Contract Creation")
390 );
391 println!("Value: {}", report.transaction.value);
392 println!();
393 println!("Gas Limit: {}", report.gas.gas_limit);
394 println!("Gas Used: {}", report.gas.gas_used);
395 println!("Gas Price: {}", report.gas.gas_price);
396 println!("Fee: {}", report.gas.transaction_fee);
397
398 if let Some(ref decoded) = report.decoded_input {
399 println!();
400 println!("Function: {}", decoded.function_name);
401 println!("Signature: {}", decoded.function_signature);
402 if !decoded.parameters.is_empty() {
403 println!("Parameters:");
404 for param in &decoded.parameters {
405 println!(" {} ({}): {}", param.name, param.param_type, param.value);
406 }
407 }
408 }
409
410 if let Some(ref traces) = report.internal_transactions
411 && !traces.is_empty()
412 {
413 println!();
414 println!("Internal Transactions: {}", traces.len());
415 for (i, trace) in traces.iter().enumerate() {
416 println!(
417 " [{}] {} {} -> {}",
418 i, trace.call_type, trace.from, trace.to
419 );
420 }
421 }
422 }
423 }
424 Ok(())
425}
426
427#[cfg(test)]
432mod tests {
433 use super::*;
434
435 const VALID_TX_HASH: &str =
436 "0xabc123def456789012345678901234567890123456789012345678901234abcd";
437
438 #[test]
439 fn test_validate_tx_hash_valid() {
440 let result = validate_tx_hash(VALID_TX_HASH, "ethereum");
441 assert!(result.is_ok());
442 }
443
444 #[test]
445 fn test_validate_tx_hash_valid_lowercase() {
446 let hash = "0xabc123def456789012345678901234567890123456789012345678901234abcd";
447 let result = validate_tx_hash(hash, "ethereum");
448 assert!(result.is_ok());
449 }
450
451 #[test]
452 fn test_validate_tx_hash_valid_polygon() {
453 let result = validate_tx_hash(VALID_TX_HASH, "polygon");
454 assert!(result.is_ok());
455 }
456
457 #[test]
458 fn test_validate_tx_hash_missing_prefix() {
459 let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
460 let result = validate_tx_hash(hash, "ethereum");
461 assert!(result.is_err());
462 assert!(result.unwrap_err().to_string().contains("0x"));
463 }
464
465 #[test]
466 fn test_validate_tx_hash_too_short() {
467 let hash = "0xabc123";
468 let result = validate_tx_hash(hash, "ethereum");
469 assert!(result.is_err());
470 assert!(result.unwrap_err().to_string().contains("66 characters"));
471 }
472
473 #[test]
474 fn test_validate_tx_hash_too_long() {
475 let hash = "0xabc123def456789012345678901234567890123456789012345678901234abcde";
476 let result = validate_tx_hash(hash, "ethereum");
477 assert!(result.is_err());
478 }
479
480 #[test]
481 fn test_validate_tx_hash_invalid_hex() {
482 let hash = "0xabc123def456789012345678901234567890123456789012345678901234GHIJ";
483 let result = validate_tx_hash(hash, "ethereum");
484 assert!(result.is_err());
485 assert!(result.unwrap_err().to_string().contains("invalid hex"));
486 }
487
488 #[test]
489 fn test_validate_tx_hash_unsupported_chain() {
490 let result = validate_tx_hash(VALID_TX_HASH, "bitcoin");
491 assert!(result.is_err());
492 assert!(
493 result
494 .unwrap_err()
495 .to_string()
496 .contains("Unsupported chain")
497 );
498 }
499
500 #[test]
501 fn test_validate_tx_hash_valid_bsc() {
502 let result = validate_tx_hash(VALID_TX_HASH, "bsc");
503 assert!(result.is_ok());
504 }
505
506 #[test]
507 fn test_validate_tx_hash_valid_aegis() {
508 let result = validate_tx_hash(VALID_TX_HASH, "aegis");
509 assert!(result.is_ok());
510 }
511
512 #[test]
513 fn test_validate_tx_hash_valid_solana() {
514 let sig = "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
516 let result = validate_tx_hash(sig, "solana");
517 assert!(result.is_ok());
518 }
519
520 #[test]
521 fn test_validate_tx_hash_invalid_solana() {
522 let result = validate_tx_hash(VALID_TX_HASH, "solana");
524 assert!(result.is_err());
525 }
526
527 #[test]
528 fn test_validate_tx_hash_valid_tron() {
529 let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
531 let result = validate_tx_hash(hash, "tron");
532 assert!(result.is_ok());
533 }
534
535 #[test]
536 fn test_validate_tx_hash_invalid_tron() {
537 let result = validate_tx_hash(VALID_TX_HASH, "tron");
539 assert!(result.is_err());
540 }
541
542 #[test]
543 fn test_tx_args_default_values() {
544 use clap::Parser;
545
546 #[derive(Parser)]
547 struct TestCli {
548 #[command(flatten)]
549 args: TxArgs,
550 }
551
552 let cli = TestCli::try_parse_from(["test", VALID_TX_HASH]).unwrap();
553
554 assert_eq!(cli.args.chain, "ethereum");
555 assert!(!cli.args.trace);
556 assert!(!cli.args.decode);
557 assert!(cli.args.format.is_none());
558 }
559
560 #[test]
561 fn test_tx_args_with_options() {
562 use clap::Parser;
563
564 #[derive(Parser)]
565 struct TestCli {
566 #[command(flatten)]
567 args: TxArgs,
568 }
569
570 let cli = TestCli::try_parse_from([
571 "test",
572 VALID_TX_HASH,
573 "--chain",
574 "polygon",
575 "--trace",
576 "--decode",
577 "--format",
578 "json",
579 ])
580 .unwrap();
581
582 assert_eq!(cli.args.chain, "polygon");
583 assert!(cli.args.trace);
584 assert!(cli.args.decode);
585 assert_eq!(cli.args.format, Some(OutputFormat::Json));
586 }
587
588 #[test]
589 fn test_transaction_report_serialization() {
590 let report = TransactionReport {
591 hash: VALID_TX_HASH.to_string(),
592 chain: "ethereum".to_string(),
593 block: BlockInfo {
594 number: 12345678,
595 timestamp: 1700000000,
596 hash: "0xblock".to_string(),
597 },
598 transaction: TransactionDetails {
599 from: "0xfrom".to_string(),
600 to: Some("0xto".to_string()),
601 value: "1.0".to_string(),
602 nonce: 42,
603 transaction_index: 5,
604 status: true,
605 input: "0x".to_string(),
606 },
607 gas: GasInfo {
608 gas_limit: 100000,
609 gas_used: 21000,
610 gas_price: "20000000000".to_string(),
611 transaction_fee: "0.00042".to_string(),
612 effective_gas_price: None,
613 },
614 decoded_input: None,
615 internal_transactions: None,
616 };
617
618 let json = serde_json::to_string(&report).unwrap();
619 assert!(json.contains(VALID_TX_HASH));
620 assert!(json.contains("12345678"));
621 assert!(json.contains("21000"));
622 assert!(!json.contains("decoded_input"));
623 assert!(!json.contains("internal_transactions"));
624 }
625
626 #[test]
627 fn test_block_info_serialization() {
628 let block = BlockInfo {
629 number: 12345678,
630 timestamp: 1700000000,
631 hash: "0xblockhash".to_string(),
632 };
633
634 let json = serde_json::to_string(&block).unwrap();
635 assert!(json.contains("12345678"));
636 assert!(json.contains("1700000000"));
637 assert!(json.contains("0xblockhash"));
638 }
639
640 #[test]
641 fn test_gas_info_serialization() {
642 let gas = GasInfo {
643 gas_limit: 100000,
644 gas_used: 50000,
645 gas_price: "20000000000".to_string(),
646 transaction_fee: "0.001".to_string(),
647 effective_gas_price: Some("25000000000".to_string()),
648 };
649
650 let json = serde_json::to_string(&gas).unwrap();
651 assert!(json.contains("100000"));
652 assert!(json.contains("50000"));
653 assert!(json.contains("effective_gas_price"));
654 }
655
656 #[test]
657 fn test_decoded_input_serialization() {
658 let decoded = DecodedInput {
659 function_signature: "transfer(address,uint256)".to_string(),
660 function_name: "transfer".to_string(),
661 parameters: vec![
662 DecodedParameter {
663 name: "to".to_string(),
664 param_type: "address".to_string(),
665 value: "0xrecipient".to_string(),
666 },
667 DecodedParameter {
668 name: "amount".to_string(),
669 param_type: "uint256".to_string(),
670 value: "1000000".to_string(),
671 },
672 ],
673 };
674
675 let json = serde_json::to_string(&decoded).unwrap();
676 assert!(json.contains("transfer(address,uint256)"));
677 assert!(json.contains("0xrecipient"));
678 assert!(json.contains("1000000"));
679 }
680
681 #[test]
682 fn test_internal_transaction_serialization() {
683 let internal = InternalTransaction {
684 call_type: "call".to_string(),
685 from: "0xfrom".to_string(),
686 to: "0xto".to_string(),
687 value: "1.0".to_string(),
688 gas: 50000,
689 input: "0x".to_string(),
690 output: "0x".to_string(),
691 };
692
693 let json = serde_json::to_string(&internal).unwrap();
694 assert!(json.contains("call"));
695 assert!(json.contains("0xfrom"));
696 assert!(json.contains("50000"));
697 }
698
699 fn make_test_tx_report() -> TransactionReport {
704 TransactionReport {
705 hash: VALID_TX_HASH.to_string(),
706 chain: "ethereum".to_string(),
707 block: BlockInfo {
708 number: 12345678,
709 timestamp: 1700000000,
710 hash: "0xblock".to_string(),
711 },
712 transaction: TransactionDetails {
713 from: "0xfrom".to_string(),
714 to: Some("0xto".to_string()),
715 value: "1.0".to_string(),
716 nonce: 42,
717 transaction_index: 5,
718 status: true,
719 input: "0xa9059cbb0000000000".to_string(),
720 },
721 gas: GasInfo {
722 gas_limit: 100000,
723 gas_used: 21000,
724 gas_price: "20000000000".to_string(),
725 transaction_fee: "0.00042".to_string(),
726 effective_gas_price: None,
727 },
728 decoded_input: Some(DecodedInput {
729 function_signature: "transfer(address,uint256)".to_string(),
730 function_name: "transfer".to_string(),
731 parameters: vec![DecodedParameter {
732 name: "to".to_string(),
733 param_type: "address".to_string(),
734 value: "0xrecipient".to_string(),
735 }],
736 }),
737 internal_transactions: Some(vec![InternalTransaction {
738 call_type: "call".to_string(),
739 from: "0xfrom".to_string(),
740 to: "0xto".to_string(),
741 value: "0.5".to_string(),
742 gas: 30000,
743 input: "0x".to_string(),
744 output: "0x".to_string(),
745 }]),
746 }
747 }
748
749 #[test]
750 fn test_output_report_json() {
751 let report = make_test_tx_report();
752 let result = output_report(&report, OutputFormat::Json);
753 assert!(result.is_ok());
754 }
755
756 #[test]
757 fn test_output_report_csv() {
758 let report = make_test_tx_report();
759 let result = output_report(&report, OutputFormat::Csv);
760 assert!(result.is_ok());
761 }
762
763 #[test]
764 fn test_output_report_table() {
765 let report = make_test_tx_report();
766 let result = output_report(&report, OutputFormat::Table);
767 assert!(result.is_ok());
768 }
769
770 #[test]
771 fn test_output_report_table_no_decoded() {
772 let mut report = make_test_tx_report();
773 report.decoded_input = None;
774 report.internal_transactions = None;
775 let result = output_report(&report, OutputFormat::Table);
776 assert!(result.is_ok());
777 }
778
779 #[test]
780 fn test_output_report_table_failed_tx() {
781 let mut report = make_test_tx_report();
782 report.transaction.status = false;
783 report.transaction.to = None; let result = output_report(&report, OutputFormat::Table);
785 assert!(result.is_ok());
786 }
787
788 #[test]
789 fn test_output_report_table_empty_traces() {
790 let mut report = make_test_tx_report();
791 report.internal_transactions = Some(vec![]);
792 let result = output_report(&report, OutputFormat::Table);
793 assert!(result.is_ok());
794 }
795
796 #[test]
797 fn test_output_report_csv_no_to() {
798 let mut report = make_test_tx_report();
799 report.transaction.to = None;
800 let result = output_report(&report, OutputFormat::Csv);
801 assert!(result.is_ok());
802 }
803
804 use crate::chains::mocks::MockClientFactory;
809
810 fn mock_factory() -> MockClientFactory {
811 MockClientFactory::new()
812 }
813
814 #[tokio::test]
815 async fn test_run_ethereum_tx() {
816 let config = Config::default();
817 let factory = mock_factory();
818 let args = TxArgs {
819 hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
820 chain: "ethereum".to_string(),
821 format: Some(OutputFormat::Json),
822 trace: false,
823 decode: false,
824 };
825 let result = super::run(args, &config, &factory).await;
826 assert!(result.is_ok());
827 }
828
829 #[tokio::test]
830 async fn test_run_tx_with_decode() {
831 let config = Config::default();
832 let mut factory = mock_factory();
833 factory.mock_client.transaction.input = "0xa9059cbb000000000000000000000000".to_string();
834 let args = TxArgs {
835 hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
836 chain: "ethereum".to_string(),
837 format: Some(OutputFormat::Table),
838 trace: false,
839 decode: true,
840 };
841 let result = super::run(args, &config, &factory).await;
842 assert!(result.is_ok());
843 }
844
845 #[tokio::test]
846 async fn test_run_tx_with_trace() {
847 let config = Config::default();
848 let factory = mock_factory();
849 let args = TxArgs {
850 hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
851 chain: "ethereum".to_string(),
852 format: Some(OutputFormat::Csv),
853 trace: true,
854 decode: false,
855 };
856 let result = super::run(args, &config, &factory).await;
857 assert!(result.is_ok());
858 }
859
860 #[tokio::test]
861 async fn test_run_tx_invalid_hash() {
862 let config = Config::default();
863 let factory = mock_factory();
864 let args = TxArgs {
865 hash: "invalid".to_string(),
866 chain: "ethereum".to_string(),
867 format: Some(OutputFormat::Json),
868 trace: false,
869 decode: false,
870 };
871 let result = super::run(args, &config, &factory).await;
872 assert!(result.is_err());
873 }
874
875 #[tokio::test]
876 async fn test_run_tx_auto_detect_tron() {
877 let config = Config::default();
878 let factory = mock_factory();
879 let args = TxArgs {
880 hash: "abc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
882 chain: "ethereum".to_string(), format: Some(OutputFormat::Json),
884 trace: false,
885 decode: false,
886 };
887 let result = super::run(args, &config, &factory).await;
888 assert!(result.is_ok());
889 }
890}