Skip to main content

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