From 4185e7f4e8e90eeedbf77233c9ec3abfd029461c Mon Sep 17 00:00:00 2001 From: cybermax4200 Date: Thu, 28 May 2026 19:19:28 +0100 Subject: [PATCH] feat(core): extract error from failed transaction XDR (#193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add from_transaction_result() that navigates TransactionResult → TxFailed ops → InvokeHostFunctionResult and maps each failure variant to the correct ErrorCategory + code - Add NotSorobanTransaction and TransactionSucceeded variants to PrismError for invalid-input cases - Wire classify_error() to prefer XDR-based classification via from_transaction_result() when resultXdr is present - Add unit tests covering Trapped, ResourceLimitExceeded, EntryArchived, IHF success, TxSuccess, and missing IHF cases --- crates/core/src/decode/host_error.rs | 160 ++++++++++++++++++++++++++- crates/core/src/error.rs | 8 ++ 2 files changed, 162 insertions(+), 6 deletions(-) diff --git a/crates/core/src/decode/host_error.rs b/crates/core/src/decode/host_error.rs index ba7cb41a..4f91537a 100644 --- a/crates/core/src/decode/host_error.rs +++ b/crates/core/src/decode/host_error.rs @@ -3,8 +3,13 @@ //! Parses error category + code from TransactionResult XDR and classifies //! into known error families using the taxonomy database. -use crate::taxonomy::schema::ErrorCategory; use crate::error::{PrismError, PrismResult}; +use crate::taxonomy::schema::ErrorCategory; +use crate::xdr::codec::XdrCodec; +use stellar_xdr::curr::{ + InvokeHostFunctionResult, OperationResult, OperationResultTr, ScError, ScErrorCode, + TransactionResult, TransactionResultResult, +}; /// Classified error information extracted from a transaction result. #[derive(Debug, Clone)] @@ -21,10 +26,72 @@ pub struct ClassifiedError { pub raw_data: serde_json::Value, } +/// Extract a [`ClassifiedError`] from a decoded [`TransactionResult`] XDR. +/// +/// Navigates `TransactionResult → results → OperationResult::OpInner → +/// OperationResultTr::InvokeHostFunction → InvokeHostFunctionResult` and maps +/// the failure variant to the correct error category and code. +/// +/// Returns [`PrismError::TransactionSucceeded`] for a successful transaction and +/// [`PrismError::NotSorobanTransaction`] when no `InvokeHostFunction` operation +/// is present. +pub fn from_transaction_result(tx_result: TransactionResult) -> PrismResult { + let op_results = match tx_result.result { + TransactionResultResult::TxSuccess(_) => return Err(PrismError::TransactionSucceeded), + TransactionResultResult::TxFailed(ops) => ops, + TransactionResultResult::TxFeeBumpInnerSuccess(_) => { + return Err(PrismError::TransactionSucceeded) + } + // Any other top-level failure (TxTooEarly, TxBadSeq, etc.) has no + // InvokeHostFunction result to inspect. + _ => return Err(PrismError::NotSorobanTransaction), + }; + + // Find the first InvokeHostFunction operation result. + let ihf_result = op_results + .iter() + .find_map(|op| { + if let OperationResult::OpInner(OperationResultTr::InvokeHostFunction(r)) = op { + Some(r.clone()) + } else { + None + } + }) + .ok_or(PrismError::NotSorobanTransaction)?; + + // Map the InvokeHostFunctionResult variant to category + code. + // The ScError lives in the diagnostic events / meta; here we derive the + // category from the result code and use 0 as the code for non-contract + // errors (the taxonomy lookup uses category + code together). + let (category, error_code, is_contract_error) = match ihf_result { + InvokeHostFunctionResult::Success(_) => return Err(PrismError::TransactionSucceeded), + InvokeHostFunctionResult::Trapped => { + // Trapped means the host function raised an ScError; without the + // meta we cannot know the exact code, so we default to Contract/0 + // and let the caller enrich from diagnostic events. + (ErrorCategory::Contract, 0u32, false) + } + InvokeHostFunctionResult::ResourceLimitExceeded => (ErrorCategory::Budget, 0, false), + InvokeHostFunctionResult::EntryArchived => (ErrorCategory::Storage, 0, false), + InvokeHostFunctionResult::Malformed | InvokeHostFunctionResult::InsufficientRefundableFee => { + (ErrorCategory::Context, 0, false) + } + }; + + Ok(ClassifiedError { + category, + error_code, + is_contract_error, + contract_id: None, + raw_data: serde_json::Value::Null, + }) +} + /// Classify the error from a transaction result JSON. /// -/// Extracts the error category, code, and determines whether it's a host error -/// or a contract-defined error. +/// When the response contains a `resultXdr` field the XDR is decoded and +/// [`from_transaction_result`] is used for precise classification. Otherwise +/// the function falls back to inspecting the JSON status field. pub fn classify_error(tx_data: &serde_json::Value) -> PrismResult { let status = tx_data .get("status") @@ -32,11 +99,17 @@ pub fn classify_error(tx_data: &serde_json::Value) -> PrismResult Option { #[cfg(test)] mod tests { use super::*; + use stellar_xdr::curr::{ + Hash, InvokeHostFunctionResult, OperationResult, OperationResultTr, TransactionResult, + TransactionResultResult, VecM, + }; + + fn make_tx_result(op_result: InvokeHostFunctionResult) -> TransactionResult { + TransactionResult { + fee_charged: 100, + result: TransactionResultResult::TxFailed( + vec![OperationResult::OpInner( + OperationResultTr::InvokeHostFunction(op_result), + )] + .try_into() + .unwrap(), + ), + ext: stellar_xdr::curr::TransactionResultExt::V0, + } + } #[test] fn test_parse_error_category() { @@ -77,4 +168,61 @@ mod tests { ); assert_eq!(parse_error_category("unknown"), None); } + + #[test] + fn test_from_transaction_result_trapped() { + let result = make_tx_result(InvokeHostFunctionResult::Trapped); + let classified = from_transaction_result(result).unwrap(); + assert_eq!(classified.category, ErrorCategory::Contract); + assert!(!classified.is_contract_error); + } + + #[test] + fn test_from_transaction_result_resource_limit() { + let result = make_tx_result(InvokeHostFunctionResult::ResourceLimitExceeded); + let classified = from_transaction_result(result).unwrap(); + assert_eq!(classified.category, ErrorCategory::Budget); + } + + #[test] + fn test_from_transaction_result_entry_archived() { + let result = make_tx_result(InvokeHostFunctionResult::EntryArchived); + let classified = from_transaction_result(result).unwrap(); + assert_eq!(classified.category, ErrorCategory::Storage); + } + + #[test] + fn test_from_transaction_result_success_returns_error() { + let tx_result = TransactionResult { + fee_charged: 100, + result: TransactionResultResult::TxSuccess(vec![].try_into().unwrap()), + ext: stellar_xdr::curr::TransactionResultExt::V0, + }; + assert!(matches!( + from_transaction_result(tx_result), + Err(PrismError::TransactionSucceeded) + )); + } + + #[test] + fn test_from_transaction_result_no_ihf_returns_error() { + let tx_result = TransactionResult { + fee_charged: 100, + result: TransactionResultResult::TxFailed(vec![].try_into().unwrap()), + ext: stellar_xdr::curr::TransactionResultExt::V0, + }; + assert!(matches!( + from_transaction_result(tx_result), + Err(PrismError::NotSorobanTransaction) + )); + } + + #[test] + fn test_from_transaction_result_ihf_success_returns_error() { + let result = make_tx_result(InvokeHostFunctionResult::Success(Hash([0; 32]))); + assert!(matches!( + from_transaction_result(result), + Err(PrismError::TransactionSucceeded) + )); + } } diff --git a/crates/core/src/error.rs b/crates/core/src/error.rs index c9989067..4a155fa4 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -101,6 +101,14 @@ pub enum PrismError { /// Generic internal error. #[error("Internal error: {0}")] Internal(String), + + /// The transaction result XDR does not contain an InvokeHostFunction operation. + #[error("Not a Soroban transaction: no InvokeHostFunction operation found")] + NotSorobanTransaction, + + /// The transaction succeeded — there is no error to decode. + #[error("Transaction succeeded — no error to decode")] + TransactionSucceeded, } /// Convenience Result type for Prism operations.