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