From d31d9fa34ecb8b106cd8b4ac6139e9e387019f4b Mon Sep 17 00:00:00 2001 From: Opulence Chuks Date: Tue, 16 Jun 2026 06:49:58 +0100 Subject: [PATCH] feat: support decoding multi-operation Stellar transactions Decodes all operations in a transaction envelope independently, aggregating them into a list ordered by operation index. Updates both the decode and inspect CLI commands to output reports for each operation separately. --- crates/cli/src/commands/decode.rs | 25 ++++++--- crates/cli/src/commands/inspect.rs | 81 ++++++++++++++---------------- crates/core/src/decode/mod.rs | 68 ++++++++++++++++--------- 3 files changed, 103 insertions(+), 71 deletions(-) diff --git a/crates/cli/src/commands/decode.rs b/crates/cli/src/commands/decode.rs index cc3b651f..3b632921 100644 --- a/crates/cli/src/commands/decode.rs +++ b/crates/cli/src/commands/decode.rs @@ -21,8 +21,10 @@ pub async fn run( ) -> anyhow::Result<()> { let effective_output = if args.short { "short" } else { output_format }; - let report = if args.raw { - build_raw_xdr_report(&args.tx_hash)? + // Decode transaction, handling possible multiple operations + let reports = if args.raw { + // Raw XDR decoding yields a single report + vec![build_raw_xdr_report(&args.tx_hash)?] } else { let spinner = indicatif::ProgressBar::new_spinner(); spinner.set_message(format!( @@ -31,15 +33,26 @@ pub async fn run( )); spinner.enable_steady_tick(std::time::Duration::from_millis(100)); - let report = prism_core::decode::decode_transaction(&args.tx_hash, network).await?; + let reports = prism_core::decode::decode_transaction_with_op_filter( + &args.tx_hash, + network, + None, + ) + .await?; spinner.finish_and_clear(); - report + reports }; - crate::output::print_diagnostic_report(&report, effective_output)?; + // Print each report; include operation index header when multiple reports + for (i, report) in reports.iter().enumerate() { + if reports.len() > 1 { + println!("\n=== Operation {} ===", i); + } + crate::output::print_diagnostic_report(report, effective_output)?; + } if let Some(path) = save { - let json = serde_json::to_string_pretty(&report)?; + let json = serde_json::to_string_pretty(&reports)?; std::fs::write(path, &json) .map_err(|e| anyhow::anyhow!("Failed to write save file '{path}': {e}"))?; eprintln!("Saved report to {path}"); diff --git a/crates/cli/src/commands/inspect.rs b/crates/cli/src/commands/inspect.rs index 1f85c6b1..d1ce076a 100644 --- a/crates/cli/src/commands/inspect.rs +++ b/crates/cli/src/commands/inspect.rs @@ -26,7 +26,7 @@ pub async fn run( spinner.set_message("Fetching and decoding transaction..."); spinner.enable_steady_tick(std::time::Duration::from_millis(100)); - let report = prism_core::decode::decode_transaction_with_op_filter( + let reports = prism_core::decode::decode_transaction_with_op_filter( &args.tx_hash, network, args.op_index, @@ -35,50 +35,47 @@ pub async fn run( spinner.finish_and_clear(); - crate::output::print_diagnostic_report(&report, output_format)?; - - if args.fee_stats - && matches!( - crate::output::OutputMode::parse(output_format), - crate::output::OutputMode::Human - ) - { - let fee_context = report.transaction_context.as_ref().map(|ctx| &ctx.fee); - - let bid_fee: Option = None; - let resource_fee = fee_context.map(|fee| fee.resource_fee); - let total_charged_fee = - fee_context.and_then(|fee| fee.inclusion_fee.checked_add(fee.resource_fee)); - let inclusion_fee = match (total_charged_fee, resource_fee) { - (Some(charged), Some(resource)) => charged.checked_sub(resource), - _ => None, - }; - let surge = match (total_charged_fee, bid_fee) { - (Some(charged), Some(bid)) => Some(charged > bid), - _ => None, - }; - - let format_fee = |value: Option| match value { - Some(v) => format!("{v} stroops"), - None => "N/A".to_string(), - }; - let format_surge = |value: Option| match value { - Some(true) => "Yes", - Some(false) => "No", - None => "N/A", - }; - - println!(); - println!("FEE BREAKDOWN"); - println!("Bid Fee: {}", format_fee(bid_fee)); - println!("Total Charged Fee: {}", format_fee(total_charged_fee)); - println!("Resource Fee: {}", format_fee(resource_fee)); - println!("Inclusion Fee: {}", format_fee(inclusion_fee)); - println!("Surge: {}", format_surge(surge)); + // Print each report with operation index label + for (i, report) in reports.iter().enumerate() { + if reports.len() > 1 { + println!("\n=== Operation {} ===", i); + } + crate::output::print_diagnostic_report(report, output_format)?; + // Fee stats are only meaningful for the first report (transaction-level) + if i == 0 && args.fee_stats && matches!(crate::output::OutputMode::parse(output_format), crate::output::OutputMode::Human) { + let fee_context = report.transaction_context.as_ref().map(|ctx| &ctx.fee); + let bid_fee: Option = None; + let resource_fee = fee_context.map(|fee| fee.resource_fee); + let total_charged_fee = fee_context.and_then(|fee| fee.inclusion_fee.checked_add(fee.resource_fee)); + let inclusion_fee = match (total_charged_fee, resource_fee) { + (Some(charged), Some(resource)) => charged.checked_sub(resource), + _ => None, + }; + let surge = match (total_charged_fee, bid_fee) { + (Some(charged), Some(bid)) => Some(charged > bid), + _ => None, + }; + let format_fee = |value: Option| match value { + Some(v) => format!("{v} stroops"), + None => "N/A".to_string(), + }; + let format_surge = |value: Option| match value { + Some(true) => "Yes", + Some(false) => "No", + None => "N/A", + }; + println!(); + println!("FEE BREAKDOWN"); + println!("Bid Fee: {}", format_fee(bid_fee)); + println!("Total Charged Fee: {}", format_fee(total_charged_fee)); + println!("Resource Fee: {}", format_fee(resource_fee)); + println!("Inclusion Fee: {}", format_fee(inclusion_fee)); + println!("Surge: {}", format_surge(surge)); + } } if let Some(path) = save { - let json = serde_json::to_string_pretty(&report)?; + let json = serde_json::to_string_pretty(&reports)?; std::fs::write(path, &json) .map_err(|e| anyhow::anyhow!("Failed to write save file '{path}': {e}"))?; eprintln!("Saved report to {path}"); diff --git a/crates/core/src/decode/mod.rs b/crates/core/src/decode/mod.rs index f2ba8310..2b65e631 100644 --- a/crates/core/src/decode/mod.rs +++ b/crates/core/src/decode/mod.rs @@ -1,5 +1,4 @@ - pub mod context; pub mod contract_error; pub mod diagnostic; @@ -44,7 +43,7 @@ fn filter_transaction_by_operation( pub async fn decode_transaction( tx_hash: &str, network: &crate::types::config::NetworkConfig, -) -> PrismResult { +) -> PrismResult> { decode_transaction_with_op_filter(tx_hash, network, None).await } @@ -52,35 +51,58 @@ pub async fn decode_transaction_with_op_filter( tx_hash: &str, network: &crate::types::config::NetworkConfig, op_index: Option, -) -> PrismResult { +) -> PrismResult> { let rpc = crate::rpc::SorobanRpcClient::new(network); let tx_data = rpc.get_transaction(tx_hash).await?; - let mut tx_data = serde_json::to_value(tx_data) + let base_tx_data = serde_json::to_value(tx_data) .map_err(|e| crate::error::PrismError::Internal(e.to_string()))?; - if let Some(index) = op_index { - filter_transaction_by_operation(&mut tx_data, index)?; - } + // Decode the envelope XDR to determine the number of operations in the transaction. + let num_ops = if let Some(envelope_str) = base_tx_data.get("envelopeXdr").and_then(|v| v.as_str()) { + // Use the XDR codec to parse the envelope. + let envelope = ::from_xdr_base64(envelope_str) + .map_err(|e| crate::error::PrismError::Internal(format!("Failed to decode envelope XDR: {}", e)))?; + match envelope { + stellar_xdr::curr::TransactionEnvelope::Tx(v1) => v1.tx.operations.len(), + stellar_xdr::curr::TransactionEnvelope::TxFeeBump(fb) => { + // Fee bump transaction contains an inner transaction with its own operations. + fb.tx.fee_bump_op.operations.len() + } + } + } else { + // Fallback to a single operation if envelope missing + 1 + }; - let error_info = host_error::classify_error(&tx_data)?; + let mut reports = Vec::new(); + let indices = match op_index { + Some(i) => vec![i], + None => (0..num_ops).collect(), + }; - let mut report = report::build_report(&error_info)?; + for i in indices { + let mut tx_data = base_tx_data.clone(); + filter_transaction_by_operation(&mut tx_data, i)?; - if error_info.is_contract_error { - if let Ok(contract_info) = contract_error::resolve( - &error_info.contract_id.unwrap_or_default(), - error_info.error_code, - network, - ) - .await - { - report.contract_error = Some(contract_info); - } - } + let error_info = host_error::classify_error(&tx_data)?; + let mut report = report::build_report(&error_info)?; - diagnostic::enrich_report(&mut report, &tx_data)?; + if error_info.is_contract_error { + if let Ok(contract_info) = contract_error::resolve( + &error_info.contract_id.unwrap_or_default(), + error_info.error_code, + network, + ) + .await + { + report.contract_error = Some(contract_info); + } + } - context::enrich_report(&mut report, &tx_data)?; + diagnostic::enrich_report(&mut report, &tx_data)?; + context::enrich_report(&mut report, &tx_data)?; + reports.push(report); + } - Ok(report) + Ok(reports) }