diff --git a/src/domain/transaction/stellar/status.rs b/src/domain/transaction/stellar/status.rs index 9b8e58be9..ab895b8a9 100644 --- a/src/domain/transaction/stellar/status.rs +++ b/src/domain/transaction/stellar/status.rs @@ -4,8 +4,9 @@ use chrono::{DateTime, Utc}; use soroban_rs::xdr::{ - Error, Hash, InnerTransactionResultResult, InvokeHostFunctionResult, Limits, OperationResult, - OperationResultTr, TransactionEnvelope, TransactionResultResult, WriteXdr, + ContractEventBody, DiagnosticEvent, Error, Hash, InnerTransactionResultResult, + InvokeHostFunctionResult, Limits, OperationResult, OperationResultTr, ScVal, + TransactionEnvelope, TransactionResultResult, WriteXdr, }; use tracing::{debug, info, warn}; @@ -371,6 +372,7 @@ where let fee_charged = provider_response.result.as_ref().map(|r| r.fee_charged); let fee_bid = provider_response.envelope.as_ref().map(extract_fee_bid); + let contract_error = extract_contract_error(&provider_response.events.diagnostic_events); warn!( tx_id = %tx.id, @@ -381,11 +383,15 @@ where inner_fee_charged, fee_charged = ?fee_charged, fee_bid = ?fee_bid, + contract_error = contract_error.as_deref().unwrap_or("n/a"), "stellar transaction failed" ); - let status_reason = format!( - "Transaction failed on-chain. Provider status: FAILED. Specific XDR reason: {result_code}." + let status_reason = format_failure_reason( + result_code, + inner_result_code, + op_result_code, + contract_error.as_deref(), ); let update_request = TransactionUpdateRequest { @@ -668,6 +674,121 @@ fn first_failing_op(ops: &[OperationResult]) -> Option<&'static str> { } } +/// Builds the layered `status_reason` written for a failed Stellar +/// transaction. Each component is omitted when its source data is unavailable. +fn format_failure_reason( + outer: &str, + inner: Option<&str>, + op: Option<&str>, + contract_error: Option<&str>, +) -> String { + let mut s = format!("Transaction failed on-chain. reason={outer}"); + if let Some(inner) = inner { + s.push_str(" inner="); + s.push_str(inner); + } + if let Some(op) = op { + s.push_str(" op="); + s.push_str(op); + } + if let Some(ce) = contract_error { + s.push_str(" contract_error="); + s.push_str(ce); + } + s +} + +/// Returns a contract-level error from Soroban diagnostic events, rendered as +/// `"()"` with an optional ` message=""` when the same +/// event carries a sibling `ScVal::String` or `ScVal::Symbol`. Returns `None` +/// when no `ScVal::Error` is present. +fn extract_contract_error(events: &[DiagnosticEvent]) -> Option { + for evt in events { + let ContractEventBody::V0(body) = &evt.event.body; + let mut error_str: Option = None; + let mut message: Option = None; + for v in body.topics.iter().chain(std::iter::once(&body.data)) { + scan_scval(v, &mut error_str, &mut message); + if error_str.is_some() && message.is_some() { + break; + } + } + if let Some(err) = error_str { + return Some(match message { + Some(m) => format!("{err} message=\"{}\"", sanitize_message(&m)), + None => err, + }); + } + } + None +} + +fn scan_scval(v: &ScVal, error_str: &mut Option, message: &mut Option) { + match v { + ScVal::Error(e) => { + if error_str.is_none() { + let payload = match e { + soroban_rs::xdr::ScError::Contract(n) => n.to_string(), + soroban_rs::xdr::ScError::WasmVm(c) + | soroban_rs::xdr::ScError::Context(c) + | soroban_rs::xdr::ScError::Storage(c) + | soroban_rs::xdr::ScError::Object(c) + | soroban_rs::xdr::ScError::Crypto(c) + | soroban_rs::xdr::ScError::Events(c) + | soroban_rs::xdr::ScError::Budget(c) + | soroban_rs::xdr::ScError::Value(c) + | soroban_rs::xdr::ScError::Auth(c) => c.name().to_string(), + }; + *error_str = Some(format!("{}({payload})", e.name())); + } + } + ScVal::String(s) => { + if message.is_none() { + let bytes: &[u8] = s.as_ref(); + if let Ok(text) = std::str::from_utf8(bytes) { + if !text.is_empty() { + *message = Some(text.to_string()); + } + } + } + } + ScVal::Symbol(sym) => { + if message.is_none() { + let bytes: &[u8] = sym.as_ref(); + if let Ok(text) = std::str::from_utf8(bytes) { + // Skip the conventional "error" topic marker. + if !text.is_empty() && text != "error" { + *message = Some(text.to_string()); + } + } + } + } + ScVal::Vec(Some(items)) => { + for inner in items.iter() { + scan_scval(inner, error_str, message); + if error_str.is_some() && message.is_some() { + return; + } + } + } + _ => {} + } +} + +fn sanitize_message(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if c.is_control() { + continue; + } + if c == '"' { + out.push('\\'); + } + out.push(c); + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -1019,7 +1140,7 @@ mod tests { assert!(handled_tx.status_reason.is_some()); assert_eq!( handled_tx.status_reason.unwrap(), - "Transaction failed on-chain. Provider status: FAILED. Specific XDR reason: unknown." + "Transaction failed on-chain. reason=unknown" ); } @@ -2966,5 +3087,220 @@ mod tests { let ops: VecM = vec![OperationResult::OpBadAuth].try_into().unwrap(); assert_eq!(first_failing_op(ops.as_slice()), Some("OpBadAuth")); } + + #[test] + fn format_failure_reason_outer_only() { + let s = format_failure_reason("TxBadSeq", None, None, None); + assert_eq!(s, "Transaction failed on-chain. reason=TxBadSeq"); + assert!(!s.contains("inner=")); + assert!(!s.contains("op=")); + assert!(!s.contains("contract_error=")); + } + + #[test] + fn format_failure_reason_layers_inner_and_op() { + let s = format_failure_reason( + "TxFeeBumpInnerFailed", + Some("TxFailed"), + Some("Trapped"), + None, + ); + assert!(s.contains("reason=TxFeeBumpInnerFailed")); + assert!(s.contains("inner=TxFailed")); + assert!(s.contains("op=Trapped")); + assert!(!s.contains("contract_error=")); + } + + #[test] + fn format_failure_reason_classic_op_failure() { + let ops: VecM = vec![OperationResult::OpBadAuth].try_into().unwrap(); + let op = first_failing_op(ops.as_slice()); + let s = format_failure_reason("TxFailed", None, op, None); + assert!(s.contains("reason=TxFailed")); + assert!(s.contains("op=OpBadAuth")); + assert!(!s.contains("contract_error=")); + } + + fn make_diag_event(topics: Vec, data: ScVal) -> DiagnosticEvent { + use soroban_rs::xdr::{ + ContractEvent, ContractEventType, ContractEventV0, ExtensionPoint, + }; + DiagnosticEvent { + in_successful_contract_call: false, + event: ContractEvent { + ext: ExtensionPoint::V0, + contract_id: None, + type_: ContractEventType::Diagnostic, + body: ContractEventBody::V0(ContractEventV0 { + topics: topics.try_into().unwrap(), + data, + }), + }, + } + } + + #[test] + fn extract_contract_error_finds_sc_error() { + use soroban_rs::xdr::ScError; + let evt = make_diag_event(vec![], ScVal::Error(ScError::Contract(5))); + assert_eq!( + extract_contract_error(&[evt]), + Some("Contract(5)".to_string()) + ); + } + + #[test] + fn extract_contract_error_returns_none_for_no_error() { + assert_eq!(extract_contract_error(&[]), None); + let evt = make_diag_event( + vec![ScVal::Symbol("transfer".try_into().unwrap())], + ScVal::I32(42), + ); + assert_eq!(extract_contract_error(&[evt]), None); + } + + #[test] + fn extract_contract_error_finds_error_with_message() { + use soroban_rs::xdr::ScError; + let evt = make_diag_event( + vec![ + ScVal::Symbol("error".try_into().unwrap()), + ScVal::Error(ScError::Contract(5)), + ], + ScVal::String(soroban_rs::xdr::ScString( + "insufficient balance".try_into().unwrap(), + )), + ); + assert_eq!( + extract_contract_error(&[evt]), + Some("Contract(5) message=\"insufficient balance\"".to_string()) + ); + } + + #[test] + fn format_failure_reason_includes_contract_error_and_message() { + use soroban_rs::xdr::ScError; + let evt = make_diag_event( + vec![ + ScVal::Symbol("error".try_into().unwrap()), + ScVal::Error(ScError::Contract(5)), + ], + ScVal::String(soroban_rs::xdr::ScString( + "insufficient balance".try_into().unwrap(), + )), + ); + let ce = extract_contract_error(&[evt]); + let s = format_failure_reason( + "TxFeeBumpInnerFailed", + Some("TxFailed"), + Some("Trapped"), + ce.as_deref(), + ); + assert!(s.contains("reason=TxFeeBumpInnerFailed")); + assert!(s.contains("inner=TxFailed")); + assert!(s.contains("op=Trapped")); + assert!(s.contains("contract_error=Contract(5)")); + assert!(s.contains("message=\"insufficient balance\"")); + } + + #[test] + fn extract_contract_error_first_event_wins() { + use soroban_rs::xdr::ScError; + let no_error_evt = make_diag_event( + vec![ScVal::Symbol("fn_call".try_into().unwrap())], + ScVal::I32(7), + ); + let first_error_evt = make_diag_event( + vec![ + ScVal::Symbol("error".try_into().unwrap()), + ScVal::Error(ScError::Contract(1)), + ], + ScVal::Void, + ); + let second_error_evt = make_diag_event( + vec![ + ScVal::Symbol("error".try_into().unwrap()), + ScVal::Error(ScError::Contract(99)), + ], + ScVal::Void, + ); + assert_eq!( + extract_contract_error(&[no_error_evt, first_error_evt, second_error_evt]), + Some("Contract(1)".to_string()) + ); + } + + #[test] + fn extract_contract_error_renders_non_contract_error_types() { + use soroban_rs::xdr::{ScError, ScErrorCode}; + let evt = make_diag_event( + vec![], + ScVal::Error(ScError::Budget(ScErrorCode::ExceededLimit)), + ); + assert_eq!( + extract_contract_error(&[evt]), + Some("Budget(ExceededLimit)".to_string()) + ); + + let evt = make_diag_event( + vec![], + ScVal::Error(ScError::WasmVm(ScErrorCode::InvalidAction)), + ); + assert_eq!( + extract_contract_error(&[evt]), + Some("WasmVm(InvalidAction)".to_string()) + ); + } + + #[test] + fn extract_contract_error_finds_error_nested_in_vec() { + use soroban_rs::xdr::{ScError, ScVec}; + let nested: VecM = vec![ + ScVal::Symbol("inner".try_into().unwrap()), + ScVal::Error(ScError::Contract(42)), + ] + .try_into() + .unwrap(); + let evt = make_diag_event( + vec![ScVal::Symbol("error".try_into().unwrap())], + ScVal::Vec(Some(ScVec(nested))), + ); + assert_eq!( + extract_contract_error(&[evt]), + Some("Contract(42) message=\"inner\"".to_string()) + ); + } + + #[test] + fn sanitize_message_escapes_quotes_and_strips_control_chars() { + assert_eq!(sanitize_message("hello"), "hello"); + assert_eq!(sanitize_message(""), ""); + assert_eq!( + sanitize_message(r#"it has "quotes""#), + r#"it has \"quotes\""# + ); + assert_eq!( + sanitize_message("multi\nline\twith\rcontrols"), + "multilinewithcontrols" + ); + } + + #[test] + fn extract_contract_error_decodes_real_prod_xdr() { + // Captured 2026-05-06 from prod-mainnet (channels-fund, inner tx + // 0de7de8245c9b39ffab6282ea196e0be26b0875c0bf2431ff97affed9eccba9b), + // event[1] of the failure's diagnosticEventsXdr stream. Topics + // [Symbol("error"), Error(Contract)], data Vec[String, U32(8)]. + const PROD_EVENT_B64: &str = "AAAAAAAAAAAAAAAB1/5EvQrxHWArEJHy9KH03yEtRE0DIeoyrbPMHLurCgQAAAACAAAAAAAAAAIAAAAPAAAABWVycm9yAAAAAAAAAgAAAAAAAAAIAAAAEAAAAAEAAAACAAAADgAAABtmYWlsaW5nIHdpdGggY29udHJhY3QgZXJyb3IAAAAAAwAAAAg="; + let evt = ::from_xdr_base64( + PROD_EVENT_B64, + Limits::none(), + ) + .expect("real prod event should parse"); + assert_eq!( + extract_contract_error(&[evt]), + Some("Contract(8) message=\"failing with contract error\"".to_string()) + ); + } } }