Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions src/domain/transaction/stellar/submit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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})"
)));
Expand Down Expand Up @@ -234,14 +243,22 @@ 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)
)))
}
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,
Expand Down
54 changes: 52 additions & 2 deletions src/domain/transaction/stellar/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -266,8 +266,18 @@ pub fn i64_from_u64(value: u64) -> Result<i64, RelayerError> {
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<String> {
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")
Expand Down Expand Up @@ -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::*;

Expand Down
9 changes: 9 additions & 0 deletions src/metrics/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading