From 9681cd12dd83743d9d5e761bae32dda76edbc51c Mon Sep 17 00:00:00 2001 From: JhayJ22 Date: Thu, 30 Jul 2026 10:07:00 +0000 Subject: [PATCH] feat(core): implement multi-op transaction decoder (#377, #379) - Add multi_op_decoder.rs module with per-operation decoding - Refactor core decoder loop to iterate over operations array in TransactionEnvelope - Map each operation's results and events from TransactionResultMeta correctly - Aggregate or isolate diagnostics per operation in the final report - Add decode_transaction_with_op_filter function for fetching and decoding - Update TransactionContext with operation_index and operation_count fields - Update DiagnosticReport with operation tracking fields - Export MultiOpDecoder and decode_transaction_with_op_filter from decode module Closes #377 Closes #379 --- crates/core/src/decode/mod.rs | 2 + crates/core/src/decode/multi_op_decoder.rs | 521 +++++++++++++++++++++ crates/core/src/lib.rs | 4 +- crates/core/src/types/report.rs | 17 +- 4 files changed, 539 insertions(+), 5 deletions(-) create mode 100644 crates/core/src/decode/multi_op_decoder.rs diff --git a/crates/core/src/decode/mod.rs b/crates/core/src/decode/mod.rs index 7dce2d34..110a0858 100644 --- a/crates/core/src/decode/mod.rs +++ b/crates/core/src/decode/mod.rs @@ -13,6 +13,7 @@ pub mod fee_analyzer; pub mod function_call_decoder; pub mod host_error; pub mod mappings; +pub mod multi_op_decoder; pub mod report; pub mod return_decoder; pub mod resource_analyzer; @@ -31,6 +32,7 @@ pub use chain_analyzer::{analyze_call_chain, CallChain, ChainAnalyzer, ChainFram pub use resource_analyzer::{ MetricDiagnostic, MetricKind, ResourceDiagnostics, ResourceUsageAnalyzer, TransactionResultMeta, }; +pub use multi_op_decoder::{decode_transaction_with_op_filter, MultiOpDecoder}; pub use scval_to_json::scval_to_json; pub use walker::{ walk_diagnostic_events, DiagnosticEventKind, DiagnosticEventWalker, StructuredDiagnosticEvent, diff --git a/crates/core/src/decode/multi_op_decoder.rs b/crates/core/src/decode/multi_op_decoder.rs new file mode 100644 index 00000000..193e109d --- /dev/null +++ b/crates/core/src/decode/multi_op_decoder.rs @@ -0,0 +1,521 @@ +use crate::decode::host_error::classify_error; +use crate::decode::report::build_report; +use crate::error::GratResult; +use crate::network::config::NetworkConfig; +use crate::rpc::SorobanRpcClient; +use crate::types::report::DiagnosticReport; +use crate::xdr::codec::XdrCodec; +use stellar_xdr::curr::{ + ContractEvent, ContractEventBody, DiagnosticEvent, Operation, OperationBody, + OperationResult, OperationResultTr, SorobanTransactionMeta, SorobanTransactionMetaExt, + TransactionEnvelope, TransactionMeta, TransactionMetaV3, TransactionResult, + TransactionResultResult, TxV1Envelope, +}; +use stellar_xdr::curr::{FeeBumpTransactionInnerTx, ScVal}; + +struct OperationResultInfo { + function_name: Option, + arguments: Vec, + return_value: Option, + is_success: bool, + error_category: Option, + error_name: Option, +} + +pub struct MultiOpDecoder; + +impl MultiOpDecoder { + pub fn new() -> Self { + Self + } + + pub fn decode_transaction( + &self, + tx_data: &serde_json::Value, + ) -> GratResult> { + let envelope_xdr = tx_data + .get("envelopeXdr") + .and_then(|v| v.as_str()) + .ok_or_else(|| crate::error::GratError::Internal( + "Missing envelopeXdr in transaction data".to_string(), + ))?; + + let envelope = ::from_xdr_base64(envelope_xdr) + .map_err(|e| crate::error::GratError::Internal(format!("Failed to decode envelope XDR: {}", e)))?; + + let num_ops = match &envelope { + TransactionEnvelope::Tx(TxV1Envelope { tx, .. }) => tx.operations.len(), + TransactionEnvelope::TxFeeBump(fb) => match &fb.tx.inner_tx { + FeeBumpTransactionInnerTx::Tx(TxV1Envelope { tx, .. }) => tx.operations.len(), + }, + TransactionEnvelope::TxV0(_) => 1, + }; + + let result_xdr = tx_data + .get("resultXdr") + .and_then(|v| v.as_str()) + .ok_or_else(|| crate::error::GratError::Internal( + "Missing resultXdr in transaction data".to_string(), + ))?; + + let tx_result = ::from_xdr_base64(result_xdr) + .map_err(|e| crate::error::GratError::Internal(format!("Failed to decode result XDR: {}", e)))?; + + let op_results = match tx_result.result { + TransactionResultResult::TxSuccess(ops) => ops, + TransactionResultResult::TxFailed(ops) => ops, + TransactionResultResult::TxFeeBumpInnerSuccess(_) => { + return Ok(vec![build_report(&classify_error(tx_data)?).map_err(|e| { + crate::error::GratError::Internal(format!("{}", e)) + })?]) + } + _ => { + return Err(crate::error::GratError::NotSorobanTransaction.into()); + } + }; + + let meta_xdr = tx_data + .get("resultMetaXdr") + .and_then(|v| v.as_str()) + .map(|xdr| { + ::from_xdr_base64(xdr) + .map_err(|e| crate::error::GratError::Internal(format!("Failed to decode meta XDR: {}", e))) + }) + .transpose()?; + + let soroban_meta = meta_xdr + .and_then(|meta| match meta { + TransactionMeta::V3(v3) => v3.soroban_meta, + TransactionMeta::V0(_) => None, + TransactionMeta::V1(_) => None, + TransactionMeta::V2(_) => None, + }); + + let all_diagnostic_events = soroban_meta + .as_ref() + .and_then(|sm| sm.diagnostic_events.as_ref()) + .map(|ev| ev.iter().cloned().collect::>()) + .unwrap_or_default(); + + let all_contract_events = soroban_meta + .as_ref() + .and_then(|sm| sm.events.as_ref()) + .map(|ev| ev.iter().cloned().collect::>()) + .unwrap_or_default(); + + let overall_resources = crate::decode::resource_analyzer::TransactionResultMeta::from_tx_data(tx_data); + let overall_resource_summary = crate::types::report::ResourceSummary { + cpu_instructions_used: overall_resources.resources_consumed.cpu_instructions, + cpu_instructions_limit: overall_resources.resources_allocated.cpu_instructions, + memory_bytes_used: overall_resources.resources_consumed.memory_bytes, + memory_bytes_limit: overall_resources.resources_allocated.memory_bytes, + read_bytes: overall_resources.resources_consumed.read_bytes, + read_bytes_limit: overall_resources.resources_allocated.read_bytes, + write_bytes: overall_resources.resources_consumed.write_bytes, + }; + + let overall_fee = crate::decode::fee_analyzer::analyze_fee_breakdown(tx_data); + + let operation_results = decode_operation_results(&envelope, &op_results, num_ops); + + let operation_event_partitions = partition_events_by_operation( + &all_diagnostic_events, + num_ops, + ); + + let operation_contract_partitions = partition_contract_events_by_operation( + &all_contract_events, + num_ops, + ); + + let mut reports = Vec::new(); + + for i in 0..num_ops { + let op_info = &operation_results[i]; + let op_events = operation_event_partitions.get(i).cloned().unwrap_or_default(); + let op_contract_events = operation_contract_partitions.get(i).cloned().unwrap_or_default(); + + let error_category = op_info.error_category.clone().unwrap_or_else(|| "unknown".to_string()); + let error_name = op_info.error_name.clone().unwrap_or_else(|| "Unknown".to_string()); + + let mut report = if op_info.is_success { + DiagnosticReport::new( + &error_category, + 0, + &error_name, + &format!("Operation {} succeeded", i + 1), + ) + } else { + DiagnosticReport::new( + &error_category, + 0, + &error_name, + &format!("Operation {} failed", i + 1), + ) + }; + + report.transaction_context = Some(crate::types::report::TransactionContext { + tx_hash: tx_data + .get("hash") + .and_then(|h| h.as_str()) + .unwrap_or("unknown") + .to_string(), + ledger_sequence: tx_data.get("ledger").and_then(|v| v.as_u64()).unwrap_or(0) as u32, + function_name: op_info.function_name.clone(), + arguments: op_info.arguments.clone(), + return_value: op_info.return_value.clone(), + fee: overall_fee.clone(), + resources: overall_resource_summary.clone(), + }); + + if !op_events.is_empty() { + let op_events_xdr: Vec = op_events.iter().filter_map(|e| { + XdrCodec::to_xdr_base64(e).ok() + }).collect(); + + let enriched = serde_json::json!({ + "diagnosticEventsXdr": op_events_xdr, + "hash": tx_data.get("hash"), + "ledger": tx_data.get("ledger"), + }); + enrich_diagnostic_report(&mut report, &enriched).ok(); + } + + let resource_json = serde_json::json!({ + "diagnosticEvents": overall_resources.is_budget_error.then(|| { + serde_json::json!({"type":"budget","data":{"category":"cpu","used":overall_resources.resources_consumed.cpu_instructions,"limit":overall_resources.resources_allocated.cpu_instructions}}) + }).into_iter().collect::>(), + "resourcesAllocated": serde_json::json!({ + "cpuInstructions": overall_resources.resources_allocated.cpu_instructions, + "memoryBytes": overall_resources.resources_allocated.memory_bytes, + "readBytes": overall_resources.resources_allocated.read_bytes, + "writeBytes": overall_resources.resources_allocated.write_bytes, + }), + "resourcesConsumed": serde_json::json!({ + "cpuInstructions": overall_resources.resources_consumed.cpu_instructions, + "memoryBytes": overall_resources.resources_consumed.memory_bytes, + "readBytes": overall_resources.resources_consumed.read_bytes, + "writeBytes": overall_resources.resources_consumed.write_bytes, + }), + "status": if op_info.is_success { "SUCCESS" } else { "FAILED" }, + }); + enrich_resource_report(&mut report, &resource_json).ok(); + + report.cross_contract_attribution = if !op_contract_events.is_empty() { + Some(crate::types::report::FailureAttribution { + contract_address: op_contract_events.iter().filter_map(|e| { + if let ContractEventBody::V0(v0) = &e.body { + v0.contract_id.as_ref().map(|h| { + crate::xdr::codec::XdrCodec::to_xdr_base64(h).unwrap_or_default() + }) + } else { + None + } + }).next().unwrap_or_default(), + function_name: op_info.function_name.clone(), + call_depth: 0, + origin_description: format!("Operation {}", i + 1), + }) + } else { + None + }; + + reports.push(report); + } + + Ok(reports) + } +} + +fn decode_operation_results( + envelope: &TransactionEnvelope, + op_results: &[OperationResult], + num_ops: usize, +) -> Vec { + let mut results = Vec::with_capacity(num_ops); + + for i in 0..num_ops { + let op = get_operation(envelope, i); + let op_result = op_results.get(i).cloned().unwrap_or_else(|| { + OperationResult { + ext: stellar_xdr::curr::ExtensionPoint::V0, + tr: OperationResultTr::InvokeHostFunction( + stellar_xdr::curr::InvokeHostFunctionResult::Success( + stellar_xdr::curr::Hash([0; 32]), + ), + ), + } + }); + + let info = match &op_result.tr { + OperationResultTr::InvokeHostFunction(inv_result) => { + match inv_result { + stellar_xdr::curr::InvokeHostFunctionResult::Success(hash) => { + let (fname, args, ret_val) = op.as_ref().and_then(|o| { + if let OperationBody::InvokeHostFunction(invoke) = &o.body { + let fname = invoke.function_name.to_string(); + let args: Vec = invoke.args.iter().map(|a| format!("{a:?}")).collect(); + (Some(fname), args, None) + } else { + (None, vec![], None) + } + }).unwrap_or((None, vec![], None)); + + OperationResultInfo { + function_name: fname, + arguments: args, + return_value: ret_val, + is_success: true, + error_category: None, + error_name: None, + } + } + stellar_xdr::curr::InvokeHostFunctionResult::Trapped => { + let (fname, _) = op.as_ref().and_then(|o| { + if let OperationBody::InvokeHostFunction(invoke) = &o.body { + (Some(invoke.function_name.to_string()), ()) + } else { + (None, ()) + } + }).unwrap_or((None, ())); + + OperationResultInfo { + function_name: fname, + arguments: vec![], + return_value: None, + is_success: false, + error_category: Some("Contract".to_string()), + error_name: Some("HostError"), + } + } + stellar_xdr::curr::InvokeHostFunctionResult::ResourceLimitExceeded => { + let (fname, _) = op.as_ref().and_then(|o| { + if let OperationBody::InvokeHostFunction(invoke) = &o.body { + (Some(invoke.function_name.to_string()), ()) + } else { + (None, ()) + } + }).unwrap_or((None, ())); + + OperationResultInfo { + function_name: fname, + arguments: vec![], + return_value: None, + is_success: false, + error_category: Some("Budget".to_string()), + error_name: Some("HostError"), + } + } + stellar_xdr::curr::InvokeHostFunctionResult::EntryArchived => { + let (fname, _) = op.as_ref().and_then(|o| { + if let OperationBody::InvokeHostFunction(invoke) = &o.body { + (Some(invoke.function_name.to_string()), ()) + } else { + (None, ()) + } + }).unwrap_or((None, ())); + + OperationResultInfo { + function_name: fname, + arguments: vec![], + return_value: None, + is_success: false, + error_category: Some("Storage".to_string()), + error_name: Some("HostError"), + } + } + stellar_xdr::curr::InvokeHostFunctionResult::Malformed + | stellar_xdr::curr::InvokeHostFunctionResult::InsufficientRefundableFee => { + let (fname, _) = op.as_ref().and_then(|o| { + if let OperationBody::InvokeHostFunction(invoke) = &o.body { + (Some(invoke.function_name.to_string()), ()) + } else { + (None, ()) + } + }).unwrap_or((None, ())); + + OperationResultInfo { + function_name: fname, + arguments: vec![], + return_value: None, + is_success: false, + error_category: Some("Context".to_string()), + error_name: Some("HostError"), + } + } + } + } + _ => { + let fname = op.as_ref().and_then(|o| { + if let OperationBody::InvokeHostFunction(invoke) = &o.body { + Some(invoke.function_name.to_string()) + } else { + None + } + }).unwrap_or_default(); + + OperationResultInfo { + function_name: if fname.is_empty() { None } else { Some(fname) }, + arguments: vec![], + return_value: None, + is_success: false, + error_category: Some("Unknown".to_string()), + error_name: Some("NonInvokeHostFunctionOperation"), + } + } + }; + + results.push(info); + } + + results +} + +fn get_operation(envelope: &TransactionEnvelope, index: usize) -> Option { + match envelope { + TransactionEnvelope::Tx(TxV1Envelope { tx, .. }) => tx.operations.get(index).cloned(), + TransactionEnvelope::TxFeeBump(fb) => match &fb.tx.inner_tx { + FeeBumpTransactionInnerTx::Tx(TxV1Envelope { tx, .. }) => tx.operations.get(index).cloned(), + }, + TransactionEnvelope::TxV0(_) => None, + } +} + +fn partition_events_by_operation( + diagnostic_events: &[DiagnosticEvent], + num_operations: usize, +) -> Vec> { + if num_operations <= 1 || diagnostic_events.is_empty() { + return vec![diagnostic_events.to_vec()]; + } + + let mut partitions: Vec> = vec![Vec::new(); num_operations]; + let mut depth: usize = 0; + let mut current_op: usize = 0; + + for event in diagnostic_events { + if let ContractEventBody::V0(v0) = &event.event.body { + let is_call = v0.topics.iter().any(|t| { + if let ScVal::Symbol(s) = t { + let s_low = s.to_string().to_lowercase(); + s_low == "fn_call" || s_low == "function_call" || s_low == "call" + } else { + false + } + }); + + if is_call { + if depth == 0 && current_op < num_operations { + current_op += 1; + } + depth += 1; + } + + if current_op > 0 && current_op <= num_operations { + partitions[current_op - 1].push(event.clone()); + } + + let is_return = v0.topics.iter().any(|t| { + if let ScVal::Symbol(s) = t { + let s_low = s.to_string().to_lowercase(); + s_low == "fn_return" || s_low == "function_return" || s_low == "return" + } else { + false + } + }); + + if is_return { + depth = depth.saturating_sub(1); + } + } + } + + partitions +} + +fn partition_contract_events_by_operation( + contract_events: &[ContractEvent], + num_operations: usize, +) -> Vec> { + if num_operations <= 1 || contract_events.is_empty() { + return vec![contract_events.to_vec()]; + } + + let mut partitions: Vec> = vec![Vec::new(); num_operations]; + let mut depth: usize = 0; + let mut current_op: usize = 0; + + for event in contract_events { + if let ContractEventBody::V0(v0) = &event.body { + let is_call = v0.topics.iter().any(|t| { + if let ScVal::Symbol(s) = t { + let s_low = s.to_string().to_lowercase(); + s_low == "fn_call" || s_low == "function_call" || s_low == "call" + } else { + false + } + }); + + if is_call { + if depth == 0 && current_op < num_operations { + current_op += 1; + } + depth += 1; + } + + if current_op > 0 && current_op <= num_operations { + partitions[current_op - 1].push(event.clone()); + } + + let is_return = v0.topics.iter().any(|t| { + if let ScVal::Symbol(s) = t { + let s_low = s.to_string().to_lowercase(); + s_low == "fn_return" || s_low == "function_return" || s_low == "return" + } else { + false + } + }); + + if is_return { + depth = depth.saturating_sub(1); + } + } + } + + partitions +} + +fn enrich_diagnostic_report( + report: &mut DiagnosticReport, + tx_data: &serde_json::Value, +) -> crate::error::GratResult<()> { + crate::decode::diagnostic::enrich_report(report, tx_data) +} + +fn enrich_resource_report( + report: &mut DiagnosticReport, + tx_data: &serde_json::Value, +) -> crate::error::GratResult<()> { + crate::decode::resource_analyzer::enrich_report(report, tx_data) +} + +pub async fn decode_transaction_with_op_filter( + tx_hash: &str, + network: &NetworkConfig, + op_index: Option, +) -> GratResult> { + let rpc = SorobanRpcClient::new(network); + let tx_data = rpc.get_transaction(tx_hash).await?; + let tx_json = serde_json::to_value(&tx_data) + .map_err(|e| crate::error::GratError::Internal(e.to_string()))?; + let decoder = MultiOpDecoder::new(); + let reports = decoder.decode_transaction(&tx_json)?; + + if let Some(idx) = op_index { + if idx < reports.len() { + return Ok(vec![reports[idx].clone()]); + } + } + + Ok(reports) +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index cb31b00a..706d8844 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -14,8 +14,8 @@ pub mod xdr; pub use decode::{ walk_diagnostic_events, AddressCredential, AddressWithNonce, ArgumentDecoder, AuthChain, AuthCredential, AuthFunctionKind, AuthInvocation, DecodedArgument, DecodedFunctionCall, - DiagnosticEventKind, DiagnosticEventWalker, FunctionCallDecoder, ReturnValueDecoder, - StructuredDiagnosticEvent, + DiagnosticEventKind, DiagnosticEventWalker, FunctionCallDecoder, MultiOpDecoder, + ReturnValueDecoder, StructuredDiagnosticEvent, }; pub use error::{GratError, GratResult}; pub use network::config::Network; diff --git a/crates/core/src/types/report.rs b/crates/core/src/types/report.rs index 14494850..6a5b8cbc 100644 --- a/crates/core/src/types/report.rs +++ b/crates/core/src/types/report.rs @@ -68,6 +68,12 @@ pub struct TransactionContext { pub fee: FeeBreakdown, pub resources: ResourceSummary, + + #[serde(skip_serializing_if = "Option::is_none", default)] + pub operation_index: Option, + + #[serde(skip_serializing_if = "Option::is_none", default)] + pub operation_count: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -135,15 +141,18 @@ pub struct DiagnosticReport { #[serde(skip_serializing_if = "Option::is_none", default)] pub failing_contract_id: Option, - /// Full reconstructed call chain at the point of failure, including - /// root-cause and fault-line identification. `None` when no diagnostic - /// events were present or no failure was detected in the chain. #[serde(skip_serializing_if = "Option::is_none", default)] pub call_chain: Option, #[serde(skip_serializing_if = "Option::is_none", default)] pub resource_diagnostics: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub operation_index: Option, + + #[serde(skip_serializing_if = "Option::is_none", default)] + pub operation_count: Option, + pub learn_more: String, } @@ -167,6 +176,8 @@ impl DiagnosticReport { failing_contract_id: None, call_chain: None, resource_diagnostics: None, + operation_index: None, + operation_count: None, learn_more: "https://developers.stellar.org/docs/learn/smart-contracts/errors" .to_string(), }