44
55use chrono:: { DateTime , Utc } ;
66use 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} ;
1011use 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) ]
672793mod 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\n line\t with\r controls" ) ,
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