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            println!("Transaction Analysis Report");
391            println!("===========================");
392            println!("Hash:         {}", report.hash);
393            println!("Chain:        {}", report.chain);
394            println!("Block:        {}", report.block.number);
395            println!(
396                "Status:       {}",
397                if report.transaction.status {
398                    "Success"
399                } else {
400                    "Failed"
401                }
402            );
403            println!();
404            println!("From:         {}", report.transaction.from);
405            println!(
406                "To:           {}",
407                report
408                    .transaction
409                    .to
410                    .as_deref()
411                    .unwrap_or("Contract Creation")
412            );
413            println!("Value:        {}", report.transaction.value);
414            println!();
415            println!("Gas Limit:    {}", report.gas.gas_limit);
416            println!("Gas Used:     {}", report.gas.gas_used);
417            println!("Gas Price:    {}", report.gas.gas_price);
418            println!("Fee:          {}", report.gas.transaction_fee);
419
420            if let Some(ref decoded) = report.decoded_input {
421                println!();
422                println!("Function:     {}", decoded.function_name);
423                println!("Signature:    {}", decoded.function_signature);
424                if !decoded.parameters.is_empty() {
425                    println!("Parameters:");
426                    for param in &decoded.parameters {
427                        println!("  {} ({}): {}", param.name, param.param_type, param.value);
428                    }
429                }
430            }
431
432            if let Some(ref traces) = report.internal_transactions
433                && !traces.is_empty()
434            {
435                println!();
436                println!("Internal Transactions: {}", traces.len());
437                for (i, trace) in traces.iter().enumerate() {
438                    println!(
439                        "  [{}] {} {} -> {}",
440                        i, trace.call_type, trace.from, trace.to
441                    );
442                }
443            }
444        }
445        OutputFormat::Markdown => {
446            let md = format_tx_markdown(report);
447            println!("{}", md);
448        }
449    }
450    Ok(())
451}
452
453/// Formats a transaction report as markdown for agent consumption.
454/// Exposed for use by insights and report generation.
455pub fn format_tx_markdown(report: &TransactionReport) -> String {
456    let mut md = String::new();
457    md.push_str("# Transaction Analysis\n\n");
458    md.push_str("| Field | Value |\n|-------|-------|\n");
459    md.push_str(&format!("| Hash | `{}` |\n", report.hash));
460    md.push_str(&format!("| Chain | {} |\n", report.chain));
461    md.push_str(&format!("| Block | {} |\n", report.block.number));
462    md.push_str(&format!(
463        "| Status | {} |\n",
464        if report.transaction.status {
465            "Success"
466        } else {
467            "Failed"
468        }
469    ));
470    md.push_str(&format!("| From | `{}` |\n", report.transaction.from));
471    md.push_str(&format!(
472        "| To | `{}` |\n",
473        report
474            .transaction
475            .to
476            .as_deref()
477            .unwrap_or("Contract Creation")
478    ));
479    md.push_str(&format!("| Value | {} |\n", report.transaction.value));
480    md.push_str(&format!("| Gas Used | {} |\n", report.gas.gas_used));
481    md.push_str(&format!("| Fee | {} |\n", report.gas.transaction_fee));
482    if let Some(ref decoded) = report.decoded_input {
483        md.push_str("\n## Decoded Input\n\n");
484        md.push_str(&format!("- **Function:** {}\n", decoded.function_name));
485        md.push_str(&format!(
486            "- **Signature:** `{}`\n",
487            decoded.function_signature
488        ));
489        if !decoded.parameters.is_empty() {
490            md.push_str("\n| Parameter | Type | Value |\n|-----------|------|-------|\n");
491            for param in &decoded.parameters {
492                md.push_str(&format!(
493                    "| {} | {} | {} |\n",
494                    param.name, param.param_type, param.value
495                ));
496            }
497        }
498    }
499    if let Some(ref traces) = report.internal_transactions
500        && !traces.is_empty()
501    {
502        md.push_str("\n## Internal Transactions\n\n");
503        md.push_str("| # | Type | From | To |\n|---|---|---|---|\n");
504        for (i, trace) in traces.iter().enumerate() {
505            md.push_str(&format!(
506                "| {} | {} | `{}` | `{}` |\n",
507                i + 1,
508                trace.call_type,
509                trace.from,
510                trace.to
511            ));
512        }
513    }
514    md
515}
516
517// ============================================================================
518// Unit Tests
519// ============================================================================
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524
525    const VALID_TX_HASH: &str =
526        "0xabc123def456789012345678901234567890123456789012345678901234abcd";
527
528    #[test]
529    fn test_validate_tx_hash_valid() {
530        let result = validate_tx_hash(VALID_TX_HASH, "ethereum");
531        assert!(result.is_ok());
532    }
533
534    #[test]
535    fn test_validate_tx_hash_valid_lowercase() {
536        let hash = "0xabc123def456789012345678901234567890123456789012345678901234abcd";
537        let result = validate_tx_hash(hash, "ethereum");
538        assert!(result.is_ok());
539    }
540
541    #[test]
542    fn test_validate_tx_hash_valid_polygon() {
543        let result = validate_tx_hash(VALID_TX_HASH, "polygon");
544        assert!(result.is_ok());
545    }
546
547    #[test]
548    fn test_validate_tx_hash_missing_prefix() {
549        let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
550        let result = validate_tx_hash(hash, "ethereum");
551        assert!(result.is_err());
552        assert!(result.unwrap_err().to_string().contains("0x"));
553    }
554
555    #[test]
556    fn test_validate_tx_hash_too_short() {
557        let hash = "0xabc123";
558        let result = validate_tx_hash(hash, "ethereum");
559        assert!(result.is_err());
560        assert!(result.unwrap_err().to_string().contains("66 characters"));
561    }
562
563    #[test]
564    fn test_validate_tx_hash_too_long() {
565        let hash = "0xabc123def456789012345678901234567890123456789012345678901234abcde";
566        let result = validate_tx_hash(hash, "ethereum");
567        assert!(result.is_err());
568    }
569
570    #[test]
571    fn test_validate_tx_hash_invalid_hex_cli() {
572        let hash = "0xabc123def456789012345678901234567890123456789012345678901234GHIJ";
573        let result = validate_tx_hash(hash, "ethereum");
574        assert!(result.is_err());
575        assert!(result.unwrap_err().to_string().contains("invalid hex"));
576    }
577
578    #[test]
579    fn test_validate_tx_hash_unsupported_chain() {
580        let result = validate_tx_hash(VALID_TX_HASH, "bitcoin");
581        assert!(result.is_err());
582        assert!(
583            result
584                .unwrap_err()
585                .to_string()
586                .contains("Unsupported chain")
587        );
588    }
589
590    #[test]
591    fn test_validate_tx_hash_valid_bsc() {
592        let result = validate_tx_hash(VALID_TX_HASH, "bsc");
593        assert!(result.is_ok());
594    }
595
596    #[test]
597    fn test_validate_tx_hash_valid_aegis() {
598        let result = validate_tx_hash(VALID_TX_HASH, "aegis");
599        assert!(result.is_ok());
600    }
601
602    #[test]
603    fn test_validate_tx_hash_valid_solana() {
604        // Solana signature (base58 encoded, ~88 chars)
605        let sig = "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW";
606        let result = validate_tx_hash(sig, "solana");
607        assert!(result.is_ok());
608    }
609
610    #[test]
611    fn test_validate_tx_hash_invalid_solana() {
612        // EVM hash should fail for Solana
613        let result = validate_tx_hash(VALID_TX_HASH, "solana");
614        assert!(result.is_err());
615    }
616
617    #[test]
618    fn test_validate_tx_hash_valid_tron() {
619        // Tron uses 64-char hex without 0x prefix
620        let hash = "abc123def456789012345678901234567890123456789012345678901234abcd";
621        let result = validate_tx_hash(hash, "tron");
622        assert!(result.is_ok());
623    }
624
625    #[test]
626    fn test_validate_tx_hash_invalid_tron() {
627        // 0x-prefixed hash should fail for Tron
628        let result = validate_tx_hash(VALID_TX_HASH, "tron");
629        assert!(result.is_err());
630    }
631
632    #[test]
633    fn test_tx_args_default_values() {
634        use clap::Parser;
635
636        #[derive(Parser)]
637        struct TestCli {
638            #[command(flatten)]
639            args: TxArgs,
640        }
641
642        let cli = TestCli::try_parse_from(["test", VALID_TX_HASH]).unwrap();
643
644        assert_eq!(cli.args.chain, "ethereum");
645        assert!(!cli.args.trace);
646        assert!(!cli.args.decode);
647        assert!(cli.args.format.is_none());
648    }
649
650    #[test]
651    fn test_tx_args_with_options() {
652        use clap::Parser;
653
654        #[derive(Parser)]
655        struct TestCli {
656            #[command(flatten)]
657            args: TxArgs,
658        }
659
660        let cli = TestCli::try_parse_from([
661            "test",
662            VALID_TX_HASH,
663            "--chain",
664            "polygon",
665            "--trace",
666            "--decode",
667            "--format",
668            "json",
669        ])
670        .unwrap();
671
672        assert_eq!(cli.args.chain, "polygon");
673        assert!(cli.args.trace);
674        assert!(cli.args.decode);
675        assert_eq!(cli.args.format, Some(OutputFormat::Json));
676    }
677
678    #[test]
679    fn test_transaction_report_serialization() {
680        let report = TransactionReport {
681            hash: VALID_TX_HASH.to_string(),
682            chain: "ethereum".to_string(),
683            block: BlockInfo {
684                number: 12345678,
685                timestamp: 1700000000,
686                hash: "0xblock".to_string(),
687            },
688            transaction: TransactionDetails {
689                from: "0xfrom".to_string(),
690                to: Some("0xto".to_string()),
691                value: "1.0".to_string(),
692                nonce: 42,
693                transaction_index: 5,
694                status: true,
695                input: "0x".to_string(),
696            },
697            gas: GasInfo {
698                gas_limit: 100000,
699                gas_used: 21000,
700                gas_price: "20000000000".to_string(),
701                transaction_fee: "0.00042".to_string(),
702                effective_gas_price: None,
703            },
704            decoded_input: None,
705            internal_transactions: None,
706        };
707
708        let json = serde_json::to_string(&report).unwrap();
709        assert!(json.contains(VALID_TX_HASH));
710        assert!(json.contains("12345678"));
711        assert!(json.contains("21000"));
712        assert!(!json.contains("decoded_input"));
713        assert!(!json.contains("internal_transactions"));
714    }
715
716    #[test]
717    fn test_block_info_serialization() {
718        let block = BlockInfo {
719            number: 12345678,
720            timestamp: 1700000000,
721            hash: "0xblockhash".to_string(),
722        };
723
724        let json = serde_json::to_string(&block).unwrap();
725        assert!(json.contains("12345678"));
726        assert!(json.contains("1700000000"));
727        assert!(json.contains("0xblockhash"));
728    }
729
730    #[test]
731    fn test_gas_info_serialization() {
732        let gas = GasInfo {
733            gas_limit: 100000,
734            gas_used: 50000,
735            gas_price: "20000000000".to_string(),
736            transaction_fee: "0.001".to_string(),
737            effective_gas_price: Some("25000000000".to_string()),
738        };
739
740        let json = serde_json::to_string(&gas).unwrap();
741        assert!(json.contains("100000"));
742        assert!(json.contains("50000"));
743        assert!(json.contains("effective_gas_price"));
744    }
745
746    #[test]
747    fn test_decoded_input_serialization() {
748        let decoded = DecodedInput {
749            function_signature: "transfer(address,uint256)".to_string(),
750            function_name: "transfer".to_string(),
751            parameters: vec![
752                DecodedParameter {
753                    name: "to".to_string(),
754                    param_type: "address".to_string(),
755                    value: "0xrecipient".to_string(),
756                },
757                DecodedParameter {
758                    name: "amount".to_string(),
759                    param_type: "uint256".to_string(),
760                    value: "1000000".to_string(),
761                },
762            ],
763        };
764
765        let json = serde_json::to_string(&decoded).unwrap();
766        assert!(json.contains("transfer(address,uint256)"));
767        assert!(json.contains("0xrecipient"));
768        assert!(json.contains("1000000"));
769    }
770
771    #[test]
772    fn test_internal_transaction_serialization() {
773        let internal = InternalTransaction {
774            call_type: "call".to_string(),
775            from: "0xfrom".to_string(),
776            to: "0xto".to_string(),
777            value: "1.0".to_string(),
778            gas: 50000,
779            input: "0x".to_string(),
780            output: "0x".to_string(),
781        };
782
783        let json = serde_json::to_string(&internal).unwrap();
784        assert!(json.contains("call"));
785        assert!(json.contains("0xfrom"));
786        assert!(json.contains("50000"));
787    }
788
789    // ========================================================================
790    // Output formatting tests
791    // ========================================================================
792
793    fn make_test_tx_report() -> TransactionReport {
794        TransactionReport {
795            hash: VALID_TX_HASH.to_string(),
796            chain: "ethereum".to_string(),
797            block: BlockInfo {
798                number: 12345678,
799                timestamp: 1700000000,
800                hash: "0xblock".to_string(),
801            },
802            transaction: TransactionDetails {
803                from: "0xfrom".to_string(),
804                to: Some("0xto".to_string()),
805                value: "1.0".to_string(),
806                nonce: 42,
807                transaction_index: 5,
808                status: true,
809                input: "0xa9059cbb0000000000".to_string(),
810            },
811            gas: GasInfo {
812                gas_limit: 100000,
813                gas_used: 21000,
814                gas_price: "20000000000".to_string(),
815                transaction_fee: "0.00042".to_string(),
816                effective_gas_price: None,
817            },
818            decoded_input: Some(DecodedInput {
819                function_signature: "transfer(address,uint256)".to_string(),
820                function_name: "transfer".to_string(),
821                parameters: vec![DecodedParameter {
822                    name: "to".to_string(),
823                    param_type: "address".to_string(),
824                    value: "0xrecipient".to_string(),
825                }],
826            }),
827            internal_transactions: Some(vec![InternalTransaction {
828                call_type: "call".to_string(),
829                from: "0xfrom".to_string(),
830                to: "0xto".to_string(),
831                value: "0.5".to_string(),
832                gas: 30000,
833                input: "0x".to_string(),
834                output: "0x".to_string(),
835            }]),
836        }
837    }
838
839    #[test]
840    fn test_output_report_json() {
841        let report = make_test_tx_report();
842        let result = output_report(&report, OutputFormat::Json);
843        assert!(result.is_ok());
844    }
845
846    #[test]
847    fn test_output_report_csv() {
848        let report = make_test_tx_report();
849        let result = output_report(&report, OutputFormat::Csv);
850        assert!(result.is_ok());
851    }
852
853    #[test]
854    fn test_output_report_table() {
855        let report = make_test_tx_report();
856        let result = output_report(&report, OutputFormat::Table);
857        assert!(result.is_ok());
858    }
859
860    #[test]
861    fn test_output_report_table_no_decoded() {
862        let mut report = make_test_tx_report();
863        report.decoded_input = None;
864        report.internal_transactions = None;
865        let result = output_report(&report, OutputFormat::Table);
866        assert!(result.is_ok());
867    }
868
869    #[test]
870    fn test_output_report_table_failed_tx() {
871        let mut report = make_test_tx_report();
872        report.transaction.status = false;
873        report.transaction.to = None; // Contract creation
874        let result = output_report(&report, OutputFormat::Table);
875        assert!(result.is_ok());
876    }
877
878    #[test]
879    fn test_output_report_table_empty_traces() {
880        let mut report = make_test_tx_report();
881        report.internal_transactions = Some(vec![]);
882        let result = output_report(&report, OutputFormat::Table);
883        assert!(result.is_ok());
884    }
885
886    #[test]
887    fn test_output_report_csv_no_to() {
888        let mut report = make_test_tx_report();
889        report.transaction.to = None;
890        let result = output_report(&report, OutputFormat::Csv);
891        assert!(result.is_ok());
892    }
893
894    // ========================================================================
895    // Mock-based tests for fetch_transaction_report
896    // ========================================================================
897
898    use crate::chains::{
899        Balance as ChainBalance, ChainClient, ChainClientFactory, DexDataSource,
900        TokenBalance as ChainTokenBalance, Transaction as ChainTransaction,
901    };
902    use async_trait::async_trait;
903
904    struct MockTxClient;
905
906    #[async_trait]
907    impl ChainClient for MockTxClient {
908        fn chain_name(&self) -> &str {
909            "ethereum"
910        }
911        fn native_token_symbol(&self) -> &str {
912            "ETH"
913        }
914        async fn get_balance(&self, _a: &str) -> crate::error::Result<ChainBalance> {
915            Ok(ChainBalance {
916                raw: "0".into(),
917                formatted: "0 ETH".into(),
918                decimals: 18,
919                symbol: "ETH".into(),
920                usd_value: None,
921            })
922        }
923        async fn enrich_balance_usd(&self, _b: &mut ChainBalance) {}
924        async fn get_transaction(&self, _h: &str) -> crate::error::Result<ChainTransaction> {
925            Ok(ChainTransaction {
926                hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".into(),
927                block_number: Some(12345678),
928                timestamp: Some(1700000000),
929                from: "0xfrom".into(),
930                to: Some("0xto".into()),
931                value: "1000000000000000000".into(),
932                gas_limit: 21000,
933                gas_used: Some(21000),
934                gas_price: "20000000000".into(),
935                nonce: 42,
936                input: "0xa9059cbb0000000000000000000000001234".into(),
937                status: Some(true),
938            })
939        }
940        async fn get_transactions(
941            &self,
942            _a: &str,
943            _l: u32,
944        ) -> crate::error::Result<Vec<ChainTransaction>> {
945            Ok(vec![])
946        }
947        async fn get_block_number(&self) -> crate::error::Result<u64> {
948            Ok(12345678)
949        }
950        async fn get_token_balances(
951            &self,
952            _a: &str,
953        ) -> crate::error::Result<Vec<ChainTokenBalance>> {
954            Ok(vec![])
955        }
956        async fn get_code(&self, _addr: &str) -> crate::error::Result<String> {
957            Ok("0x".into())
958        }
959    }
960
961    struct MockTxFactory;
962    impl ChainClientFactory for MockTxFactory {
963        fn create_chain_client(&self, _chain: &str) -> crate::error::Result<Box<dyn ChainClient>> {
964            Ok(Box::new(MockTxClient))
965        }
966        fn create_dex_client(&self) -> Box<dyn DexDataSource> {
967            crate::chains::DefaultClientFactory {
968                chains_config: Default::default(),
969            }
970            .create_dex_client()
971        }
972    }
973
974    #[tokio::test]
975    async fn test_fetch_transaction_report_mock() {
976        let factory = MockTxFactory;
977        let result = fetch_transaction_report(
978            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
979            "ethereum",
980            false,
981            false,
982            &factory,
983        )
984        .await;
985        assert!(result.is_ok());
986        let report = result.unwrap();
987        assert_eq!(report.transaction.from, "0xfrom");
988        assert!(report.transaction.status);
989    }
990
991    #[tokio::test]
992    async fn test_fetch_transaction_report_with_decode() {
993        let factory = MockTxFactory;
994        let result = fetch_transaction_report(
995            "0xabc123def456789012345678901234567890123456789012345678901234abcd",
996            "ethereum",
997            true,
998            false,
999            &factory,
1000        )
1001        .await;
1002        assert!(result.is_ok());
1003    }
1004
1005    // ========================================================================
1006    // End-to-end tests using MockClientFactory
1007    // ========================================================================
1008
1009    use crate::chains::mocks::MockClientFactory;
1010
1011    fn mock_factory() -> MockClientFactory {
1012        MockClientFactory::new()
1013    }
1014
1015    #[tokio::test]
1016    async fn test_run_ethereum_tx() {
1017        let config = Config::default();
1018        let factory = mock_factory();
1019        let args = TxArgs {
1020            hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1021            chain: "ethereum".to_string(),
1022            format: Some(OutputFormat::Json),
1023            trace: false,
1024            decode: false,
1025        };
1026        let result = super::run(args, &config, &factory).await;
1027        assert!(result.is_ok());
1028    }
1029
1030    #[tokio::test]
1031    async fn test_run_tx_with_decode() {
1032        let config = Config::default();
1033        let mut factory = mock_factory();
1034        factory.mock_client.transaction.input = "0xa9059cbb000000000000000000000000".to_string();
1035        let args = TxArgs {
1036            hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1037            chain: "ethereum".to_string(),
1038            format: Some(OutputFormat::Table),
1039            trace: false,
1040            decode: true,
1041        };
1042        let result = super::run(args, &config, &factory).await;
1043        assert!(result.is_ok());
1044    }
1045
1046    #[tokio::test]
1047    async fn test_run_tx_with_trace() {
1048        let config = Config::default();
1049        let factory = mock_factory();
1050        let args = TxArgs {
1051            hash: "0xabc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1052            chain: "ethereum".to_string(),
1053            format: Some(OutputFormat::Csv),
1054            trace: true,
1055            decode: false,
1056        };
1057        let result = super::run(args, &config, &factory).await;
1058        assert!(result.is_ok());
1059    }
1060
1061    #[tokio::test]
1062    async fn test_run_tx_invalid_hash() {
1063        let config = Config::default();
1064        let factory = mock_factory();
1065        let args = TxArgs {
1066            hash: "invalid".to_string(),
1067            chain: "ethereum".to_string(),
1068            format: Some(OutputFormat::Json),
1069            trace: false,
1070            decode: false,
1071        };
1072        let result = super::run(args, &config, &factory).await;
1073        assert!(result.is_err());
1074    }
1075
1076    #[tokio::test]
1077    async fn test_run_tx_auto_detect_tron() {
1078        let config = Config::default();
1079        let factory = mock_factory();
1080        let args = TxArgs {
1081            // 64 hex chars = tron
1082            hash: "abc123def456789012345678901234567890123456789012345678901234abcd".to_string(),
1083            chain: "ethereum".to_string(), // Will be auto-detected to tron
1084            format: Some(OutputFormat::Json),
1085            trace: false,
1086            decode: false,
1087        };
1088        let result = super::run(args, &config, &factory).await;
1089        assert!(result.is_ok());
1090    }
1091
1092    // ========================================================================
1093    // Markdown formatting tests
1094    // ========================================================================
1095
1096    #[test]
1097    fn test_format_tx_markdown_basic() {
1098        let report = make_test_tx_report();
1099        let md = format_tx_markdown(&report);
1100        assert!(md.contains("# Transaction Analysis"));
1101        assert!(md.contains(&report.hash));
1102        assert!(md.contains(&report.chain));
1103        assert!(md.contains("Success"));
1104        assert!(md.contains(&report.transaction.from));
1105    }
1106
1107    #[test]
1108    fn test_format_tx_markdown_contract_creation() {
1109        let mut report = make_test_tx_report();
1110        report.transaction.to = None;
1111        let md = format_tx_markdown(&report);
1112        assert!(md.contains("Contract Creation"));
1113    }
1114
1115    #[test]
1116    fn test_format_tx_markdown_failed_tx() {
1117        let mut report = make_test_tx_report();
1118        report.transaction.status = false;
1119        let md = format_tx_markdown(&report);
1120        assert!(md.contains("Failed"));
1121    }
1122
1123    #[test]
1124    fn test_format_tx_markdown_with_decoded_input() {
1125        let report = make_test_tx_report();
1126        let md = format_tx_markdown(&report);
1127        assert!(md.contains("## Decoded Input"));
1128        assert!(md.contains("transfer"));
1129        assert!(md.contains("transfer(address,uint256)"));
1130    }
1131
1132    #[test]
1133    fn test_format_tx_markdown_with_internal_transactions() {
1134        let report = make_test_tx_report();
1135        let md = format_tx_markdown(&report);
1136        assert!(md.contains("## Internal Transactions"));
1137        assert!(md.contains("call"));
1138    }
1139
1140    #[test]
1141    fn test_format_tx_markdown_no_decoded_input() {
1142        let mut report = make_test_tx_report();
1143        report.decoded_input = None;
1144        let md = format_tx_markdown(&report);
1145        assert!(!md.contains("## Decoded Input"));
1146    }
1147
1148    #[test]
1149    fn test_format_tx_markdown_no_internal_transactions() {
1150        let mut report = make_test_tx_report();
1151        report.internal_transactions = None;
1152        let md = format_tx_markdown(&report);
1153        assert!(!md.contains("## Internal Transactions"));
1154    }
1155
1156    #[test]
1157    fn test_format_tx_markdown_empty_internal_transactions() {
1158        let mut report = make_test_tx_report();
1159        report.internal_transactions = Some(vec![]);
1160        let md = format_tx_markdown(&report);
1161        assert!(!md.contains("## Internal Transactions"));
1162    }
1163}