Skip to content

Commit 4bfd68d

Browse files
feat(stellar): Structure failed tx status_reason (#765)
1 parent 2c785fd commit 4bfd68d

1 file changed

Lines changed: 341 additions & 5 deletions

File tree

src/domain/transaction/stellar/status.rs

Lines changed: 341 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
55
use chrono::{DateTime, Utc};
66
use soroban_rs::xdr::{
7-
Error, Hash, InnerTransactionResultResult, InvokeHostFunctionResult, Limits, OperationResult,
8-
OperationResultTr, TransactionEnvelope, TransactionResultResult, WriteXdr,
7+
ContractEventBody, DiagnosticEvent, Error, Hash, InnerTransactionResultResult,
8+
InvokeHostFunctionResult, Limits, OperationResult, OperationResultTr, ScVal,
9+
TransactionEnvelope, TransactionResultResult, WriteXdr,
910
};
1011
use tracing::{debug, info, warn};
1112

@@ -371,6 +372,7 @@ where
371372

372373
let fee_charged = provider_response.result.as_ref().map(|r| r.fee_charged);
373374
let fee_bid = provider_response.envelope.as_ref().map(extract_fee_bid);
375+
let contract_error = extract_contract_error(&provider_response.events.diagnostic_events);
374376

375377
warn!(
376378
tx_id = %tx.id,
@@ -381,11 +383,15 @@ where
381383
inner_fee_charged,
382384
fee_charged = ?fee_charged,
383385
fee_bid = ?fee_bid,
386+
contract_error = contract_error.as_deref().unwrap_or("n/a"),
384387
"stellar transaction failed"
385388
);
386389

387-
let status_reason = format!(
388-
"Transaction failed on-chain. Provider status: FAILED. Specific XDR reason: {result_code}."
390+
let status_reason = format_failure_reason(
391+
result_code,
392+
inner_result_code,
393+
op_result_code,
394+
contract_error.as_deref(),
389395
);
390396

391397
let update_request = TransactionUpdateRequest {
@@ -668,6 +674,121 @@ fn first_failing_op(ops: &[OperationResult]) -> Option<&'static str> {
668674
}
669675
}
670676

677+
/// Builds the layered `status_reason` written for a failed Stellar
678+
/// transaction. Each component is omitted when its source data is unavailable.
679+
fn format_failure_reason(
680+
outer: &str,
681+
inner: Option<&str>,
682+
op: Option<&str>,
683+
contract_error: Option<&str>,
684+
) -> String {
685+
let mut s = format!("Transaction failed on-chain. reason={outer}");
686+
if let Some(inner) = inner {
687+
s.push_str(" inner=");
688+
s.push_str(inner);
689+
}
690+
if let Some(op) = op {
691+
s.push_str(" op=");
692+
s.push_str(op);
693+
}
694+
if let Some(ce) = contract_error {
695+
s.push_str(" contract_error=");
696+
s.push_str(ce);
697+
}
698+
s
699+
}
700+
701+
/// Returns a contract-level error from Soroban diagnostic events, rendered as
702+
/// `"<TypeName>(<code>)"` with an optional ` message="<text>"` when the same
703+
/// event carries a sibling `ScVal::String` or `ScVal::Symbol`. Returns `None`
704+
/// when no `ScVal::Error` is present.
705+
fn extract_contract_error(events: &[DiagnosticEvent]) -> Option<String> {
706+
for evt in events {
707+
let ContractEventBody::V0(body) = &evt.event.body;
708+
let mut error_str: Option<String> = None;
709+
let mut message: Option<String> = None;
710+
for v in body.topics.iter().chain(std::iter::once(&body.data)) {
711+
scan_scval(v, &mut error_str, &mut message);
712+
if error_str.is_some() && message.is_some() {
713+
break;
714+
}
715+
}
716+
if let Some(err) = error_str {
717+
return Some(match message {
718+
Some(m) => format!("{err} message=\"{}\"", sanitize_message(&m)),
719+
None => err,
720+
});
721+
}
722+
}
723+
None
724+
}
725+
726+
fn scan_scval(v: &ScVal, error_str: &mut Option<String>, message: &mut Option<String>) {
727+
match v {
728+
ScVal::Error(e) => {
729+
if error_str.is_none() {
730+
let payload = match e {
731+
soroban_rs::xdr::ScError::Contract(n) => n.to_string(),
732+
soroban_rs::xdr::ScError::WasmVm(c)
733+
| soroban_rs::xdr::ScError::Context(c)
734+
| soroban_rs::xdr::ScError::Storage(c)
735+
| soroban_rs::xdr::ScError::Object(c)
736+
| soroban_rs::xdr::ScError::Crypto(c)
737+
| soroban_rs::xdr::ScError::Events(c)
738+
| soroban_rs::xdr::ScError::Budget(c)
739+
| soroban_rs::xdr::ScError::Value(c)
740+
| soroban_rs::xdr::ScError::Auth(c) => c.name().to_string(),
741+
};
742+
*error_str = Some(format!("{}({payload})", e.name()));
743+
}
744+
}
745+
ScVal::String(s) => {
746+
if message.is_none() {
747+
let bytes: &[u8] = s.as_ref();
748+
if let Ok(text) = std::str::from_utf8(bytes) {
749+
if !text.is_empty() {
750+
*message = Some(text.to_string());
751+
}
752+
}
753+
}
754+
}
755+
ScVal::Symbol(sym) => {
756+
if message.is_none() {
757+
let bytes: &[u8] = sym.as_ref();
758+
if let Ok(text) = std::str::from_utf8(bytes) {
759+
// Skip the conventional "error" topic marker.
760+
if !text.is_empty() && text != "error" {
761+
*message = Some(text.to_string());
762+
}
763+
}
764+
}
765+
}
766+
ScVal::Vec(Some(items)) => {
767+
for inner in items.iter() {
768+
scan_scval(inner, error_str, message);
769+
if error_str.is_some() && message.is_some() {
770+
return;
771+
}
772+
}
773+
}
774+
_ => {}
775+
}
776+
}
777+
778+
fn sanitize_message(s: &str) -> String {
779+
let mut out = String::with_capacity(s.len());
780+
for c in s.chars() {
781+
if c.is_control() {
782+
continue;
783+
}
784+
if c == '"' {
785+
out.push('\\');
786+
}
787+
out.push(c);
788+
}
789+
out
790+
}
791+
671792
#[cfg(test)]
672793
mod tests {
673794
use super::*;
@@ -1019,7 +1140,7 @@ mod tests {
10191140
assert!(handled_tx.status_reason.is_some());
10201141
assert_eq!(
10211142
handled_tx.status_reason.unwrap(),
1022-
"Transaction failed on-chain. Provider status: FAILED. Specific XDR reason: unknown."
1143+
"Transaction failed on-chain. reason=unknown"
10231144
);
10241145
}
10251146

@@ -2966,5 +3087,220 @@ mod tests {
29663087
let ops: VecM<OperationResult> = vec![OperationResult::OpBadAuth].try_into().unwrap();
29673088
assert_eq!(first_failing_op(ops.as_slice()), Some("OpBadAuth"));
29683089
}
3090+
3091+
#[test]
3092+
fn format_failure_reason_outer_only() {
3093+
let s = format_failure_reason("TxBadSeq", None, None, None);
3094+
assert_eq!(s, "Transaction failed on-chain. reason=TxBadSeq");
3095+
assert!(!s.contains("inner="));
3096+
assert!(!s.contains("op="));
3097+
assert!(!s.contains("contract_error="));
3098+
}
3099+
3100+
#[test]
3101+
fn format_failure_reason_layers_inner_and_op() {
3102+
let s = format_failure_reason(
3103+
"TxFeeBumpInnerFailed",
3104+
Some("TxFailed"),
3105+
Some("Trapped"),
3106+
None,
3107+
);
3108+
assert!(s.contains("reason=TxFeeBumpInnerFailed"));
3109+
assert!(s.contains("inner=TxFailed"));
3110+
assert!(s.contains("op=Trapped"));
3111+
assert!(!s.contains("contract_error="));
3112+
}
3113+
3114+
#[test]
3115+
fn format_failure_reason_classic_op_failure() {
3116+
let ops: VecM<OperationResult> = vec![OperationResult::OpBadAuth].try_into().unwrap();
3117+
let op = first_failing_op(ops.as_slice());
3118+
let s = format_failure_reason("TxFailed", None, op, None);
3119+
assert!(s.contains("reason=TxFailed"));
3120+
assert!(s.contains("op=OpBadAuth"));
3121+
assert!(!s.contains("contract_error="));
3122+
}
3123+
3124+
fn make_diag_event(topics: Vec<ScVal>, data: ScVal) -> DiagnosticEvent {
3125+
use soroban_rs::xdr::{
3126+
ContractEvent, ContractEventType, ContractEventV0, ExtensionPoint,
3127+
};
3128+
DiagnosticEvent {
3129+
in_successful_contract_call: false,
3130+
event: ContractEvent {
3131+
ext: ExtensionPoint::V0,
3132+
contract_id: None,
3133+
type_: ContractEventType::Diagnostic,
3134+
body: ContractEventBody::V0(ContractEventV0 {
3135+
topics: topics.try_into().unwrap(),
3136+
data,
3137+
}),
3138+
},
3139+
}
3140+
}
3141+
3142+
#[test]
3143+
fn extract_contract_error_finds_sc_error() {
3144+
use soroban_rs::xdr::ScError;
3145+
let evt = make_diag_event(vec![], ScVal::Error(ScError::Contract(5)));
3146+
assert_eq!(
3147+
extract_contract_error(&[evt]),
3148+
Some("Contract(5)".to_string())
3149+
);
3150+
}
3151+
3152+
#[test]
3153+
fn extract_contract_error_returns_none_for_no_error() {
3154+
assert_eq!(extract_contract_error(&[]), None);
3155+
let evt = make_diag_event(
3156+
vec![ScVal::Symbol("transfer".try_into().unwrap())],
3157+
ScVal::I32(42),
3158+
);
3159+
assert_eq!(extract_contract_error(&[evt]), None);
3160+
}
3161+
3162+
#[test]
3163+
fn extract_contract_error_finds_error_with_message() {
3164+
use soroban_rs::xdr::ScError;
3165+
let evt = make_diag_event(
3166+
vec![
3167+
ScVal::Symbol("error".try_into().unwrap()),
3168+
ScVal::Error(ScError::Contract(5)),
3169+
],
3170+
ScVal::String(soroban_rs::xdr::ScString(
3171+
"insufficient balance".try_into().unwrap(),
3172+
)),
3173+
);
3174+
assert_eq!(
3175+
extract_contract_error(&[evt]),
3176+
Some("Contract(5) message=\"insufficient balance\"".to_string())
3177+
);
3178+
}
3179+
3180+
#[test]
3181+
fn format_failure_reason_includes_contract_error_and_message() {
3182+
use soroban_rs::xdr::ScError;
3183+
let evt = make_diag_event(
3184+
vec![
3185+
ScVal::Symbol("error".try_into().unwrap()),
3186+
ScVal::Error(ScError::Contract(5)),
3187+
],
3188+
ScVal::String(soroban_rs::xdr::ScString(
3189+
"insufficient balance".try_into().unwrap(),
3190+
)),
3191+
);
3192+
let ce = extract_contract_error(&[evt]);
3193+
let s = format_failure_reason(
3194+
"TxFeeBumpInnerFailed",
3195+
Some("TxFailed"),
3196+
Some("Trapped"),
3197+
ce.as_deref(),
3198+
);
3199+
assert!(s.contains("reason=TxFeeBumpInnerFailed"));
3200+
assert!(s.contains("inner=TxFailed"));
3201+
assert!(s.contains("op=Trapped"));
3202+
assert!(s.contains("contract_error=Contract(5)"));
3203+
assert!(s.contains("message=\"insufficient balance\""));
3204+
}
3205+
3206+
#[test]
3207+
fn extract_contract_error_first_event_wins() {
3208+
use soroban_rs::xdr::ScError;
3209+
let no_error_evt = make_diag_event(
3210+
vec![ScVal::Symbol("fn_call".try_into().unwrap())],
3211+
ScVal::I32(7),
3212+
);
3213+
let first_error_evt = make_diag_event(
3214+
vec![
3215+
ScVal::Symbol("error".try_into().unwrap()),
3216+
ScVal::Error(ScError::Contract(1)),
3217+
],
3218+
ScVal::Void,
3219+
);
3220+
let second_error_evt = make_diag_event(
3221+
vec![
3222+
ScVal::Symbol("error".try_into().unwrap()),
3223+
ScVal::Error(ScError::Contract(99)),
3224+
],
3225+
ScVal::Void,
3226+
);
3227+
assert_eq!(
3228+
extract_contract_error(&[no_error_evt, first_error_evt, second_error_evt]),
3229+
Some("Contract(1)".to_string())
3230+
);
3231+
}
3232+
3233+
#[test]
3234+
fn extract_contract_error_renders_non_contract_error_types() {
3235+
use soroban_rs::xdr::{ScError, ScErrorCode};
3236+
let evt = make_diag_event(
3237+
vec![],
3238+
ScVal::Error(ScError::Budget(ScErrorCode::ExceededLimit)),
3239+
);
3240+
assert_eq!(
3241+
extract_contract_error(&[evt]),
3242+
Some("Budget(ExceededLimit)".to_string())
3243+
);
3244+
3245+
let evt = make_diag_event(
3246+
vec![],
3247+
ScVal::Error(ScError::WasmVm(ScErrorCode::InvalidAction)),
3248+
);
3249+
assert_eq!(
3250+
extract_contract_error(&[evt]),
3251+
Some("WasmVm(InvalidAction)".to_string())
3252+
);
3253+
}
3254+
3255+
#[test]
3256+
fn extract_contract_error_finds_error_nested_in_vec() {
3257+
use soroban_rs::xdr::{ScError, ScVec};
3258+
let nested: VecM<ScVal> = vec![
3259+
ScVal::Symbol("inner".try_into().unwrap()),
3260+
ScVal::Error(ScError::Contract(42)),
3261+
]
3262+
.try_into()
3263+
.unwrap();
3264+
let evt = make_diag_event(
3265+
vec![ScVal::Symbol("error".try_into().unwrap())],
3266+
ScVal::Vec(Some(ScVec(nested))),
3267+
);
3268+
assert_eq!(
3269+
extract_contract_error(&[evt]),
3270+
Some("Contract(42) message=\"inner\"".to_string())
3271+
);
3272+
}
3273+
3274+
#[test]
3275+
fn sanitize_message_escapes_quotes_and_strips_control_chars() {
3276+
assert_eq!(sanitize_message("hello"), "hello");
3277+
assert_eq!(sanitize_message(""), "");
3278+
assert_eq!(
3279+
sanitize_message(r#"it has "quotes""#),
3280+
r#"it has \"quotes\""#
3281+
);
3282+
assert_eq!(
3283+
sanitize_message("multi\nline\twith\rcontrols"),
3284+
"multilinewithcontrols"
3285+
);
3286+
}
3287+
3288+
#[test]
3289+
fn extract_contract_error_decodes_real_prod_xdr() {
3290+
// Captured 2026-05-06 from prod-mainnet (channels-fund, inner tx
3291+
// 0de7de8245c9b39ffab6282ea196e0be26b0875c0bf2431ff97affed9eccba9b),
3292+
// event[1] of the failure's diagnosticEventsXdr stream. Topics
3293+
// [Symbol("error"), Error(Contract)], data Vec[String, U32(8)].
3294+
const PROD_EVENT_B64: &str = "AAAAAAAAAAAAAAAB1/5EvQrxHWArEJHy9KH03yEtRE0DIeoyrbPMHLurCgQAAAACAAAAAAAAAAIAAAAPAAAABWVycm9yAAAAAAAAAgAAAAAAAAAIAAAAEAAAAAEAAAACAAAADgAAABtmYWlsaW5nIHdpdGggY29udHJhY3QgZXJyb3IAAAAAAwAAAAg=";
3295+
let evt = <DiagnosticEvent as soroban_rs::xdr::ReadXdr>::from_xdr_base64(
3296+
PROD_EVENT_B64,
3297+
Limits::none(),
3298+
)
3299+
.expect("real prod event should parse");
3300+
assert_eq!(
3301+
extract_contract_error(&[evt]),
3302+
Some("Contract(8) message=\"failing with contract error\"".to_string())
3303+
);
3304+
}
29693305
}
29703306
}

0 commit comments

Comments
 (0)