Skip to content

feat: bound subscription lifetime with max_executions/expiry_time#32

Merged
abayomicornelius merged 12 commits into
StellarSend:mainfrom
prodbycorne:feat/subscription-caps-catchup-limit
Jul 19, 2026
Merged

feat: bound subscription lifetime with max_executions/expiry_time#32
abayomicornelius merged 12 commits into
StellarSend:mainfrom
prodbycorne:feat/subscription-caps-catchup-limit

Conversation

@prodbycorne

Copy link
Copy Markdown
Contributor

Summary

Closes #23. stellar_send subscriptions had no cap at all — no max_executions, no expiry_time — so a subscription created once and then forgotten (or one whose keeper went offline for a long stretch) runs indefinitely, and the module's own deliberate "advance by exactly one interval per call" anti-drift design means a keeper resuming after a long gap can rapid-fire catch up every missed interval in one burst.

This is an open-ended/investigation issue (labeled "spike"), so the PR states its resolution explicitly:

  • max_executions: Option<u32> — a hard ceiling on total lifetime executions. Reaching it auto-deactivates the subscription (active = false), the same terminal state cancel_subscription already produces, so a capped-out subscription surfaces the familiar SubscriptionInactive on any further call. Some(0) is rejected at creation (InvalidMaxExecutions) since it could never run.
  • expiry_time: Option<u64> — a ledger timestamp past which execute_subscription refuses to run, independent of max_executions. Unlike the cap, this does not auto-deactivate — matching PaymentRequest.expiry's existing behavior in this same codebase, every attempt past expiry gets the specific SubscriptionExpired rather than a generic "inactive" once discovered. Must be strictly after start_time at creation (InvalidExpiry).
  • Catch-up burst semantics are deliberately left unchanged. The issue frames disallowing catch-up (skip-ahead instead) as a genuine, undecided tradeoff — it trades a burst-of-payments surprise for a skipped-payment surprise, and the module's existing anti-drift design already reflects a considered choice. Per the issue's own reasoning ("a bounded subscription makes an unbounded catch-up burst less concerning... than an unbounded one"), max_executions/expiry_time is the resolution: they bound the burst's damage without touching the cadence logic itself. Documented at length in the module doc comment in subscription.rs.

Both Subscription fields are Option-typed and both are None by default — an unbounded subscription (e.g. an indefinite payroll payment) is still a fully valid, intentional choice; the payer opts into a bound rather than having one imposed.

Test plan

  • InvalidMaxExecutions on max_executions = Some(0).
  • InvalidExpiry on expiry_time == start_time (would be created already-expired).
  • SubscriptionExpired on a due-but-expired execution — isolated specifically from SubscriptionNotDue (the due-time gate alone would allow the call). Confirmed the subscription stays active afterward (doesn't auto-deactivate on expiry, matching PaymentRequest).
  • max_executions auto-deactivation end-to-end: still active after execution 1 of 2, auto-deactivates exactly on execution 2, and a 3rd attempt the schedule alone would permit correctly fails with SubscriptionInactive.
  • test_execute_subscription_rapid_catch_up_multiple_calls (named per the issue's own testing-strategy ask): pins down the exact count for a known backlog — a 5-interval gap yields 6 successful rapid catch-up calls (the original due execution plus 5 more that individually came due), not 5, then a 7th is correctly refused.
  • test_execute_subscription_max_executions_bounds_catch_up_burst: the same 5-interval/six-due-time backlog, but capped at 3 — auto-deactivates after exactly 3 executions (SubscriptionInactive, not SubscriptionNotDue) even though the backlog still has unclaimed due-times, proving the cap is what stopped it rather than the schedule running out on its own.
  • All 3 pre-existing subscription tests updated for the new create_subscription signature and still passing.
  • cargo build --workspace, cargo test --workspace (58 tests across all 4 contracts, 29 in stellar_send — up from 23), cargo clippy -p stellar_send --all-targets (zero new warnings — the 4 pre-existing ones are in send_path_payment/emit_path_payment_sent, untouched by this PR), and cargo fmt -p stellar_send --check (clean on every file this PR touches) all pass locally.
  • This repository has no CI workflow configured (gh api .../actions/workflows reports 0), so there's nothing to gate on beyond the above local verification.

🤖 Generated with Claude Code

StellarSend#23)

Two new error codes for bounding subscriptions: InvalidMaxExecutions for
a create-time max_executions = Some(0) (a subscription that could never
execute even once), and SubscriptionExpired for an execute_subscription
call past a subscription's expiry_time.
…tellarSend#23)

Explains the two independent bounds about to be added (max_executions,
expiry_time) and, per the issue's own investigation guidance, why the
existing "advance by exactly one interval per call" catch-up burst
behavior is deliberately left unchanged rather than switched to a
skip-ahead policy — disallowing catch-up trades one kind of user-surprise
for another, and a hard execution/expiry cap is what actually bounds the
burst's damage.
…ields (StellarSend#23)

New Subscription fields for bounding a recurring payment's lifetime:
max_executions (Option<u32> hard ceiling), expiry_time (Option<u64>
ledger-timestamp cutoff), and executions_count (running tally). Wired to
None/0 placeholders at the one construction site in create_subscription
for now — real caller-supplied values and validation land next.
…tellarSend#23)

create_subscription now takes max_executions: Option<u32> and
expiry_time: Option<u64>. Some(0) for the former and an expiry at-or-
before start_time for the latter are both rejected — both describe a
subscription that could never execute even once.

Note: existing callers (test.rs) need updating for the new signature —
that lands in a later commit alongside the new coverage, matching how
this feature is being built up in reviewable stages.
…larSend#23)

An indexer/keeper reading the creation event can now see a subscription's
caps directly, without a separate get_subscription call.
…tellarSend#23)

Checked independently of (and after) the existing next_execution_time
due-time gate, so a subscription that's both due and expired reports the
more specific SubscriptionExpired rather than SubscriptionNotDue.
…ions (StellarSend#23)

Every successful execution increments executions_count; once it reaches
max_executions (if set), the subscription is auto-deactivated the same
way cancel_subscription does, so any further call correctly surfaces the
familiar SubscriptionInactive instead of continuing to execute past the
payer's declared limit.
…end#23)

Lets a keeper or indexer see directly from the execution event whether
this call was the one that hit max_executions and auto-deactivated the
subscription, without a separate get_subscription call.
…tion signature (StellarSend#23)

The three pre-existing subscription tests now pass &None, &None for
max_executions/expiry_time, preserving their original unbounded-
subscription behavior. Includes the resulting test_snapshots updates
(the persisted Subscription now carries the three new fields).
…iptionExpired (StellarSend#23)

Three new tests: max_executions = Some(0) is rejected at creation;
expiry_time == start_time is rejected at creation (would be created
already-expired); and a due-but-expired execute_subscription call gets
SubscriptionExpired specifically (not SubscriptionNotDue), while the
subscription itself stays active — matching how PaymentRequest.expiry
never auto-cancels on discovery either.
…StellarSend#23)

A subscription capped at 2 executions: confirms it's still active after
the 1st, auto-deactivates exactly on the 2nd (executions_count == 2,
active == false), and a 3rd attempt — which the due-time schedule alone
would otherwise permit — correctly fails with SubscriptionInactive
instead of moving a 3rd payment.
…s bounds it (StellarSend#23)

Directly answers the issue's own investigation question with a pinned-
down number: with a 5-interval backlog, exactly 6 rapid back-to-back
calls succeed (the original due execution plus 5 more that individually
came due along the way) before SubscriptionNotDue correctly blocks a 7th
— the "advance by exactly one interval" design's catch-up burst,
quantified precisely rather than left as a vague concern.

A companion test proves max_executions genuinely bounds that burst's
damage rather than just describing it: the same 5-interval/six-due-time
backlog, but capped at 3, auto-deactivates after exactly 3 executions —
SubscriptionInactive, not SubscriptionNotDue — even though unclaimed
due-times are still sitting in the backlog. This is the concrete
resolution to the issue: catch-up semantics are kept exactly as they
were (see the module doc comment), and it's the new cap that limits how
much damage a burst can ever do.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stellar_send subscriptions have no expiry or max_executions cap, risking indefinite forgotten recurring charges

2 participants