diff --git a/src/constants/stellar_transaction.rs b/src/constants/stellar_transaction.rs index 813636d9c..8097ee344 100644 --- a/src/constants/stellar_transaction.rs +++ b/src/constants/stellar_transaction.rs @@ -67,16 +67,20 @@ pub fn get_stellar_sponsored_transaction_validity_duration() -> Duration { pub const STELLAR_MAX_STUCK_TRANSACTION_LIFETIME_MINUTES: i64 = 15; /// Base interval (seconds) for resubmitting a Submitted transaction. -/// Stellar Core retries internally for ~3 ledgers (~15s). We start resubmitting at 15s +/// Stellar Core retries internally for ~2 ledgers (~10s). We start resubmitting at 10s /// to ensure the transaction is back in the mempool before Core's window closes. -pub const STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS: i64 = 15; +pub const STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS: i64 = 10; /// Maximum number of times a Stellar submission may be retried after an insufficient-fee error. pub const STELLAR_INSUFFICIENT_FEE_MAX_RETRIES: u32 = 2; -/// Maximum resubmit interval (seconds) to cap exponential backoff. +/// Maximum resubmit interval (seconds) to cap the resubmission backoff. /// Prevents excessively long gaps between resubmissions. -pub const STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS: i64 = 180; +pub const STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS: i64 = 120; + +/// Growth factor for resubmit backoff. Each tier multiplies the interval by this factor. +/// With base=10 and factor=1.5: 10s → 15s → 22s → 33s → 50s → 75s → 113s → 120s (capped). +pub const STELLAR_RESUBMIT_GROWTH_FACTOR: f64 = 1.5; /// Get base resubmit interval for Submitted transactions pub fn get_stellar_resubmit_base_interval() -> Duration { diff --git a/src/domain/transaction/stellar/status.rs b/src/domain/transaction/stellar/status.rs index d6d1379b5..9b8e58be9 100644 --- a/src/domain/transaction/stellar/status.rs +++ b/src/domain/transaction/stellar/status.rs @@ -12,7 +12,7 @@ use tracing::{debug, info, warn}; use super::{is_final_state, StellarRelayerTransaction}; use crate::constants::{ get_stellar_max_stuck_transaction_lifetime, STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS, - STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS, + STELLAR_RESUBMIT_GROWTH_FACTOR, STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS, }; use crate::domain::transaction::stellar::prepare::common::send_submit_transaction_job; use crate::domain::transaction::stellar::utils::{ @@ -451,14 +451,15 @@ where return result; } - // Resubmit with exponential backoff based on total transaction age. + // Resubmit with backoff based on total transaction age. // Uses the same backoff logic as the Submitted state handler: - // 15s → 30s → 60s → 120s → 180s (capped). + // 10s → 15s → 22s → 33s → 50s → 75s → 113s → 120s (capped). let total_age = get_age_since_created(&tx)?; if let Some(backoff_interval) = compute_resubmit_backoff_interval( total_age, STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS, STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS, + STELLAR_RESUBMIT_GROWTH_FACTOR, ) { let age_since_last_submit = get_age_since_sent_or_created(&tx)?; if age_since_last_submit > backoff_interval { @@ -599,13 +600,14 @@ where return result; } - // Resubmit with exponential backoff based on total transaction age. - // The backoff interval grows: 15s → 30s → 60s → 120s → 180s (capped). + // Resubmit with backoff based on total transaction age. + // The backoff interval grows: 10s → 15s → 22s → 33s → 50s → 75s → 113s → 120s (capped). let total_age = get_age_since_created(&tx)?; if let Some(backoff_interval) = compute_resubmit_backoff_interval( total_age, STELLAR_RESUBMIT_BASE_INTERVAL_SECONDS, STELLAR_RESUBMIT_MAX_INTERVAL_SECONDS, + STELLAR_RESUBMIT_GROWTH_FACTOR, ) { let age_since_last_submit = get_age_since_sent_or_created(&tx)?; if age_since_last_submit > backoff_interval { @@ -2296,9 +2298,9 @@ mod tests { #[tokio::test] async fn test_handle_submitted_state_backoff_resubmits_when_interval_exceeded() { - // Transaction created 25s ago, sent_at 21s ago. - // At total_age=25s, backoff interval = 15s (base*2^0, since 25/15=1, log2(1)=0). - // age_since_last_submit=21s > 15s → should resubmit. + // Transaction created 25s ago, sent_at 25s ago. + // At total_age=25s with base=10, factor=1.5: interval = 22s (third tier). + // age_since_last_submit=25s > 22s → should resubmit. let relayer = create_test_relayer(); let mut mocks = default_test_mocks(); @@ -2306,7 +2308,7 @@ mod tests { tx.id = "tx-submitted-backoff-resubmit".to_string(); tx.status = TransactionStatus::Submitted; tx.created_at = (Utc::now() - Duration::seconds(25)).to_rfc3339(); - tx.sent_at = Some((Utc::now() - Duration::seconds(21)).to_rfc3339()); + tx.sent_at = Some((Utc::now() - Duration::seconds(25)).to_rfc3339()); let tx_hash_bytes = [12u8; 32]; if let NetworkTransactionData::Stellar(ref mut stellar_data) = tx.network_data { stellar_data.hash = Some(hex::encode(tx_hash_bytes)); @@ -2323,7 +2325,7 @@ mod tests { Box::pin(async { Ok(dummy_get_transaction_response("PENDING")) }) }); - // Should resubmit (21s > 15s backoff interval) + // Should resubmit (25s > 22s backoff interval) mocks .job_producer .expect_produce_submit_transaction_job() @@ -2341,8 +2343,8 @@ mod tests { #[tokio::test] async fn test_handle_submitted_state_recent_sent_at_prevents_resubmit() { // Transaction created 60s ago (old), but sent_at only 5s ago (recent resubmission). - // At total_age=60s, backoff interval = 60s (base*2^2, since 60/15=4, log2(4)=2). - // age_since_last_submit=5s < 60s → should NOT resubmit. + // At total_age=60s with base=10, factor=1.5: interval = 50s (fifth tier). + // age_since_last_submit=5s < 50s → should NOT resubmit. // This verifies that sent_at being updated on resubmission correctly resets the clock. let relayer = create_test_relayer(); let mut mocks = default_test_mocks(); diff --git a/src/domain/transaction/stellar/utils.rs b/src/domain/transaction/stellar/utils.rs index 9fa614974..f2b34ef62 100644 --- a/src/domain/transaction/stellar/utils.rs +++ b/src/domain/transaction/stellar/utils.rs @@ -1301,33 +1301,49 @@ pub fn asset_to_asset_id(asset: &Asset) -> Result Option { let age_secs = total_age.num_seconds(); - if age_secs < base_interval_secs { + if age_secs < base_interval_secs || base_interval_secs <= 0 || max_interval_secs <= 0 { return None; } - // n = floor(log2(age / base)), so interval = base * 2^n - let ratio = age_secs / base_interval_secs; // >= 1 - let n = (ratio as u64).ilog2(); // floor(log2(ratio)) - let interval = base_interval_secs.saturating_mul(1_i64.wrapping_shl(n)); - let capped = interval.min(max_interval_secs); + // Guard: factor must be > 1.0 to produce growth; fall back to min(base, max). + if growth_factor <= 1.0 { + return Some(chrono::Duration::seconds( + base_interval_secs.min(max_interval_secs), + )); + } + + // Each tier boundary = previous boundary × growth_factor. + // The interval at each tier = previous interval × growth_factor, capped at max. + let mut interval = base_interval_secs as f64; + let mut tier_end = base_interval_secs as f64 * growth_factor; + while tier_end <= age_secs as f64 { + interval = (interval * growth_factor).min(max_interval_secs as f64); + tier_end *= growth_factor; + } + + let capped = (interval as i64).min(max_interval_secs); Some(chrono::Duration::seconds(capped)) } @@ -3768,76 +3784,116 @@ mod compute_resubmit_backoff_interval_tests { const BASE: i64 = 10; const MAX: i64 = 120; + const FACTOR: f64 = 1.5; #[test] fn returns_none_below_base() { - assert!(compute_resubmit_backoff_interval(Duration::seconds(0), BASE, MAX).is_none()); - assert!(compute_resubmit_backoff_interval(Duration::seconds(5), BASE, MAX).is_none()); - assert!(compute_resubmit_backoff_interval(Duration::seconds(9), BASE, MAX).is_none()); + assert!( + compute_resubmit_backoff_interval(Duration::seconds(0), BASE, MAX, FACTOR).is_none() + ); + assert!( + compute_resubmit_backoff_interval(Duration::seconds(5), BASE, MAX, FACTOR).is_none() + ); + assert!( + compute_resubmit_backoff_interval(Duration::seconds(9), BASE, MAX, FACTOR).is_none() + ); } #[test] - fn base_interval_at_1x() { - // age 10-19s: ratio=1, log2(1)=0, interval = 10 * 2^0 = 10s + fn base_interval_at_first_tier() { + // age 10-14s: interval = 10s (tier boundary at 10 * 1.5 = 15) assert_eq!( - compute_resubmit_backoff_interval(Duration::seconds(10), BASE, MAX), + compute_resubmit_backoff_interval(Duration::seconds(10), BASE, MAX, FACTOR), Some(Duration::seconds(10)) ); assert_eq!( - compute_resubmit_backoff_interval(Duration::seconds(19), BASE, MAX), + compute_resubmit_backoff_interval(Duration::seconds(14), BASE, MAX, FACTOR), Some(Duration::seconds(10)) ); } #[test] - fn doubles_at_2x() { - // age 20-39s: ratio=2-3, log2(2)=1, interval = 10 * 2^1 = 20s + fn grows_by_factor_at_second_tier() { + // age 15-21s: interval = 10 * 1.5 = 15s (tier boundary at 15 * 1.5 = 22.5) assert_eq!( - compute_resubmit_backoff_interval(Duration::seconds(20), BASE, MAX), - Some(Duration::seconds(20)) + compute_resubmit_backoff_interval(Duration::seconds(15), BASE, MAX, FACTOR), + Some(Duration::seconds(15)) ); assert_eq!( - compute_resubmit_backoff_interval(Duration::seconds(39), BASE, MAX), - Some(Duration::seconds(20)) + compute_resubmit_backoff_interval(Duration::seconds(22), BASE, MAX, FACTOR), + Some(Duration::seconds(15)) ); } #[test] - fn quadruples_at_4x() { - // age 40-79s: ratio=4-7, log2(4)=2, interval = 10 * 2^2 = 40s + fn grows_by_factor_squared_at_third_tier() { + // age 23-33s: interval = 10 * 1.5^2 = 22.5 ≈ 22s (tier boundary at 22.5 * 1.5 = 33.75) assert_eq!( - compute_resubmit_backoff_interval(Duration::seconds(40), BASE, MAX), - Some(Duration::seconds(40)) + compute_resubmit_backoff_interval(Duration::seconds(23), BASE, MAX, FACTOR), + Some(Duration::seconds(22)) ); assert_eq!( - compute_resubmit_backoff_interval(Duration::seconds(79), BASE, MAX), - Some(Duration::seconds(40)) + compute_resubmit_backoff_interval(Duration::seconds(33), BASE, MAX, FACTOR), + Some(Duration::seconds(22)) ); } #[test] - fn interval_at_8x() { - // age 80-119s: ratio=8-11, log2(8)=3, interval = 10 * 2^3 = 80s + fn fourth_tier() { + // age 34-50s: interval = 10 * 1.5^3 = 33.75 → 33s (truncated) assert_eq!( - compute_resubmit_backoff_interval(Duration::seconds(80), BASE, MAX), - Some(Duration::seconds(80)) + compute_resubmit_backoff_interval(Duration::seconds(34), BASE, MAX, FACTOR), + Some(Duration::seconds(33)) ); assert_eq!( - compute_resubmit_backoff_interval(Duration::seconds(119), BASE, MAX), - Some(Duration::seconds(80)) + compute_resubmit_backoff_interval(Duration::seconds(50), BASE, MAX, FACTOR), + Some(Duration::seconds(33)) ); } #[test] fn capped_at_max() { - // age 160s: ratio=16, log2(16)=4, interval = 10*16 = 160 → capped at 120s + // At high ages the interval should be capped at MAX (120s) + assert_eq!( + compute_resubmit_backoff_interval(Duration::seconds(300), BASE, MAX, FACTOR), + Some(Duration::seconds(MAX)) + ); assert_eq!( - compute_resubmit_backoff_interval(Duration::seconds(160), BASE, MAX), + compute_resubmit_backoff_interval(Duration::seconds(1000), BASE, MAX, FACTOR), Some(Duration::seconds(MAX)) ); - // age 1280s: ratio=128, log2(128)=7, interval = 10*128 = 1280 → capped at 120s + } + + #[test] + fn works_with_factor_2_doubling() { + // With factor=2.0, behavior matches classic doubling: 10 → 20 → 40 → 80 → 120 + let factor = 2.0; + assert_eq!( + compute_resubmit_backoff_interval(Duration::seconds(10), BASE, MAX, factor), + Some(Duration::seconds(10)) + ); + assert_eq!( + compute_resubmit_backoff_interval(Duration::seconds(19), BASE, MAX, factor), + Some(Duration::seconds(10)) + ); + assert_eq!( + compute_resubmit_backoff_interval(Duration::seconds(20), BASE, MAX, factor), + Some(Duration::seconds(20)) + ); + assert_eq!( + compute_resubmit_backoff_interval(Duration::seconds(39), BASE, MAX, factor), + Some(Duration::seconds(20)) + ); + assert_eq!( + compute_resubmit_backoff_interval(Duration::seconds(40), BASE, MAX, factor), + Some(Duration::seconds(40)) + ); + assert_eq!( + compute_resubmit_backoff_interval(Duration::seconds(80), BASE, MAX, factor), + Some(Duration::seconds(80)) + ); assert_eq!( - compute_resubmit_backoff_interval(Duration::seconds(1280), BASE, MAX), + compute_resubmit_backoff_interval(Duration::seconds(160), BASE, MAX, factor), Some(Duration::seconds(MAX)) ); } @@ -3847,20 +3903,55 @@ mod compute_resubmit_backoff_interval_tests { // Verify the function is generic, not hardcoded to Stellar constants let base = 5; let max = 30; - // age 5-9s: interval = 5s + // age 5-7s: interval = 5s assert_eq!( - compute_resubmit_backoff_interval(Duration::seconds(5), base, max), + compute_resubmit_backoff_interval(Duration::seconds(5), base, max, FACTOR), Some(Duration::seconds(5)) ); - // age 10-19s: interval = 10s + // age 8-11s: interval = 5 * 1.5 = 7.5 → 7s (truncated) assert_eq!( - compute_resubmit_backoff_interval(Duration::seconds(10), base, max), - Some(Duration::seconds(10)) + compute_resubmit_backoff_interval(Duration::seconds(8), base, max, FACTOR), + Some(Duration::seconds(7)) ); - // age 40s: interval = 40 → capped at 30s + // age 100s: capped at 30s assert_eq!( - compute_resubmit_backoff_interval(Duration::seconds(40), base, max), + compute_resubmit_backoff_interval(Duration::seconds(100), base, max, FACTOR), Some(Duration::seconds(30)) ); } + + #[test] + fn factor_at_or_below_one_returns_min_base_max() { + // growth_factor <= 1.0 would cause an infinite loop; guard returns min(base, max) instead + assert_eq!( + compute_resubmit_backoff_interval(Duration::seconds(100), BASE, MAX, 1.0), + Some(Duration::seconds(std::cmp::min(BASE, MAX))) + ); + assert_eq!( + compute_resubmit_backoff_interval(Duration::seconds(100), BASE, MAX, 0.5), + Some(Duration::seconds(std::cmp::min(BASE, MAX))) + ); + // When base > max, returns max + assert_eq!( + compute_resubmit_backoff_interval(Duration::seconds(200), 200, MAX, 1.0), + Some(Duration::seconds(MAX)) + ); + // Still returns None below base + assert!(compute_resubmit_backoff_interval(Duration::seconds(5), BASE, MAX, 1.0).is_none()); + } + + #[test] + fn non_positive_base_or_max_returns_none() { + // base_interval_secs <= 0 would cause infinite loop; guard returns None + assert!( + compute_resubmit_backoff_interval(Duration::seconds(100), 0, MAX, FACTOR).is_none() + ); + assert!( + compute_resubmit_backoff_interval(Duration::seconds(100), -5, MAX, FACTOR).is_none() + ); + // max_interval_secs <= 0 also returns None + assert!( + compute_resubmit_backoff_interval(Duration::seconds(100), BASE, 0, FACTOR).is_none() + ); + } }