Skip to main content

scope/cli/
tx.rs

1//! # Transaction Analysis Command
2//!
3//! This module implements the `bca tx` command for analyzing
4//! blockchain transactions. It decodes transaction data, traces
5//! execution, and displays detailed transaction information.
6//!
7//! ## Usage
8//!
9//! ```bash
10//! # Basic transaction analysis
11//! bca tx 0xabc123...
12//!
13//! # Specify chain
14//! bca tx 0xabc123... --chain polygon
15//!
16//! # Include internal transactions
17//! bca tx 0xabc123... --trace
18//! ```
19
20use 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/// Arguments for the transaction analysis command.
26#[derive(Debug, Clone, Args)]
27pub struct TxArgs {
28    /// The transaction hash to analyze.
29    ///
30    /// Must be a valid transaction hash for the target chain
31    /// (e.g., 0x-prefixed 64-character hex for Ethereum).
32    #[arg(value_name = "HASH")]
33    pub hash: String,
34
35    /// Target blockchain network.
36    ///
37    /// EVM chains: ethereum, polygon, arbitrum, optimism, base, bsc, aegis
38    /// Non-EVM chains: solana, tron
39    #[arg(short, long, default_value = "ethereum")]
40    pub chain: String,
41
42    /// Override output format for this command.
43    #[arg(short, long, value_name = "FORMAT")]
44    pub format: Option<OutputFormat>,
45
46    /// Include internal transactions (trace).
47    #[arg(long)]
48    pub trace: bool,
49
50    /// Decode transaction input data.
51    #[arg(long)]
52    pub decode: bool,
53}
54
55/// Result of a transaction analysis.
56#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
57pub struct TransactionReport {
58    /// The analyzed transaction hash.
59    pub hash: String,
60
61    /// The blockchain network.
62    pub chain: String,
63
64    /// Block information.
65    pub block: BlockInfo,
66
67    /// Transaction details.
68    pub transaction: TransactionDetails,
69
70    /// Gas information.
71    pub gas: GasInfo,
72
73    /// Decoded input data (if requested).
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub decoded_input: Option<DecodedInput>,
76
77    /// Internal transactions (if requested).
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub internal_transactions: Option<Vec<InternalTransaction>>,
80}
81
82/// Block information for a transaction.
83#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
84pub struct BlockInfo {
85    /// Block number.
86    pub number: u64,
87
88    /// Block timestamp (Unix epoch).
89    pub timestamp: u64,
90
91    /// Block hash.
92    pub hash: String,
93}
94
95/// Detailed transaction information.
96#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
97pub struct TransactionDetails {
98    /// Sender address.
99    pub from: String,
100
101    /// Recipient address (None for contract creation).
102    pub to: Option<String>,
103
104    /// Value transferred in native token.
105    pub value: String,
106
107    /// Transaction nonce.
108    pub nonce: u64,
109
110    /// Transaction index in block.
111    pub transaction_index: u64,
112
113    /// Transaction status (success/failure).
114    pub status: bool,
115
116    /// Raw input data.
117    pub input: String,
118}
119
120/// Gas usage information.
121#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
122pub struct GasInfo {
123    /// Gas limit set for transaction.
124    pub gas_limit: u64,
125
126    /// Actual gas used.
127    pub gas_used: u64,
128
129    /// Gas price in wei.
130    pub gas_price: String,
131
132    /// Total transaction fee.
133    pub transaction_fee: String,
134
135    /// Effective gas price (for EIP-1559).
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub effective_gas_price: Option<String>,
138}
139
140/// Decoded transaction input.
141#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
142pub struct DecodedInput {
143    /// Function signature (e.g., "transfer(address,uint256)").
144    pub function_signature: String,
145
146    /// Function name.
147    pub function_name: String,
148
149    /// Decoded parameters.
150    pub parameters: Vec<DecodedParameter>,
151}
152
153/// A decoded function parameter.
154#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
155pub struct DecodedParameter {
156    /// Parameter name.
157    pub name: String,
158
159    /// Parameter type.
160    pub param_type: String,
161
162    /// Parameter value.
163    pub value: String,
164}
165
166/// An internal transaction (trace result).
167#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
168pub struct InternalTransaction {
169    /// Call type (call, delegatecall, staticcall, create).
170    pub call_type: String,
171
172    /// From address.
173    pub from: String,
174
175    /// To address.
176    pub to: String,
177
178    /// Value transferred.
179    pub value: String,
180
181    /// Gas provided.
182    pub gas: u64,
183
184    /// Input data.
185    pub input: String,
186
187    /// Output data.
188    pub output: String,
189}
190
191/// Executes the transaction analysis command.
192///
193/// # Arguments
194///
195/// * `args` - The parsed command arguments
196/// * `config` - Application configuration
197///
198/// # Returns
199///
200/// Returns `Ok(())` on success, or an error if the analysis fails.
201///
202/// # Errors
203///
204/// Returns [`ScopeError::InvalidHash`] if the transaction hash is invalid.
205/// Returns [`ScopeError::Request`] if API calls fail.
206pub async fn run(
207    mut args: TxArgs,
208    config: &Config,
209    clients: &dyn ChainClientFactory,
210) -> Result<()> {
211    // Auto-infer chain if using default and hash format is recognizable
212    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 transaction hash
228    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    // Calculate transaction fee
236    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        // For Solana, gas_price already contains the fee in lamports
242        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(), // Block hash not available from tx data
255        },
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            // Basic decode: show function selector (first 4 bytes)
274            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    // Output based on format
297    let format = args.format.unwrap_or(config.output.format);
298    output_report(&report, format)?;
299
300    Ok(())
301}
302
303/// Validates a transaction hash format for the given chain.
304fn validate_tx_hash(hash: &str, chain: &str) -> Result<()> {
305    match chain {
306        // EVM-compatible chains use 0x-prefixed 64-char hex hashes
307        "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 uses base58-encoded 64-byte signatures
328        "solana" => {
329            validate_solana_signature(hash)?;
330        }
331        // Tron uses 64-char hex hashes (no 0x prefix)
332        "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
345/// Outputs the transaction report in the specified format.
346fn 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// ============================================================================
428// Unit Tests
429// ============================================================================
430
431#[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        // Solana signature (base58 encoded, ~88 chars)
515        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        // EVM hash should fail for Solana
523        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        // Tron uses 64-char hex without 0x prefix
530        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        // 0x-prefixed hash should fail for Tron
538        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    // ========================================================================
700    // Output formatting tests
701    // ========================================================================
702
703    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; // Contract creation
784        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    // ========================================================================
805    // End-to-end tests using MockClientFactory
806    // ========================================================================
807
808    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            // 64 hex chars = tron
881            hash: "abc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
882            chain: "ethereum".to_string(), // Will be auto-detected to tron
883            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}