Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 137 additions & 3 deletions crates/core/src/decode/host_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ClassifiedError> {
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<ClassifiedError> {
let status = tx_data
Expand All @@ -135,9 +196,7 @@ pub fn classify_error(tx_data: &serde_json::Value) -> PrismResult<ClassifiedErro
.unwrap_or("UNKNOWN");

if status == "SUCCESS" {
return Err(PrismError::Internal(
"Transaction succeeded — no error to classify".to_string(),
));
return Err(PrismError::TransactionSucceeded);
}

Ok(ClassifiedError {
Expand Down Expand Up @@ -169,6 +228,24 @@ pub fn parse_error_category(category_str: &str) -> Option<ErrorCategory> {
#[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() {
Expand Down Expand Up @@ -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)
));
}
}
8 changes: 8 additions & 0 deletions crates/core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down