Skip to content

stellar_send: persistent Subscription records are never TTL-extended, risking archival for exactly the long-interval payments the feature exists to serve #43

Description

@abayomicornelius

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

  • create_subscription sets an initial TTL extension sized relative to the subscription's own cadence (interval_seconds, and expiry_time if present).
  • execute_subscription extends the TTL again on every successful execution.
  • A 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.
  • A test using soroban-sdk's TTL testutils demonstrates a long-interval_seconds subscription (e.g. a yearly payment) surviving to its next due date without requiring manual restoration, after the fix.
  • Module doc comment updated with the TTL/restoration risk for long-interval subscriptions.

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignbugSomething isn't workingcontractsSmart contract logicvery hardVery difficult / senior-level bounty issue

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions