diff --git a/crates/core/src/decode/host_error.rs b/crates/core/src/decode/host_error.rs index ee6b5280..69e052a3 100644 --- a/crates/core/src/decode/host_error.rs +++ b/crates/core/src/decode/host_error.rs @@ -127,6 +127,67 @@ 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. pub fn classify_error(tx_data: &serde_json::Value) -> PrismResult { let status = tx_data @@ -135,9 +196,7 @@ 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_category_name() { @@ -329,4 +406,61 @@ mod tests { ); } } + + #[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 dd0af97b..c458eacc 100644 --- a/crates/core/src/error.rs +++ b/crates/core/src/error.rs @@ -111,6 +111,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.