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
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, 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        OutputFormat::Markdown => {
424            let md = format_tx_markdown(report);
425            println!("{}", md);
426        }
427    }
428    Ok(())
429}
430
431/// Formats a transaction report as markdown for agent consumption.
432fn format_tx_markdown(report: &TransactionReport) -> String {
433    let mut md = String::new();
434    md.push_str("# Transaction Analysis\n\n");
435    md.push_str("| Field | Value |\n|-------|-------|\n");
436    md.push_str(&format!("| Hash | `{}` |\n", report.hash));
437    md.push_str(&format!("| Chain | {} |\n", report.chain));
438    md.push_str(&format!("| Block | {} |\n", report.block.number));
439    md.push_str(&format!(
440        "| Status | {} |\n",
441        if report.transaction.status {
442            "Success"
443        } else {
444            "Failed"
445        }
446    ));
447    md.push_str(&format!("| From | `{}` |\n", report.transaction.from));
448    md.push_str(&format!(
449        "| To | `{}` |\n",
450        report
451            .transaction
452            .to
453            .as_deref()
454            .unwrap_or("Contract Creation")
455    ));
456    md.push_str(&format!("| Value | {} |\n", report.transaction.value));
457    md.push_str(&format!("| Gas Used | {} |\n", report.gas.gas_used));
458    md.push_str(&format!("| Fee | {} |\n", report.gas.transaction_fee));
459    if let Some(ref decoded) = report.decoded_input {
460        md.push_str("\n## Decoded Input\n\n");
461        md.push_str(&format!("- **Function:** {}\n", decoded.function_name));
462        md.push_str(&format!(
463            "- **Signature:** `{}`\n",
464            decoded.function_signature
465        ));
466        if !decoded.parameters.is_empty() {
467            md.push_str("\n| Parameter | Type | Value |\n|-----------|------|-------|\n");
468            for param in &decoded.parameters {
469                md.push_str(&format!(
470                    "| {} | {} | {} |\n",
471                    param.name, param.param_type, param.value
472                ));
473            }
474        }
475    }
476    if let Some(ref traces) = report.internal_transactions
477        && !traces.is_empty()
478    {
479        md.push_str("\n## Internal Transactions\n\n");
480        md.push_str("| # | Type | From | To |\n|---|---|---|---|\n");
481        for (i, trace) in traces.iter().enumerate() {
482            md.push_str(&format!(
483                "| {} | {} | `{}` | `{}` |\n",
484                i + 1,
485                trace.call_type,
486                trace.from,
487                trace.to
488            ));
489        }
490    }
491    md
492}
493
494// ============================================================================
495// Unit Tests
496// ============================================================================
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    const VALID_TX_HASH: &str =
503        "0xabc123def456789012345678901234567890123456789012345678901234abcd";
504
505    #[test]
506    fn test_validate_tx_hash_valid() {
507        let result = validate_tx_hash(VALID_TX_HASH, "ethereum");
508        assert!(result.is_ok());
509    }
510
511    #[test]
512    fn test_validate_tx_hash_valid_lowercase() {
513        let hash = "0xabc123def456789012345678901234567890123456789012345678901234abcd";
514        let result = validate_tx_hash(hash, "ethereum");
515        assert!(result.is_ok());
516    }
517
518    #[test]
519    fn test_validate_tx_hash_valid_polygon() {
520        let result = validate_tx_hash(VALID_TX_HASH, "polygon");
521        assert!(result.is_ok());
522    }
523
524    #[test]
525    fn test_validate_tx_hash_missing_prefix() {
526        let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
527        let result = validate_tx_hash(hash, "ethereum");
528        assert!(result.is_err());
529        assert!(result.unwrap_err().to_string().contains("0x"));
530    }
531
532    #[test]
533    fn test_validate_tx_hash_too_short() {
534        let hash = "0xabc123";
535        let result = validate_tx_hash(hash, "ethereum");
536        assert!(result.is_err());
537        assert!(result.unwrap_err().to_string().contains("66 characters"));
538    }
539
540    #[test]
541    fn test_validate_tx_hash_too_long() {
542        let hash = "0xabc123def456789012345678901234567890123456789012345678901234abcde";
543        let result = validate_tx_hash(hash, "ethereum");
544        assert!(result.is_err());
545    }
546
547    #[test]
548    fn test_validate_tx_hash_invalid_hex() {
549        let hash = "0xabc123def456789012345678901234567890123456789012345678901234GHIJ";
550        let result = validate_tx_hash(hash, "ethereum");
551        assert!(result.is_err());
552        assert!(result.unwrap_err().to_string().contains("invalid hex"));
553    }
554
555    #[test]
556    fn test_validate_tx_hash_unsupported_chain() {
557        let result = validate_tx_hash(VALID_TX_HASH, "bitcoin");
558        assert!(result.is_err());
559        assert!(
560            result
561                .unwrap_err()
562                .to_string()
563                .contains("Unsupported chain")
564        );
565    }
566
567    #[test]
568    fn test_validate_tx_hash_valid_bsc() {
569        let result = validate_tx_hash(VALID_TX_HASH, "bsc");
570        assert!(result.is_ok());
571    }
572
573    #[test]
574    fn test_validate_tx_hash_valid_aegis() {
575        let result = validate_tx_hash(VALID_TX_HASH, "aegis");
576        assert!(result.is_ok());
577    }
578
579    #[test]
580    fn test_validate_tx_hash_valid_solana() {
581        // Solana signature (base58 encoded, ~88 chars)
582        let sig = "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
583        let result = validate_tx_hash(sig, "solana");
584        assert!(result.is_ok());
585    }
586
587    #[test]
588    fn test_validate_tx_hash_invalid_solana() {
589        // EVM hash should fail for Solana
590        let result = validate_tx_hash(VALID_TX_HASH, "solana");
591        assert!(result.is_err());
592    }
593
594    #[test]
595    fn test_validate_tx_hash_valid_tron() {
596        // Tron uses 64-char hex without 0x prefix
597        let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
598        let result = validate_tx_hash(hash, "tron");
599        assert!(result.is_ok());
600    }
601
602    #[test]
603    fn test_validate_tx_hash_invalid_tron() {
604        // 0x-prefixed hash should fail for Tron
605        let result = validate_tx_hash(VALID_TX_HASH, "tron");
606        assert!(result.is_err());
607    }
608
609    #[test]
610    fn test_tx_args_default_values() {
611        use clap::Parser;
612
613        #[derive(Parser)]
614        struct TestCli {
615            #[command(flatten)]
616            args: TxArgs,
617        }
618
619        let cli = TestCli::try_parse_from(["test", VALID_TX_HASH]).unwrap();
620
621        assert_eq!(cli.args.chain, "ethereum");
622        assert!(!cli.args.trace);
623        assert!(!cli.args.decode);
624        assert!(cli.args.format.is_none());
625    }
626
627    #[test]
628    fn test_tx_args_with_options() {
629        use clap::Parser;
630
631        #[derive(Parser)]
632        struct TestCli {
633            #[command(flatten)]
634            args: TxArgs,
635        }
636
637        let cli = TestCli::try_parse_from([
638            "test",
639            VALID_TX_HASH,
640            "--chain",
641            "polygon",
642            "--trace",
643            "--decode",
644            "--format",
645            "json",
646        ])
647        .unwrap();
648
649        assert_eq!(cli.args.chain, "polygon");
650        assert!(cli.args.trace);
651        assert!(cli.args.decode);
652        assert_eq!(cli.args.format, Some(OutputFormat::Json));
653    }
654
655    #[test]
656    fn test_transaction_report_serialization() {
657        let report = TransactionReport {
658            hash: VALID_TX_HASH.to_string(),
659            chain: "ethereum".to_string(),
660            block: BlockInfo {
661                number: 12345678,
662                timestamp: 1700000000,
663                hash: "0xblock".to_string(),
664            },
665            transaction: TransactionDetails {
666                from: "0xfrom".to_string(),
667                to: Some("0xto".to_string()),
668                value: "1.0".to_string(),
669                nonce: 42,
670                transaction_index: 5,
671                status: true,
672                input: "0x".to_string(),
673            },
674            gas: GasInfo {
675                gas_limit: 100000,
676                gas_used: 21000,
677                gas_price: "20000000000".to_string(),
678                transaction_fee: "0.00042".to_string(),
679                effective_gas_price: None,
680            },
681            decoded_input: None,
682            internal_transactions: None,
683        };
684
685        let json = serde_json::to_string(&report).unwrap();
686        assert!(json.contains(VALID_TX_HASH));
687        assert!(json.contains("12345678"));
688        assert!(json.contains("21000"));
689        assert!(!json.contains("decoded_input"));
690        assert!(!json.contains("internal_transactions"));
691    }
692
693    #[test]
694    fn test_block_info_serialization() {
695        let block = BlockInfo {
696            number: 12345678,
697            timestamp: 1700000000,
698            hash: "0xblockhash".to_string(),
699        };
700
701        let json = serde_json::to_string(&block).unwrap();
702        assert!(json.contains("12345678"));
703        assert!(json.contains("1700000000"));
704        assert!(json.contains("0xblockhash"));
705    }
706
707    #[test]
708    fn test_gas_info_serialization() {
709        let gas = GasInfo {
710            gas_limit: 100000,
711            gas_used: 50000,
712            gas_price: "20000000000".to_string(),
713            transaction_fee: "0.001".to_string(),
714            effective_gas_price: Some("25000000000".to_string()),
715        };
716
717        let json = serde_json::to_string(&gas).unwrap();
718        assert!(json.contains("100000"));
719        assert!(json.contains("50000"));
720        assert!(json.contains("effective_gas_price"));
721    }
722
723    #[test]
724    fn test_decoded_input_serialization() {
725        let decoded = DecodedInput {
726            function_signature: "transfer(address,uint256)".to_string(),
727            function_name: "transfer".to_string(),
728            parameters: vec![
729                DecodedParameter {
730                    name: "to".to_string(),
731                    param_type: "address".to_string(),
732                    value: "0xrecipient".to_string(),
733                },
734                DecodedParameter {
735                    name: "amount".to_string(),
736                    param_type: "uint256".to_string(),
737                    value: "1000000".to_string(),
738                },
739            ],
740        };
741
742        let json = serde_json::to_string(&decoded).unwrap();
743        assert!(json.contains("transfer(address,uint256)"));
744        assert!(json.contains("0xrecipient"));
745        assert!(json.contains("1000000"));
746    }
747
748    #[test]
749    fn test_internal_transaction_serialization() {
750        let internal = InternalTransaction {
751            call_type: "call".to_string(),
752            from: "0xfrom".to_string(),
753            to: "0xto".to_string(),
754            value: "1.0".to_string(),
755            gas: 50000,
756            input: "0x".to_string(),
757            output: "0x".to_string(),
758        };
759
760        let json = serde_json::to_string(&internal).unwrap();
761        assert!(json.contains("call"));
762        assert!(json.contains("0xfrom"));
763        assert!(json.contains("50000"));
764    }
765
766    // ========================================================================
767    // Output formatting tests
768    // ========================================================================
769
770    fn make_test_tx_report() -> TransactionReport {
771        TransactionReport {
772            hash: VALID_TX_HASH.to_string(),
773            chain: "ethereum".to_string(),
774            block: BlockInfo {
775                number: 12345678,
776                timestamp: 1700000000,
777                hash: "0xblock".to_string(),
778            },
779            transaction: TransactionDetails {
780                from: "0xfrom".to_string(),
781                to: Some("0xto".to_string()),
782                value: "1.0".to_string(),
783                nonce: 42,
784                transaction_index: 5,
785                status: true,
786                input: "0xa9059cbb0000000000".to_string(),
787            },
788            gas: GasInfo {
789                gas_limit: 100000,
790                gas_used: 21000,
791                gas_price: "20000000000".to_string(),
792                transaction_fee: "0.00042".to_string(),
793                effective_gas_price: None,
794            },
795            decoded_input: Some(DecodedInput {
796                function_signature: "transfer(address,uint256)".to_string(),
797                function_name: "transfer".to_string(),
798                parameters: vec![DecodedParameter {
799                    name: "to".to_string(),
800                    param_type: "address".to_string(),
801                    value: "0xrecipient".to_string(),
802                }],
803            }),
804            internal_transactions: Some(vec![InternalTransaction {
805                call_type: "call".to_string(),
806                from: "0xfrom".to_string(),
807                to: "0xto".to_string(),
808                value: "0.5".to_string(),
809                gas: 30000,
810                input: "0x".to_string(),
811                output: "0x".to_string(),
812            }]),
813        }
814    }
815
816    #[test]
817    fn test_output_report_json() {
818        let report = make_test_tx_report();
819        let result = output_report(&report, OutputFormat::Json);
820        assert!(result.is_ok());
821    }
822
823    #[test]
824    fn test_output_report_csv() {
825        let report = make_test_tx_report();
826        let result = output_report(&report, OutputFormat::Csv);
827        assert!(result.is_ok());
828    }
829
830    #[test]
831    fn test_output_report_table() {
832        let report = make_test_tx_report();
833        let result = output_report(&report, OutputFormat::Table);
834        assert!(result.is_ok());
835    }
836
837    #[test]
838    fn test_output_report_table_no_decoded() {
839        let mut report = make_test_tx_report();
840        report.decoded_input = None;
841        report.internal_transactions = None;
842        let result = output_report(&report, OutputFormat::Table);
843        assert!(result.is_ok());
844    }
845
846    #[test]
847    fn test_output_report_table_failed_tx() {
848        let mut report = make_test_tx_report();
849        report.transaction.status = false;
850        report.transaction.to = None; // Contract creation
851        let result = output_report(&report, OutputFormat::Table);
852        assert!(result.is_ok());
853    }
854
855    #[test]
856    fn test_output_report_table_empty_traces() {
857        let mut report = make_test_tx_report();
858        report.internal_transactions = Some(vec![]);
859        let result = output_report(&report, OutputFormat::Table);
860        assert!(result.is_ok());
861    }
862
863    #[test]
864    fn test_output_report_csv_no_to() {
865        let mut report = make_test_tx_report();
866        report.transaction.to = None;
867        let result = output_report(&report, OutputFormat::Csv);
868        assert!(result.is_ok());
869    }
870
871    // ========================================================================
872    // End-to-end tests using MockClientFactory
873    // ========================================================================
874
875    use crate::chains::mocks::MockClientFactory;
876
877    fn mock_factory() -> MockClientFactory {
878        MockClientFactory::new()
879    }
880
881    #[tokio::test]
882    async fn test_run_ethereum_tx() {
883        let config = Config::default();
884        let factory = mock_factory();
885        let args = TxArgs {
886            hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
887            chain: "ethereum".to_string(),
888            format: Some(OutputFormat::Json),
889            trace: false,
890            decode: false,
891        };
892        let result = super::run(args, &config, &factory).await;
893        assert!(result.is_ok());
894    }
895
896    #[tokio::test]
897    async fn test_run_tx_with_decode() {
898        let config = Config::default();
899        let mut factory = mock_factory();
900        factory.mock_client.transaction.input = "0xa9059cbb000000000000000000000000".to_string();
901        let args = TxArgs {
902            hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
903            chain: "ethereum".to_string(),
904            format: Some(OutputFormat::Table),
905            trace: false,
906            decode: true,
907        };
908        let result = super::run(args, &config, &factory).await;
909        assert!(result.is_ok());
910    }
911
912    #[tokio::test]
913    async fn test_run_tx_with_trace() {
914        let config = Config::default();
915        let factory = mock_factory();
916        let args = TxArgs {
917            hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
918            chain: "ethereum".to_string(),
919            format: Some(OutputFormat::Csv),
920            trace: true,
921            decode: false,
922        };
923        let result = super::run(args, &config, &factory).await;
924        assert!(result.is_ok());
925    }
926
927    #[tokio::test]
928    async fn test_run_tx_invalid_hash() {
929        let config = Config::default();
930        let factory = mock_factory();
931        let args = TxArgs {
932            hash: "invalid".to_string(),
933            chain: "ethereum".to_string(),
934            format: Some(OutputFormat::Json),
935            trace: false,
936            decode: false,
937        };
938        let result = super::run(args, &config, &factory).await;
939        assert!(result.is_err());
940    }
941
942    #[tokio::test]
943    async fn test_run_tx_auto_detect_tron() {
944        let config = Config::default();
945        let factory = mock_factory();
946        let args = TxArgs {
947            // 64 hex chars = tron
948            hash: "abc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
949            chain: "ethereum".to_string(), // Will be auto-detected to tron
950            format: Some(OutputFormat::Json),
951            trace: false,
952            decode: false,
953        };
954        let result = super::run(args, &config, &factory).await;
955        assert!(result.is_ok());
956    }
957}