diff --git a/src/domain/transaction/stellar/submit.rs b/src/domain/transaction/stellar/submit.rs index e5ec4c8d4..c6f8a9bf5 100644 --- a/src/domain/transaction/stellar/submit.rs +++ b/src/domain/transaction/stellar/submit.rs @@ -13,6 +13,7 @@ use super::{ use crate::{ constants::STELLAR_INSUFFICIENT_FEE_MAX_RETRIES, jobs::JobProducerTrait, + metrics::STELLAR_SUBMISSION_FAILURES, models::{ NetworkTransactionData, RelayerRepoModel, TransactionError, TransactionMetadata, TransactionRepoModel, TransactionStatus, TransactionUpdateRequest, @@ -105,7 +106,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() { @@ -200,6 +206,9 @@ where .saturating_add(1); if insufficient_fee_retries > STELLAR_INSUFFICIENT_FEE_MAX_RETRIES { + STELLAR_SUBMISSION_FAILURES + .with_label_values(&["error", "tx_insufficient_fee"]) + .inc(); return Err(TransactionError::UnexpectedError(format!( "Transaction submission error: insufficient fee retry limit exceeded ({STELLAR_INSUFFICIENT_FEE_MAX_RETRIES})" ))); @@ -234,7 +243,12 @@ where .await?; return Ok(updated_tx); } - + STELLAR_SUBMISSION_FAILURES + .with_label_values(&[ + "error", + decoded_result_code.as_deref().unwrap_or("unknown"), + ]) + .inc(); Err(TransactionError::UnexpectedError(format!( "Transaction submission error: {}", decoded_result_code.unwrap_or(error_detail) @@ -242,6 +256,9 @@ where } 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, diff --git a/src/domain/transaction/stellar/utils.rs b/src/domain/transaction/stellar/utils.rs index 7c6532ad6..9fa614974 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") @@ -1475,6 +1485,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 is_insufficient_fee_error_tests { use super::*; diff --git a/src/metrics/mod.rs b/src/metrics/mod.rs index d0941ce98..c6a39bfaa 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");