feat: bound subscription lifetime with max_executions/expiry_time#32
Merged
abayomicornelius merged 12 commits intoJul 19, 2026
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #23.
stellar_sendsubscriptions had no cap at all — nomax_executions, noexpiry_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 statecancel_subscriptionalready produces, so a capped-out subscription surfaces the familiarSubscriptionInactiveon any further call.Some(0)is rejected at creation (InvalidMaxExecutions) since it could never run.expiry_time: Option<u64>— a ledger timestamp past whichexecute_subscriptionrefuses to run, independent ofmax_executions. Unlike the cap, this does not auto-deactivate — matchingPaymentRequest.expiry's existing behavior in this same codebase, every attempt past expiry gets the specificSubscriptionExpiredrather than a generic "inactive" once discovered. Must be strictly afterstart_timeat creation (InvalidExpiry).max_executions/expiry_timeis the resolution: they bound the burst's damage without touching the cadence logic itself. Documented at length in the module doc comment insubscription.rs.Both
Subscriptionfields areOption-typed and both areNoneby 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
InvalidMaxExecutionsonmax_executions = Some(0).InvalidExpiryonexpiry_time == start_time(would be created already-expired).SubscriptionExpiredon a due-but-expired execution — isolated specifically fromSubscriptionNotDue(the due-time gate alone would allow the call). Confirmed the subscription staysactiveafterward (doesn't auto-deactivate on expiry, matchingPaymentRequest).max_executionsauto-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 withSubscriptionInactive.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, notSubscriptionNotDue) 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.create_subscriptionsignature and still passing.cargo build --workspace,cargo test --workspace(58 tests across all 4 contracts, 29 instellar_send— up from 23),cargo clippy -p stellar_send --all-targets(zero new warnings — the 4 pre-existing ones are insend_path_payment/emit_path_payment_sent, untouched by this PR), andcargo fmt -p stellar_send --check(clean on every file this PR touches) all pass locally.gh api .../actions/workflowsreports 0), so there's nothing to gate on beyond the above local verification.🤖 Generated with Claude Code