fix(subscription): close infinite free-trial bypass#4
Open
maycuatroi1 wants to merge 1 commit intoPiNetwork:mainfrom
Open
fix(subscription): close infinite free-trial bypass#4maycuatroi1 wants to merge 1 commit intoPiNetwork:mainfrom
maycuatroi1 wants to merge 1 commit intoPiNetwork:mainfrom
Conversation
The original subscribe() gates trial abuse with
`had_trial && !auto_renew && service.trial_period_secs > 0`, assuming
that `auto_renew=true` implies the merchant will collect payment. This
assumption is not enforced on-chain.
A subscriber can deterministically bypass the gate:
1. subscribe(auto_renew=true, trial=T) — trial granted, no payment.
2. subscriber revokes token allowance (or drains balance) — a direct
token.approve(contract, 0, 0) only needs the subscriber's sig.
3. at trial_end_ts the merchant calls process(), try_transfer_from
fails, contract sets sub.auto_renew = false (failure isolation).
4. once service_end_ts (== trial_end_ts) lapses the dedup gate lets
the subscriber call subscribe() again. The had_trial check reads
the previous subscription's trial_period_secs, but only blocks the
auto_renew=false branch — the subscriber retries with auto_renew=
true and receives a fresh trial. Repeatable indefinitely.
Fix: track trial consumption on a dedicated persistent key
`DataKey::TrialUsed(Address, u64)`. Set it whenever a trial is granted,
with a TTL bumped to `max_ttl - 1` so the flag survives long beyond
any subscription lifecycle. A subscriber that already consumed the
trial does not receive another one; `has_trial` falls through to the
paid branch (immediate charge, no trial_end_ts).
This changes the documented re-subscribe UX slightly: after trial
expiry a subscriber may still subscribe with auto_renew=false, but is
charged upfront for the first period instead of being rejected with
AlreadySubscribed. The dedup gate for overlapping subscriptions is
unchanged.
Regression tests:
- test_trial_not_regranted_after_payment_failure — subscriber revokes
allowance, process() fails, re-subscribe returns trial_period_secs=0
and charges the first period.
- test_trial_not_regranted_after_balance_drain — same via balance
drain and re-mint.
- test_paid_subscriber_does_not_set_trial_used — a non-trial service
subscription does not set the TrialUsed flag, so a subsequent trial
service from a different merchant still grants its trial.
The pre-existing test_subscribe_trial_abuse_blocked is updated to
reflect the new semantics (re-subscribe allowed, no trial re-granted).
All 51 tests pass (48 baseline + 3 new).
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
The subscription contract's trial-abuse prevention check is ineffective: a subscriber can obtain unlimited free trials on the same service by inducing a single
try_transfer_fromfailure after each trial. No privileged role is needed.This PR adds a dedicated
TrialUsed(subscriber, service_id)persistent key that records trial consumption independently of the subscription lifecycle state, closing the bypass. All 48 existing tests still pass; 3 new regression tests are added.Vulnerability
subscribe()currently gates trial abuse with:The check only blocks re-subscription when
auto_renew=false. The implicit assumption is thatauto_renew=trueforces payment on the first post-trial cycle. On-chain, that assumption is not enforced — the subscriber can revoke the token allowance directly viatoken.approve(contract, 0, 0)(one signature, no contract cooperation). Whenprocess()later callstry_transfer_from, it fails; the contract's failure-isolation path setssub.auto_renew = falseon the victim subscription without rolling back the consumed trial. Onceservice_end_tslapses, the dedup gate at line 350 admits a new subscription, and the subscriber callssubscribe(auto_renew=true)again —had_trialistruebut the check does not fire, so a fresh trial is granted.Loop cost: one
subscribe+ onetoken.approve(0)per trial. Time cost:trial_period_secs. Attacker can scale horizontally by creating Stellar addresses (effectively free).Concrete impact is proportional to the merchant's cost-to-serve during the trial (compute, bandwidth, API quota, etc.). A reusable trial is not a theft of funds already in the merchant's wallet but a permanent revenue-denial vector and a griefing channel for any service whose trial delivers real value.
Proof of concept
Before the fix this test would assert
sub2.trial_period_secs == WEEK.Fix
DataKey::TrialUsed(Address, u64)to the storage key enum.subscribe(), read this flag before computinghas_trial; if set, forcehas_trial = falseso re-subscribers skip the trial branch entirely.max_ttl - 1. This outlasts any subscription lifecycle — including TTL-driven GC of theSubandSubServicePairrecords, which would otherwise reopen the bypass on a slower timescale.Behavioural change vs. original contract:
subscribe(auto_renew=false)after trial expiry returnedAlreadySubscribed. After fix, it is allowed and charges the first period upfront. This is a friendlier UX and matches the paid!has_trialbranch.subscribe(auto_renew=true)after trial expiry still succeeds; it just no longer receives a second trial.Test plan
cargo build --target wasm32v1-none --release -p subscription— compiles clean.cargo test -p subscription --lib— 51/51 pass (48 baseline + 3 new regressions).test_subscribe_trial_abuse_blockedupdated to assert the new (correct) semantics.test_trial_not_regranted_after_payment_failure— bypass via allowance revoke is blocked.test_trial_not_regranted_after_balance_drain— bypass via balance drain + re-mint is blocked.test_paid_subscriber_does_not_set_trial_used— a non-trial subscribe to service A does not interfere with a trial grant on service B.Notes for reviewers
TrialUsedflag keep working as before, and the flag gets set lazily the next time anyone callssubscribe()on a trial service.process(),cancel(),toggle_auto_renew(), orextend_subscription().74c48c6. Separate follow-ups (unbounded index-list growth,is_activedead write, admin upgrade trust boundary) are not addressed in this PR; happy to send follow-up PRs if useful.Research author: Binhna.