From 3fd5181b1a2cae209c8cfd03d5a65f2c53310fc2 Mon Sep 17 00:00:00 2001 From: Zeljko Date: Fri, 20 Feb 2026 13:28:15 +0100 Subject: [PATCH] feat: Stellar submission failures metrics --- src/domain/transaction/stellar/submit.rs | 57 +++++++++++++++++++++--- src/domain/transaction/stellar/utils.rs | 54 +++++++++++++++++++++- src/metrics/mod.rs | 9 ++++ 3 files changed, 111 insertions(+), 9 deletions(-) diff --git a/src/domain/transaction/stellar/submit.rs b/src/domain/transaction/stellar/submit.rs index 2e53af2bf..847b95e84 100644 --- a/src/domain/transaction/stellar/submit.rs +++ b/src/domain/transaction/stellar/submit.rs @@ -5,9 +5,14 @@ use chrono::Utc; use tracing::{info, warn}; -use super::{is_final_state, utils::is_bad_sequence_error, StellarRelayerTransaction}; +use super::{ + is_final_state, + utils::{decode_tx_result_code, is_bad_sequence_error}, + StellarRelayerTransaction, +}; use crate::{ jobs::JobProducerTrait, + metrics::STELLAR_SUBMISSION_FAILURES, models::{ NetworkTransactionData, RelayerRepoModel, TransactionError, TransactionRepoModel, TransactionStatus, TransactionUpdateRequest, @@ -98,7 +103,12 @@ where .provider() .send_transaction_with_status(&tx_envelope) .await - .map_err(TransactionError::from)?; + .map_err(|e| { + STELLAR_SUBMISSION_FAILURES + .with_label_values(&["provider_error", "n/a"]) + .inc(); + TransactionError::from(e) + })?; // Handle status codes from the RPC response match response.status.as_str() { @@ -148,21 +158,42 @@ where } "TRY_AGAIN_LATER" => { // Transaction not queued - per acceptance criteria, mark as failed + STELLAR_SUBMISSION_FAILURES + .with_label_values(&["try_again_later", "n/a"]) + .inc(); Err(TransactionError::UnexpectedError( "Transaction not queued: TRY_AGAIN_LATER".to_string(), )) } "ERROR" => { - // Transaction validation failed - let error_detail = response - .error_result_xdr - .unwrap_or_else(|| "No error details provided".to_string()); + // Transaction validation failed — decode XDR for human-readable result + let error_xdr = response.error_result_xdr.unwrap_or_default(); + let result_code = if error_xdr.is_empty() { + None + } else { + decode_tx_result_code(&error_xdr) + }; + let result_code_label = result_code.as_deref().unwrap_or("unknown"); + STELLAR_SUBMISSION_FAILURES + .with_label_values(&["error", result_code_label]) + .inc(); + + let error_detail = if let Some(ref code) = result_code { + format!("{code} (xdr: {error_xdr})") + } else if error_xdr.is_empty() { + "No error details provided".to_string() + } else { + error_xdr + }; Err(TransactionError::UnexpectedError(format!( "Transaction submission error: {error_detail}" ))) } unknown => { // Unknown status - treat as error + STELLAR_SUBMISSION_FAILURES + .with_label_values(&["unknown_status", "n/a"]) + .inc(); warn!( tx_id = %tx.id, relayer_id = %tx.relayer_id, @@ -929,15 +960,27 @@ mod tests { #[tokio::test] async fn submit_transaction_error_status_fails() { + use soroban_rs::xdr::{ + Limits, TransactionResult, TransactionResultExt, TransactionResultResult, + }; + let relayer = create_test_relayer(); let mut mocks = default_test_mocks(); + // Build a TxInsufficientFee error XDR (non-bad-sequence, so no retry path) + let tx_result = TransactionResult { + fee_charged: 100, + result: TransactionResultResult::TxInsufficientFee, + ext: TransactionResultExt::V0, + }; + let error_xdr = tx_result.to_xdr_base64(Limits::none()).unwrap(); + // Provider returns ERROR status with error XDR let mut response = create_send_tx_response( "ERROR", "0101010101010101010101010101010101010101010101010101010101010101", ); - response.error_result_xdr = Some("AAAAAAAAAGT////7AAAAAA==".to_string()); + response.error_result_xdr = Some(error_xdr); mocks .provider .expect_send_transaction_with_status() diff --git a/src/domain/transaction/stellar/utils.rs b/src/domain/transaction/stellar/utils.rs index 7279f0cb6..d1bd75a09 100644 --- a/src/domain/transaction/stellar/utils.rs +++ b/src/domain/transaction/stellar/utils.rs @@ -13,7 +13,7 @@ use soroban_rs::xdr::{ AccountId, AlphaNum12, AlphaNum4, Asset, ChangeTrustAsset, ContractDataEntry, ContractId, Hash, LedgerEntryData, LedgerKey, LedgerKeyContractData, Limits, Operation, Preconditions, PublicKey as XdrPublicKey, ReadXdr, ScAddress, ScSymbol, ScVal, TimeBounds, TimePoint, - TransactionEnvelope, TransactionMeta, Uint256, VecM, + TransactionEnvelope, TransactionMeta, TransactionResult, Uint256, VecM, }; use std::str::FromStr; use stellar_strkey::ed25519::PublicKey; @@ -266,8 +266,18 @@ pub fn i64_from_u64(value: u64) -> Result { i64::try_from(value).map_err(|_| RelayerError::ProviderError("u64→i64 overflow".into())) } +/// Decodes a base64-encoded `TransactionResult` XDR into a human-readable result code name. +/// +/// Returns the variant name of the `TransactionResultResult` (e.g., `"TxBadSeq"`, +/// `"TxInsufficientBalance"`, `"TxFailed"`). Returns `None` if the XDR cannot be decoded. +pub fn decode_tx_result_code(error_result_xdr: &str) -> Option { + TransactionResult::from_xdr_base64(error_result_xdr, Limits::none()) + .ok() + .map(|r| r.result.name().to_string()) +} + /// Detects if an error is due to a bad sequence number. -/// Returns true if the error message contains indicators of sequence number mismatch. +/// Checks both string matching on the error message and XDR decoding when available. pub fn is_bad_sequence_error(error_msg: &str) -> bool { let error_lower = error_msg.to_lowercase(); error_lower.contains("txbadseq") @@ -1458,6 +1468,46 @@ mod tests { } } + mod decode_tx_result_code_tests { + use super::*; + use soroban_rs::xdr::{TransactionResult, TransactionResultResult, WriteXdr}; + + #[test] + fn test_decodes_tx_bad_seq() { + let result = TransactionResult { + fee_charged: 100, + result: TransactionResultResult::TxBadSeq, + ext: soroban_rs::xdr::TransactionResultExt::V0, + }; + let xdr = result.to_xdr_base64(Limits::none()).unwrap(); + assert_eq!(decode_tx_result_code(&xdr), Some("TxBadSeq".to_string())); + } + + #[test] + fn test_decodes_tx_insufficient_balance() { + let result = TransactionResult { + fee_charged: 100, + result: TransactionResultResult::TxInsufficientBalance, + ext: soroban_rs::xdr::TransactionResultExt::V0, + }; + let xdr = result.to_xdr_base64(Limits::none()).unwrap(); + assert_eq!( + decode_tx_result_code(&xdr), + Some("TxInsufficientBalance".to_string()) + ); + } + + #[test] + fn test_returns_none_for_invalid_xdr() { + assert_eq!(decode_tx_result_code("not-valid-xdr"), None); + } + + #[test] + fn test_returns_none_for_empty_string() { + assert_eq!(decode_tx_result_code(""), None); + } + } + mod status_check_utils_tests { use crate::models::{ NetworkTransactionData, StellarTransactionData, TransactionError, TransactionInput, diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs index 82ef54394..457b8c3df 100644 --- a/src/metrics/mod.rs +++ b/src/metrics/mod.rs @@ -199,6 +199,15 @@ lazy_static! { histogram_vec }; + // Counter for Stellar transaction submission failures with decoded result codes. + pub static ref STELLAR_SUBMISSION_FAILURES: CounterVec = { + let opts = Opts::new("stellar_submission_failures_total", + "Stellar transaction submission failures by status and result code"); + let counter_vec = CounterVec::new(opts, &["submit_status", "result_code"]).unwrap(); + REGISTRY.register(Box::new(counter_vec.clone())).unwrap(); + counter_vec + }; + // Counter for plugin calls (tracks requests to /api/v1/plugins/{plugin_id}/call endpoints). pub static ref PLUGIN_CALLS: CounterVec = { let opts = Opts::new("plugin_calls_total", "Total number of plugin calls");