Skip to main content

scope/cli/
tx.rs

1//! # Transaction Analysis Command
2//!
3//! This module implements the `scope 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//! scope tx 0xabc123...
12//!
13//! # Specify chain
14//! scope tx 0xabc123... --chain polygon
15//!
16//! # Include internal transactions
17//! scope 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)]
27#[command(after_help = "\x1b[1mExamples:\x1b[0m
28  scope tx 0xabc123def456...
29  scope tx 0xabc123... --chain polygon --trace
30  scope tx 0xabc123... --decode --format json
31
32\x1b[2mTip: All address/token inputs accept @label shortcuts from the address book.\x1b[0m")]
33pub struct TxArgs {
34    /// The transaction hash to analyze.
35    ///
36    /// Must be a valid transaction hash for the target chain
37    /// (e.g., 0x-prefixed 64-character hex for Ethereum).
38    #[arg(value_name = "HASH")]
39    pub hash: String,
40
41    /// Target blockchain network.
42    ///
43    /// EVM chains: ethereum, polygon, arbitrum, optimism, base, bsc
44    /// Non-EVM chains: solana, tron
45    #[arg(short, long, default_value = "ethereum")]
46    pub chain: String,
47
48    /// Override output format for this command.
49    #[arg(short, long, value_name = "FORMAT")]
50    pub format: Option<OutputFormat>,
51
52    /// Include internal transactions (trace).
53    #[arg(long)]
54    pub trace: bool,
55
56    /// Decode transaction input data.
57    #[arg(long)]
58    pub decode: bool,
59}
60
61/// Result of a transaction analysis.
62#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63pub struct TransactionReport {
64    /// The analyzed transaction hash.
65    pub hash: String,
66
67    /// The blockchain network.
68    pub chain: String,
69
70    /// Block information.
71    pub block: BlockInfo,
72
73    /// Transaction details.
74    pub transaction: TransactionDetails,
75
76    /// Gas information.
77    pub gas: GasInfo,
78
79    /// Decoded input data (if requested).
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub decoded_input: Option<DecodedInput>,
82
83    /// Internal transactions (if requested).
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub internal_transactions: Option<Vec<InternalTransaction>>,
86}
87
88/// Block information for a transaction.
89#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
90pub struct BlockInfo {
91    /// Block number.
92    pub number: u64,
93
94    /// Block timestamp (Unix epoch).
95    pub timestamp: u64,
96
97    /// Block hash.
98    pub hash: String,
99}
100
101/// Detailed transaction information.
102#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
103pub struct TransactionDetails {
104    /// Sender address.
105    pub from: String,
106
107    /// Recipient address (None for contract creation).
108    pub to: Option<String>,
109
110    /// Value transferred in native token.
111    pub value: String,
112
113    /// Transaction nonce.
114    pub nonce: u64,
115
116    /// Transaction index in block.
117    pub transaction_index: u64,
118
119    /// Transaction status (success/failure).
120    pub status: bool,
121
122    /// Raw input data.
123    pub input: String,
124}
125
126/// Gas usage information.
127#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
128pub struct GasInfo {
129    /// Gas limit set for transaction.
130    pub gas_limit: u64,
131
132    /// Actual gas used.
133    pub gas_used: u64,
134
135    /// Gas price in wei.
136    pub gas_price: String,
137
138    /// Total transaction fee.
139    pub transaction_fee: String,
140
141    /// Effective gas price (for EIP-1559).
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub effective_gas_price: Option<String>,
144}
145
146/// Decoded transaction input.
147#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
148pub struct DecodedInput {
149    /// Function signature (e.g., "transfer(address,uint256)").
150    pub function_signature: String,
151
152    /// Function name.
153    pub function_name: String,
154
155    /// Decoded parameters.
156    pub parameters: Vec<DecodedParameter>,
157}
158
159/// A decoded function parameter.
160#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
161pub struct DecodedParameter {
162    /// Parameter name.
163    pub name: String,
164
165    /// Parameter type.
166    pub param_type: String,
167
168    /// Parameter value.
169    pub value: String,
170}
171
172/// An internal transaction (trace result).
173#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
174pub struct InternalTransaction {
175    /// Call type (call, delegatecall, staticcall, create).
176    pub call_type: String,
177
178    /// From address.
179    pub from: String,
180
181    /// To address.
182    pub to: String,
183
184    /// Value transferred.
185    pub value: String,
186
187    /// Gas provided.
188    pub gas: u64,
189
190    /// Input data.
191    pub input: String,
192
193    /// Output data.
194    pub output: String,
195}
196
197/// Executes the transaction analysis command.
198///
199/// # Arguments
200///
201/// * `args` - The parsed command arguments
202/// * `config` - Application configuration
203///
204/// # Returns
205///
206/// Returns `Ok(())` on success, or an error if the analysis fails.
207///
208/// # Errors
209///
210/// Returns [`ScopeError::InvalidHash`] if the transaction hash is invalid.
211/// Returns [`ScopeError::Request`] if API calls fail.
212pub async fn run(
213    mut args: TxArgs,
214    config: &Config,
215    clients: &dyn ChainClientFactory,
216) -> Result<()> {
217    // Auto-infer chain if using default and hash format is recognizable
218    if args.chain == "ethereum"
219        && let Some(inferred) = crate::chains::infer_chain_from_hash(&args.hash)
220        && inferred != "ethereum"
221    {
222        tracing::info!("Auto-detected chain: {}", inferred);
223        println!("Auto-detected chain: {}", inferred);
224        args.chain = inferred.to_string();
225    }
226
227    tracing::info!(
228        hash = %args.hash,
229        chain = %args.chain,
230        "Starting transaction analysis"
231    );
232
233    // Validate transaction hash
234    validate_tx_hash(&args.hash, &args.chain)?;
235
236    let sp =
237        crate::cli::progress::Spinner::new(&format!("Analyzing transaction on {}...", args.chain));
238
239    let report =
240        fetch_transaction_report(&args.hash, &args.chain, args.decode, args.trace, clients).await?;
241
242    sp.finish("Transaction loaded.");
243
244    // Output based on format
245    let format = args.format.unwrap_or(config.output.format);
246    output_report(&report, format)?;
247
248    Ok(())
249}
250
251/// Fetches and builds a transaction report for programmatic use.
252///
253/// Used by the insights command and batch reporting.
254pub async fn fetch_transaction_report(
255    hash: &str,
256    chain: &str,
257    decode: bool,
258    trace: bool,
259    clients: &dyn ChainClientFactory,
260) -> Result<TransactionReport> {
261    validate_tx_hash(hash, chain)?;
262    let client = clients.create_chain_client(chain)?;
263    let tx = client.get_transaction(hash).await?;
264
265    let gas_price_val: u128 = tx.gas_price.parse().unwrap_or(0);
266    let gas_used_val = tx.gas_used.unwrap_or(0) as u128;
267    let fee_wei = gas_price_val * gas_used_val;
268    let chain_lower = chain.to_lowercase();
269    let fee_str = if chain_lower == "solana" || chain_lower == "sol" {
270        let fee_sol = tx.gas_price.parse::<f64>().unwrap_or(0.0) / 1_000_000_000.0;
271        format!("{:.9}", fee_sol)
272    } else {
273        fee_wei.to_string()
274    };
275
276    let report = TransactionReport {
277        hash: tx.hash.clone(),
278        chain: chain.to_string(),
279        block: BlockInfo {
280            number: tx.block_number.unwrap_or(0),
281            timestamp: tx.timestamp.unwrap_or(0),
282            hash: String::new(),
283        },
284        transaction: TransactionDetails {
285            from: tx.from.clone(),
286            to: tx.to.clone(),
287            value: tx.value.clone(),
288            nonce: tx.nonce,
289            transaction_index: 0,
290            status: tx.status.unwrap_or(true),
291            input: tx.input.clone(),
292        },
293        gas: GasInfo {
294            gas_limit: tx.gas_limit,
295            gas_used: tx.gas_used.unwrap_or(0),
296            gas_price: tx.gas_price.clone(),
297            transaction_fee: fee_str,
298            effective_gas_price: None,
299        },
300        decoded_input: if decode && !tx.input.is_empty() && tx.input != "0x" {
301            let selector = if tx.input.len() >= 10 {
302                &tx.input[..10]
303            } else {
304                &tx.input
305            };
306            Some(DecodedInput {
307                function_signature: format!("{}(...)", selector),
308                function_name: selector.to_string(),
309                parameters: vec![],
310            })
311        } else if decode {
312            Some(DecodedInput {
313                function_signature: "transfer()".to_string(),
314                function_name: "Native Transfer".to_string(),
315                parameters: vec![],
316            })
317        } else {
318            None
319        },
320        internal_transactions: if trace { Some(vec![]) } else { None },
321    };
322    Ok(report)
323}
324
325/// Validates a transaction hash format for the given chain.
326fn validate_tx_hash(hash: &str, chain: &str) -> Result<()> {
327    match chain {
328        // EVM-compatible chains use 0x-prefixed 64-char hex hashes
329        "ethereum" | "polygon" | "arbitrum" | "optimism" | "base" | "bsc" | "aegis" => {
330            if !hash.starts_with("0x") {
331                return Err(ScopeError::InvalidHash(format!(
332                    "Transaction hash must start with '0x': {}",
333                    hash
334                )));
335            }
336            if hash.len() != 66 {
337                return Err(ScopeError::InvalidHash(format!(
338                    "Transaction hash must be 66 characters (0x + 64 hex): {}",
339                    hash
340                )));
341            }
342            if !hash[2..].chars().all(|c| c.is_ascii_hexdigit()) {
343                return Err(ScopeError::InvalidHash(format!(
344                    "Transaction hash contains invalid hex characters: {}",
345                    hash
346                )));
347            }
348        }
349        // Solana uses base58-encoded 64-byte signatures
350        "solana" => {
351            validate_solana_signature(hash)?;
352        }
353        // Tron uses 64-char hex hashes (no 0x prefix)
354        "tron" => {
355            validate_tron_tx_hash(hash)?;
356        }
357        _ => {
358            return Err(ScopeError::Chain(format!(
359                "Unsupported chain: {}. Supported: ethereum, polygon, arbitrum, optimism, base, bsc, solana, tron",
360                chain
361            )));
362        }
363    }
364    Ok(())
365}
366
367/// Outputs the transaction report in the specified format.
368fn output_report(report: &TransactionReport, format: OutputFormat) -> Result<()> {
369    match format {
370        OutputFormat::Json => {
371            let json = serde_json::to_string_pretty(report)?;
372            println!("{}", json);
373        }
374        OutputFormat::Csv => {
375            println!("hash,chain,block,from,to,value,status,gas_used,fee");
376            println!(
377                "{},{},{},{},{},{},{},{},{}",
378                report.hash,
379                report.chain,
380                report.block.number,
381                report.transaction.from,
382                report.transaction.to.as_deref().unwrap_or(""),
383                report.transaction.value,
384                report.transaction.status,
385                report.gas.gas_used,
386                report.gas.transaction_fee
387            );
388        }
389        OutputFormat::Table => {
390            use crate::display::terminal as t;
391
392            println!("{}", t::section_header("Transaction Analysis"));
393            println!("{}", t::kv_row("Hash", &report.hash));
394            println!("{}", t::kv_row("Chain", &report.chain));
395            println!("{}", t::kv_row("Block", &report.block.number.to_string()));
396            if report.transaction.status {
397                println!("{}", t::check_pass("Success"));
398            } else {
399                println!("{}", t::check_fail("Failed"));
400            }
401            println!("{}", t::blank_row());
402            println!("{}", t::kv_row("From", &report.transaction.from));
403            println!(
404                "{}",
405                t::kv_row(
406                    "To",
407                    report
408                        .transaction
409                        .to
410                        .as_deref()
411                        .unwrap_or("Contract Creation")
412                )
413            );
414            println!("{}", t::kv_row("Value", &report.transaction.value));
415            println!("{}", t::blank_row());
416            println!(
417                "{}",
418                t::kv_row("Gas Limit", &report.gas.gas_limit.to_string())
419            );
420            println!(
421                "{}",
422                t::kv_row("Gas Used", &report.gas.gas_used.to_string())
423            );
424            println!("{}", t::kv_row("Gas Price", &report.gas.gas_price));
425            println!("{}", t::kv_row("Fee", &report.gas.transaction_fee));
426
427            if let Some(ref decoded) = report.decoded_input {
428                println!("{}", t::blank_row());
429                println!("{}", t::subsection_header("Decoded Input"));
430                println!("{}", t::kv_row("Function", &decoded.function_name));
431                println!("{}", t::kv_row("Signature", &decoded.function_signature));
432                if !decoded.parameters.is_empty() {
433                    for param in &decoded.parameters {
434                        println!(
435                            "{}",
436                            t::bullet_row(&format!(
437                                "{} ({}): {}",
438                                param.name, param.param_type, param.value
439                            ))
440                        );
441                    }
442                }
443            }
444
445            if let Some(ref traces) = report.internal_transactions
446                && !traces.is_empty()
447            {
448                println!("{}", t::blank_row());
449                println!(
450                    "{}",
451                    t::subsection_header(&format!("Internal Transactions ({})", traces.len()))
452                );
453                for (i, trace) in traces.iter().enumerate() {
454                    println!(
455                        "{}",
456                        t::bullet_row(&format!(
457                            "[{}] {} {} -> {}",
458                            i, trace.call_type, trace.from, trace.to
459                        ))
460                    );
461                }
462            }
463
464            println!("{}", t::section_footer());
465        }
466        OutputFormat::Markdown => {
467            let md = format_tx_markdown(report);
468            println!("{}", md);
469        }
470    }
471    Ok(())
472}
473
474/// Formats a transaction report as markdown for agent consumption.
475/// Exposed for use by insights and report generation.
476pub fn format_tx_markdown(report: &TransactionReport) -> String {
477    let mut md = String::new();
478    md.push_str("# Transaction Analysis\n\n");
479    md.push_str("| Field | Value |\n|-------|-------|\n");
480    md.push_str(&format!("| Hash | `{}` |\n", report.hash));
481    md.push_str(&format!("| Chain | {} |\n", report.chain));
482    md.push_str(&format!("| Block | {} |\n", report.block.number));
483    md.push_str(&format!(
484        "| Status | {} |\n",
485        if report.transaction.status {
486            "Success"
487        } else {
488            "Failed"
489        }
490    ));
491    md.push_str(&format!("| From | `{}` |\n", report.transaction.from));
492    md.push_str(&format!(
493        "| To | `{}` |\n",
494        report
495            .transaction
496            .to
497            .as_deref()
498            .unwrap_or("Contract Creation")
499    ));
500    md.push_str(&format!("| Value | {} |\n", report.transaction.value));
501    md.push_str(&format!("| Gas Used | {} |\n", report.gas.gas_used));
502    md.push_str(&format!("| Fee | {} |\n", report.gas.transaction_fee));
503    if let Some(ref decoded) = report.decoded_input {
504        md.push_str("\n## Decoded Input\n\n");
505        md.push_str(&format!("- **Function:** {}\n", decoded.function_name));
506        md.push_str(&format!(
507            "- **Signature:** `{}`\n",
508            decoded.function_signature
509        ));
510        if !decoded.parameters.is_empty() {
511            md.push_str("\n| Parameter | Type | Value |\n|-----------|------|-------|\n");
512            for param in &decoded.parameters {
513                md.push_str(&format!(
514                    "| {} | {} | {} |\n",
515                    param.name, param.param_type, param.value
516                ));
517            }
518        }
519    }
520    if let Some(ref traces) = report.internal_transactions
521        && !traces.is_empty()
522    {
523        md.push_str("\n## Internal Transactions\n\n");
524        md.push_str("| # | Type | From | To |\n|---|---|---|---|\n");
525        for (i, trace) in traces.iter().enumerate() {
526            md.push_str(&format!(
527                "| {} | {} | `{}` | `{}` |\n",
528                i + 1,
529                trace.call_type,
530                trace.from,
531                trace.to
532            ));
533        }
534    }
535    md
536}
537
538// ============================================================================
539// Unit Tests
540// ============================================================================
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    const VALID_TX_HASH: &str =
547        "0xabc123def456789012345678901234567890123456789012345678901234abcd";
548
549    #[test]
550    fn test_validate_tx_hash_valid() {
551        let result = validate_tx_hash(VALID_TX_HASH, "ethereum");
552        assert!(result.is_ok());
553    }
554
555    #[test]
556    fn test_validate_tx_hash_valid_lowercase() {
557        let hash = "0xabc123def456789012345678901234567890123456789012345678901234abcd";
558        let result = validate_tx_hash(hash, "ethereum");
559        assert!(result.is_ok());
560    }
561
562    #[test]
563    fn test_validate_tx_hash_valid_polygon() {
564        let result = validate_tx_hash(VALID_TX_HASH, "polygon");
565        assert!(result.is_ok());
566    }
567
568    #[test]
569    fn test_validate_tx_hash_missing_prefix() {
570        let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
571        let result = validate_tx_hash(hash, "ethereum");
572        assert!(result.is_err());
573        assert!(result.unwrap_err().to_string().contains("0x"));
574    }
575
576    #[test]
577    fn test_validate_tx_hash_too_short() {
578        let hash = "0xabc123";
579        let result = validate_tx_hash(hash, "ethereum");
580        assert!(result.is_err());
581        assert!(result.unwrap_err().to_string().contains("66 characters"));
582    }
583
584    #[test]
585    fn test_validate_tx_hash_too_long() {
586        let hash = "0xabc123def456789012345678901234567890123456789012345678901234abcde";
587        let result = validate_tx_hash(hash, "ethereum");
588        assert!(result.is_err());
589    }
590
591    #[test]
592    fn test_validate_tx_hash_invalid_hex_cli() {
593        let hash = "0xabc123def456789012345678901234567890123456789012345678901234GHIJ";
594        let result = validate_tx_hash(hash, "ethereum");
595        assert!(result.is_err());
596        assert!(result.unwrap_err().to_string().contains("invalid hex"));
597    }
598
599    #[test]
600    fn test_validate_tx_hash_unsupported_chain() {
601        let result = validate_tx_hash(VALID_TX_HASH, "bitcoin");
602        assert!(result.is_err());
603        assert!(
604            result
605                .unwrap_err()
606                .to_string()
607                .contains("Unsupported chain")
608        );
609    }
610
611    #[test]
612    fn test_validate_tx_hash_valid_bsc() {
613        let result = validate_tx_hash(VALID_TX_HASH, "bsc");
614        assert!(result.is_ok());
615    }
616
617    #[test]
618    fn test_validate_tx_hash_valid_aegis() {
619        let result = validate_tx_hash(VALID_TX_HASH, "aegis");
620        assert!(result.is_ok());
621    }
622
623    #[test]
624    fn test_validate_tx_hash_valid_solana() {
625        // Solana signature (base58 encoded, ~88 chars)
626        let sig = "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
627        let result = validate_tx_hash(sig, "solana");
628        assert!(result.is_ok());
629    }
630
631    #[test]
632    fn test_validate_tx_hash_invalid_solana() {
633        // EVM hash should fail for Solana
634        let result = validate_tx_hash(VALID_TX_HASH, "solana");
635        assert!(result.is_err());
636    }
637
638    #[test]
639    fn test_validate_tx_hash_valid_tron() {
640        // Tron uses 64-char hex without 0x prefix
641        let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
642        let result = validate_tx_hash(hash, "tron");
643        assert!(result.is_ok());
644    }
645
646    #[test]
647    fn test_validate_tx_hash_invalid_tron() {
648        // 0x-prefixed hash should fail for Tron
649        let result = validate_tx_hash(VALID_TX_HASH, "tron");
650        assert!(result.is_err());
651    }
652
653    #[test]
654    fn test_tx_args_default_values() {
655        use clap::Parser;
656
657        #[derive(Parser)]
658        struct TestCli {
659            #[command(flatten)]
660            args: TxArgs,
661        }
662
663        let cli = TestCli::try_parse_from(["test", VALID_TX_HASH]).unwrap();
664
665        assert_eq!(cli.args.chain, "ethereum");
666        assert!(!cli.args.trace);
667        assert!(!cli.args.decode);
668        assert!(cli.args.format.is_none());
669    }
670
671    #[test]
672    fn test_tx_args_with_options() {
673        use clap::Parser;
674
675        #[derive(Parser)]
676        struct TestCli {
677            #[command(flatten)]
678            args: TxArgs,
679        }
680
681        let cli = TestCli::try_parse_from([
682            "test",
683            VALID_TX_HASH,
684            "--chain",
685            "polygon",
686            "--trace",
687            "--decode",
688            "--format",
689            "json",
690        ])
691        .unwrap();
692
693        assert_eq!(cli.args.chain, "polygon");
694        assert!(cli.args.trace);
695        assert!(cli.args.decode);
696        assert_eq!(cli.args.format, Some(OutputFormat::Json));
697    }
698
699    #[test]
700    fn test_transaction_report_serialization() {
701        let report = TransactionReport {
702            hash: VALID_TX_HASH.to_string(),
703            chain: "ethereum".to_string(),
704            block: BlockInfo {
705                number: 12345678,
706                timestamp: 1700000000,
707                hash: "0xblock".to_string(),
708            },
709            transaction: TransactionDetails {
710                from: "0xfrom".to_string(),
711                to: Some("0xto".to_string()),
712                value: "1.0".to_string(),
713                nonce: 42,
714                transaction_index: 5,
715                status: true,
716                input: "0x".to_string(),
717            },
718            gas: GasInfo {
719                gas_limit: 100000,
720                gas_used: 21000,
721                gas_price: "20000000000".to_string(),
722                transaction_fee: "0.00042".to_string(),
723                effective_gas_price: None,
724            },
725            decoded_input: None,
726            internal_transactions: None,
727        };
728
729        let json = serde_json::to_string(&report).unwrap();
730        assert!(json.contains(VALID_TX_HASH));
731        assert!(json.contains("12345678"));
732        assert!(json.contains("21000"));
733        assert!(!json.contains("decoded_input"));
734        assert!(!json.contains("internal_transactions"));
735    }
736
737    #[test]
738    fn test_block_info_serialization() {
739        let block = BlockInfo {
740            number: 12345678,
741            timestamp: 1700000000,
742            hash: "0xblockhash".to_string(),
743        };
744
745        let json = serde_json::to_string(&block).unwrap();
746        assert!(json.contains("12345678"));
747        assert!(json.contains("1700000000"));
748        assert!(json.contains("0xblockhash"));
749    }
750
751    #[test]
752    fn test_gas_info_serialization() {
753        let gas = GasInfo {
754            gas_limit: 100000,
755            gas_used: 50000,
756            gas_price: "20000000000".to_string(),
757            transaction_fee: "0.001".to_string(),
758            effective_gas_price: Some("25000000000".to_string()),
759        };
760
761        let json = serde_json::to_string(&gas).unwrap();
762        assert!(json.contains("100000"));
763        assert!(json.contains("50000"));
764        assert!(json.contains("effective_gas_price"));
765    }
766
767    #[test]
768    fn test_decoded_input_serialization() {
769        let decoded = DecodedInput {
770            function_signature: "transfer(address,uint256)".to_string(),
771            function_name: "transfer".to_string(),
772            parameters: vec![
773                DecodedParameter {
774                    name: "to".to_string(),
775                    param_type: "address".to_string(),
776                    value: "0xrecipient".to_string(),
777                },
778                DecodedParameter {
779                    name: "amount".to_string(),
780                    param_type: "uint256".to_string(),
781                    value: "1000000".to_string(),
782                },
783            ],
784        };
785
786        let json = serde_json::to_string(&decoded).unwrap();
787        assert!(json.contains("transfer(address,uint256)"));
788        assert!(json.contains("0xrecipient"));
789        assert!(json.contains("1000000"));
790    }
791
792    #[test]
793    fn test_internal_transaction_serialization() {
794        let internal = InternalTransaction {
795            call_type: "call".to_string(),
796            from: "0xfrom".to_string(),
797            to: "0xto".to_string(),
798            value: "1.0".to_string(),
799            gas: 50000,
800            input: "0x".to_string(),
801            output: "0x".to_string(),
802        };
803
804        let json = serde_json::to_string(&internal).unwrap();
805        assert!(json.contains("call"));
806        assert!(json.contains("0xfrom"));
807        assert!(json.contains("50000"));
808    }
809
810    // ========================================================================
811    // Output formatting tests
812    // ========================================================================
813
814    fn make_test_tx_report() -> TransactionReport {
815        TransactionReport {
816            hash: VALID_TX_HASH.to_string(),
817            chain: "ethereum".to_string(),
818            block: BlockInfo {
819                number: 12345678,
820                timestamp: 1700000000,
821                hash: "0xblock".to_string(),
822            },
823            transaction: TransactionDetails {
824                from: "0xfrom".to_string(),
825                to: Some("0xto".to_string()),
826                value: "1.0".to_string(),
827                nonce: 42,
828                transaction_index: 5,
829                status: true,
830                input: "0xa9059cbb0000000000".to_string(),
831            },
832            gas: GasInfo {
833                gas_limit: 100000,
834                gas_used: 21000,
835                gas_price: "20000000000".to_string(),
836                transaction_fee: "0.00042".to_string(),
837                effective_gas_price: None,
838            },
839            decoded_input: Some(DecodedInput {
840                function_signature: "transfer(address,uint256)".to_string(),
841                function_name: "transfer".to_string(),
842                parameters: vec![DecodedParameter {
843                    name: "to".to_string(),
844                    param_type: "address".to_string(),
845                    value: "0xrecipient".to_string(),
846                }],
847            }),
848            internal_transactions: Some(vec![InternalTransaction {
849                call_type: "call".to_string(),
850                from: "0xfrom".to_string(),
851                to: "0xto".to_string(),
852                value: "0.5".to_string(),
853                gas: 30000,
854                input: "0x".to_string(),
855                output: "0x".to_string(),
856            }]),
857        }
858    }
859
860    #[test]
861    fn test_output_report_json() {
862        let report = make_test_tx_report();
863        let result = output_report(&report, OutputFormat::Json);
864        assert!(result.is_ok());
865    }
866
867    #[test]
868    fn test_output_report_csv() {
869        let report = make_test_tx_report();
870        let result = output_report(&report, OutputFormat::Csv);
871        assert!(result.is_ok());
872    }
873
874    #[test]
875    fn test_output_report_table() {
876        let report = make_test_tx_report();
877        let result = output_report(&report, OutputFormat::Table);
878        assert!(result.is_ok());
879    }
880
881    #[test]
882    fn test_output_report_table_no_decoded() {
883        let mut report = make_test_tx_report();
884        report.decoded_input = None;
885        report.internal_transactions = None;
886        let result = output_report(&report, OutputFormat::Table);
887        assert!(result.is_ok());
888    }
889
890    #[test]
891    fn test_output_report_table_failed_tx() {
892        let mut report = make_test_tx_report();
893        report.transaction.status = false;
894        report.transaction.to = None; // Contract creation
895        let result = output_report(&report, OutputFormat::Table);
896        assert!(result.is_ok());
897    }
898
899    #[test]
900    fn test_output_report_table_empty_traces() {
901        let mut report = make_test_tx_report();
902        report.internal_transactions = Some(vec![]);
903        let result = output_report(&report, OutputFormat::Table);
904        assert!(result.is_ok());
905    }
906
907    #[test]
908    fn test_output_report_csv_no_to() {
909        let mut report = make_test_tx_report();
910        report.transaction.to = None;
911        let result = output_report(&report, OutputFormat::Csv);
912        assert!(result.is_ok());
913    }
914
915    // ========================================================================
916    // Mock-based tests for fetch_transaction_report
917    // ========================================================================
918
919    use crate::chains::{
920        Balance as ChainBalance, ChainClient, ChainClientFactory, DexDataSource,
921        TokenBalance as ChainTokenBalance, Transaction as ChainTransaction,
922    };
923    use async_trait::async_trait;
924
925    struct MockTxClient;
926
927    #[async_trait]
928    impl ChainClient for MockTxClient {
929        fn chain_name(&self) -> &str {
930            "ethereum"
931        }
932        fn native_token_symbol(&self) -> &str {
933            "ETH"
934        }
935        async fn get_balance(&self, _a: &str) -> crate::error::Result<ChainBalance> {
936            Ok(ChainBalance {
937                raw: "0".into(),
938                formatted: "0 ETH".into(),
939                decimals: 18,
940                symbol: "ETH".into(),
941                usd_value: None,
942            })
943        }
944        async fn enrich_balance_usd(&self, _b: &mut ChainBalance) {}
945        async fn get_transaction(&self, _h: &str) -> crate::error::Result<ChainTransaction> {
946            Ok(ChainTransaction {
947                hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".into(),
948                block_number: Some(12345678),
949                timestamp: Some(1700000000),
950                from: "0xfrom".into(),
951                to: Some("0xto".into()),
952                value: "1000000000000000000".into(),
953                gas_limit: 21000,
954                gas_used: Some(21000),
955                gas_price: "20000000000".into(),
956                nonce: 42,
957                input: "0xa9059cbb0000000000000000000000001234".into(),
958                status: Some(true),
959            })
960        }
961        async fn get_transactions(
962            &self,
963            _a: &str,
964            _l: u32,
965        ) -> crate::error::Result<Vec<ChainTransaction>> {
966            Ok(vec![])
967        }
968        async fn get_block_number(&self) -> crate::error::Result<u64> {
969            Ok(12345678)
970        }
971        async fn get_token_balances(
972            &self,
973            _a: &str,
974        ) -> crate::error::Result<Vec<ChainTokenBalance>> {
975            Ok(vec![])
976        }
977        async fn get_code(&self, _addr: &str) -> crate::error::Result<String> {
978            Ok("0x".into())
979        }
980    }
981
982    struct MockTxFactory;
983    impl ChainClientFactory for MockTxFactory {
984        fn create_chain_client(&self, _chain: &str) -> crate::error::Result<Box<dyn ChainClient>> {
985            Ok(Box::new(MockTxClient))
986        }
987        fn create_dex_client(&self) -> Box<dyn DexDataSource> {
988            crate::chains::DefaultClientFactory {
989                chains_config: Default::default(),
990            }
991            .create_dex_client()
992        }
993    }
994
995    #[tokio::test]
996    async fn test_fetch_transaction_report_mock() {
997        let factory = MockTxFactory;
998        let result = fetch_transaction_report(
999            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
1000            "ethereum",
1001            false,
1002            false,
1003            &factory,
1004        )
1005        .await;
1006        assert!(result.is_ok());
1007        let report = result.unwrap();
1008        assert_eq!(report.transaction.from, "0xfrom");
1009        assert!(report.transaction.status);
1010    }
1011
1012    #[tokio::test]
1013    async fn test_fetch_transaction_report_with_decode() {
1014        let factory = MockTxFactory;
1015        let result = fetch_transaction_report(
1016            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
1017            "ethereum",
1018            true,
1019            false,
1020            &factory,
1021        )
1022        .await;
1023        assert!(result.is_ok());
1024    }
1025
1026    // ========================================================================
1027    // End-to-end tests using MockClientFactory
1028    // ========================================================================
1029
1030    use crate::chains::mocks::MockClientFactory;
1031
1032    fn mock_factory() -> MockClientFactory {
1033        MockClientFactory::new()
1034    }
1035
1036    #[tokio::test]
1037    async fn test_run_ethereum_tx() {
1038        let config = Config::default();
1039        let factory = mock_factory();
1040        let args = TxArgs {
1041            hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1042            chain: "ethereum".to_string(),
1043            format: Some(OutputFormat::Json),
1044            trace: false,
1045            decode: false,
1046        };
1047        let result = super::run(args, &config, &factory).await;
1048        assert!(result.is_ok());
1049    }
1050
1051    #[tokio::test]
1052    async fn test_run_tx_with_decode() {
1053        let config = Config::default();
1054        let mut factory = mock_factory();
1055        factory.mock_client.transaction.input = "0xa9059cbb000000000000000000000000".to_string();
1056        let args = TxArgs {
1057            hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1058            chain: "ethereum".to_string(),
1059            format: Some(OutputFormat::Table),
1060            trace: false,
1061            decode: true,
1062        };
1063        let result = super::run(args, &config, &factory).await;
1064        assert!(result.is_ok());
1065    }
1066
1067    #[tokio::test]
1068    async fn test_run_tx_with_trace() {
1069        let config = Config::default();
1070        let factory = mock_factory();
1071        let args = TxArgs {
1072            hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1073            chain: "ethereum".to_string(),
1074            format: Some(OutputFormat::Csv),
1075            trace: true,
1076            decode: false,
1077        };
1078        let result = super::run(args, &config, &factory).await;
1079        assert!(result.is_ok());
1080    }
1081
1082    #[tokio::test]
1083    async fn test_run_tx_invalid_hash() {
1084        let config = Config::default();
1085        let factory = mock_factory();
1086        let args = TxArgs {
1087            hash: "invalid".to_string(),
1088            chain: "ethereum".to_string(),
1089            format: Some(OutputFormat::Json),
1090            trace: false,
1091            decode: false,
1092        };
1093        let result = super::run(args, &config, &factory).await;
1094        assert!(result.is_err());
1095    }
1096
1097    #[tokio::test]
1098    async fn test_run_tx_auto_detect_tron() {
1099        let config = Config::default();
1100        let factory = mock_factory();
1101        let args = TxArgs {
1102            // 64 hex chars = tron
1103            hash: "abc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1104            chain: "ethereum".to_string(), // Will be auto-detected to tron
1105            format: Some(OutputFormat::Json),
1106            trace: false,
1107            decode: false,
1108        };
1109        let result = super::run(args, &config, &factory).await;
1110        assert!(result.is_ok());
1111    }
1112
1113    // ========================================================================
1114    // Markdown formatting tests
1115    // ========================================================================
1116
1117    #[test]
1118    fn test_format_tx_markdown_basic() {
1119        let report = make_test_tx_report();
1120        let md = format_tx_markdown(&report);
1121        assert!(md.contains("# Transaction Analysis"));
1122        assert!(md.contains(&report.hash));
1123        assert!(md.contains(&report.chain));
1124        assert!(md.contains("Success"));
1125        assert!(md.contains(&report.transaction.from));
1126    }
1127
1128    #[test]
1129    fn test_format_tx_markdown_contract_creation() {
1130        let mut report = make_test_tx_report();
1131        report.transaction.to = None;
1132        let md = format_tx_markdown(&report);
1133        assert!(md.contains("Contract Creation"));
1134    }
1135
1136    #[test]
1137    fn test_format_tx_markdown_failed_tx() {
1138        let mut report = make_test_tx_report();
1139        report.transaction.status = false;
1140        let md = format_tx_markdown(&report);
1141        assert!(md.contains("Failed"));
1142    }
1143
1144    #[test]
1145    fn test_format_tx_markdown_with_decoded_input() {
1146        let report = make_test_tx_report();
1147        let md = format_tx_markdown(&report);
1148        assert!(md.contains("## Decoded Input"));
1149        assert!(md.contains("transfer"));
1150        assert!(md.contains("transfer(address,uint256)"));
1151    }
1152
1153    #[test]
1154    fn test_format_tx_markdown_with_internal_transactions() {
1155        let report = make_test_tx_report();
1156        let md = format_tx_markdown(&report);
1157        assert!(md.contains("## Internal Transactions"));
1158        assert!(md.contains("call"));
1159    }
1160
1161    #[test]
1162    fn test_format_tx_markdown_no_decoded_input() {
1163        let mut report = make_test_tx_report();
1164        report.decoded_input = None;
1165        let md = format_tx_markdown(&report);
1166        assert!(!md.contains("## Decoded Input"));
1167    }
1168
1169    #[test]
1170    fn test_format_tx_markdown_no_internal_transactions() {
1171        let mut report = make_test_tx_report();
1172        report.internal_transactions = None;
1173        let md = format_tx_markdown(&report);
1174        assert!(!md.contains("## Internal Transactions"));
1175    }
1176
1177    #[test]
1178    fn test_format_tx_markdown_empty_internal_transactions() {
1179        let mut report = make_test_tx_report();
1180        report.internal_transactions = Some(vec![]);
1181        let md = format_tx_markdown(&report);
1182        assert!(!md.contains("## Internal Transactions"));
1183    }
1184}