Overview
stellar_send's subscription feature stores each recurring payment as a persistent() Subscription record (stellar_send/src/subscription.rs:165, (KEY_SUB, id) → Subscription), and — like every other persistent record in this workspace — its TTL is never extended anywhere. Repo-wide grep -rn "extend_ttl\|bump\|ttl\|TTL" across all four contracts' non-test source returns zero matches.
This is arguably the single worst place in the whole workspace for this gap to exist, because subscriptions are specifically the feature whose entire value proposition is long, unattended dormancy between touches. Compare to escrow (also covered by its own TTL issue in this batch): an escrow's dormancy is bounded by a chosen unlock_time, and is at least sometimes short. A subscription with a monthly or yearly interval_seconds is designed to sit completely untouched in storage for that entire interval — execute_subscription (stellar_send/src/subscription.rs:210-273) is the only function that ever reads or writes a given (KEY_SUB, id) entry after creation, and by construction it isn't called again until next_execution_time arrives:
// stellar_send/src/subscription.rs:280-285
fn load_subscription(env: &Env, id: u64) -> Result<Subscription, StellarSendError> {
env.storage()
.persistent()
.get(&(KEY_SUB, id))
.ok_or(StellarSendError::SubscriptionNotFound)
}
If a subscription's interval_seconds (an unbounded u64, per create_subscription's only check being interval_seconds == 0 at stellar_send/src/subscription.rs:136-138) is set to anything longer than the persistent-entry default TTL window, the very first scheduled execute_subscription call after creation risks failing — not with a clean StellarSendError, but because the entry has been archived and requires an explicit RestoreFootprintOp first. Worse, load_subscription's .ok_or(StellarSendError::SubscriptionNotFound) means an archived-and-not-yet-restored entry is likely to surface identically to "this id was never created" from the caller's point of view (depending on exact host/RPC behavior around archived vs. missing keys), actively misleading a keeper or payer trying to diagnose why a legitimate, previously-working subscription suddenly appears not to exist.
Requirements
- Extend the TTL of a
Subscription's persistent entry on every touch — create_subscription (sized relative to interval_seconds and, if set, expiry_time, so a long-interval subscription's TTL is actually set out far enough to survive to its next due date) and execute_subscription (refreshed again on every successful execution, since the next interval also needs to survive).
- Given subscriptions are specifically the unattended case, strongly consider a permissionless
keep_alive(subscription_id)-style function so a keeper bot (which already exists conceptually in this design, since execute_subscription is explicitly meant to be called by an untrusted keeper) can proactively refresh a subscription's TTL well before its next due date, independent of whether it's actually due yet.
- Document the relationship between
interval_seconds/expiry_time and the TTL risk explicitly in the module doc comment (stellar_send/src/subscription.rs:1-64), which already has a detailed "Bounding indefinite subscriptions" and "Catch-up bursts" section — this is a natural, related addition to that existing documentation.
Acceptance Criteria
Additional Notes
Precise references: stellar_send/src/subscription.rs:165 (create_subscription's write, no TTL call), :258 (execute_subscription's write, no TTL call), :280-285 (load_subscription, a plain .get() on the read path with no TTL check/refresh), :136-138 (the only bound on interval_seconds, rejecting only zero — nothing prevents a multi-year interval).
Relationship to the existing catch-up-burst design already documented in this module (stellar_send/src/subscription.rs:34-56): that design deliberately accepts that a keeper going offline for a while produces a burst of catch-up executions once it's back — a considered tradeoff. This TTL issue is a different failure mode layered on top: if the keeper (or anyone) doesn't touch the subscription for long enough, the catch-up itself may not be reachable at all without first restoring the archived entry, which the module's existing catch-up-burst design doesn't account for since it implicitly assumes the entry is always readable.
Test/reproduction plan: in stellar_send/src/test.rs, extend the pattern used by test_execute_subscription_rapid_catch_up_multiple_calls (stellar_send/src/test.rs:651-714, which already demonstrates advancing env.ledger().set_timestamp far into the future to simulate keeper downtime): create a subscription with a long interval_seconds (e.g. one year in seconds), then instead of just advancing the timestamp, also use the ledger/TTL testutils to simulate the persistent entry's TTL lapsing over that same span, and assert that — before the fix — the resulting execute_subscription call fails via the archival/restoration failure mode described above rather than the clean SubscriptionNotDue/SubscriptionExpired errors the existing tests exercise; after the fix, assert the same sequence succeeds because the TTL was proactively extended.
Overview
stellar_send's subscription feature stores each recurring payment as apersistent()Subscriptionrecord (stellar_send/src/subscription.rs:165,(KEY_SUB, id) → Subscription), and — like every other persistent record in this workspace — its TTL is never extended anywhere. Repo-widegrep -rn "extend_ttl\|bump\|ttl\|TTL"across all four contracts' non-test source returns zero matches.This is arguably the single worst place in the whole workspace for this gap to exist, because subscriptions are specifically the feature whose entire value proposition is long, unattended dormancy between touches. Compare to
escrow(also covered by its own TTL issue in this batch): an escrow's dormancy is bounded by a chosenunlock_time, and is at least sometimes short. A subscription with a monthly or yearlyinterval_secondsis designed to sit completely untouched in storage for that entire interval —execute_subscription(stellar_send/src/subscription.rs:210-273) is the only function that ever reads or writes a given(KEY_SUB, id)entry after creation, and by construction it isn't called again untilnext_execution_timearrives:If a subscription's
interval_seconds(an unboundedu64, percreate_subscription's only check beinginterval_seconds == 0atstellar_send/src/subscription.rs:136-138) is set to anything longer than the persistent-entry default TTL window, the very first scheduledexecute_subscriptioncall after creation risks failing — not with a cleanStellarSendError, but because the entry has been archived and requires an explicitRestoreFootprintOpfirst. Worse,load_subscription's.ok_or(StellarSendError::SubscriptionNotFound)means an archived-and-not-yet-restored entry is likely to surface identically to "this id was never created" from the caller's point of view (depending on exact host/RPC behavior around archived vs. missing keys), actively misleading a keeper or payer trying to diagnose why a legitimate, previously-working subscription suddenly appears not to exist.Requirements
Subscription's persistent entry on every touch —create_subscription(sized relative tointerval_secondsand, if set,expiry_time, so a long-interval subscription's TTL is actually set out far enough to survive to its next due date) andexecute_subscription(refreshed again on every successful execution, since the next interval also needs to survive).keep_alive(subscription_id)-style function so a keeper bot (which already exists conceptually in this design, sinceexecute_subscriptionis explicitly meant to be called by an untrusted keeper) can proactively refresh a subscription's TTL well before its next due date, independent of whether it's actually due yet.interval_seconds/expiry_timeand the TTL risk explicitly in the module doc comment (stellar_send/src/subscription.rs:1-64), which already has a detailed "Bounding indefinite subscriptions" and "Catch-up bursts" section — this is a natural, related addition to that existing documentation.Acceptance Criteria
create_subscriptionsets an initial TTL extension sized relative to the subscription's own cadence (interval_seconds, andexpiry_timeif present).execute_subscriptionextends the TTL again on every successful execution.keep_alive-style permissionless refresh function exists, or an equivalent documented operational runbook (e.g. "keepers must also periodically submit restore/extend transactions independent of execution") is provided.soroban-sdk's TTL testutils demonstrates a long-interval_secondssubscription (e.g. a yearly payment) surviving to its next due date without requiring manual restoration, after the fix.Additional Notes
Precise references:
stellar_send/src/subscription.rs:165(create_subscription's write, no TTL call),:258(execute_subscription's write, no TTL call),:280-285(load_subscription, a plain.get()on the read path with no TTL check/refresh),:136-138(the only bound oninterval_seconds, rejecting only zero — nothing prevents a multi-year interval).Relationship to the existing catch-up-burst design already documented in this module (
stellar_send/src/subscription.rs:34-56): that design deliberately accepts that a keeper going offline for a while produces a burst of catch-up executions once it's back — a considered tradeoff. This TTL issue is a different failure mode layered on top: if the keeper (or anyone) doesn't touch the subscription for long enough, the catch-up itself may not be reachable at all without first restoring the archived entry, which the module's existing catch-up-burst design doesn't account for since it implicitly assumes the entry is always readable.Test/reproduction plan: in
stellar_send/src/test.rs, extend the pattern used bytest_execute_subscription_rapid_catch_up_multiple_calls(stellar_send/src/test.rs:651-714, which already demonstrates advancingenv.ledger().set_timestampfar into the future to simulate keeper downtime): create a subscription with a longinterval_seconds(e.g. one year in seconds), then instead of just advancing the timestamp, also use the ledger/TTL testutils to simulate the persistent entry's TTL lapsing over that same span, and assert that — before the fix — the resultingexecute_subscriptioncall fails via the archival/restoration failure mode described above rather than the cleanSubscriptionNotDue/SubscriptionExpirederrors the existing tests exercise; after the fix, assert the same sequence succeeds because the TTL was proactively extended.