Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 30 additions & 14 deletions contracts/subscription/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ pub enum DataKey {
SubscriberSubs(Address),
ServiceSubs(u64),
SubServicePair(Address, u64),
TrialUsed(Address, u64),
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -339,36 +340,38 @@ impl SubscriptionContract {

// ---- Dedup check ----
let pair_key = DataKey::SubServicePair(subscriber.clone(), service_id);
let had_trial = if let Some(existing_sub_id) =
env.storage().persistent().get::<_, u64>(&pair_key)
{
if let Some(existing_sub_id) = env.storage().persistent().get::<_, u64>(&pair_key) {
bump_persistent(&env, &pair_key, service.period_secs);
let sub_key = DataKey::Sub(existing_sub_id);
if let Some(existing) = env.storage().persistent().get::<_, Subscription>(&sub_key) {
bump_persistent(&env, &sub_key, existing.period_secs);
if existing.auto_renew || env.ledger().timestamp() < existing.service_end_ts {
return Err(ContractError::AlreadySubscribed);
}
existing.trial_period_secs > 0
} else {
false
}
} else {
false
};
}

// Prevent repeated free trial: if the subscriber already used a trial
// for this service, they cannot re-subscribe without auto_renew.
if had_trial && !auto_renew && service.trial_period_secs > 0 {
return Err(ContractError::AlreadySubscribed);
// Trial consumption tracking (trial-abuse prevention). A subscriber
// that already consumed the trial for this service does NOT get a
// second trial; the new subscription starts on the paid schedule.
// Without this, a malicious subscriber can induce a payment failure
// after the trial (e.g. revoke the token allowance), which drops
// auto_renew to false; once service_end_ts lapses the dedup gate lets
// them re-subscribe with auto_renew=true and receive a fresh trial.
let trial_used_key = DataKey::TrialUsed(subscriber.clone(), service_id);
let trial_already_used = env.storage().persistent().has(&trial_used_key);
if trial_already_used {
bump_persistent(&env, &trial_used_key, service.period_secs);
}

let now = env.ledger().timestamp();
let sub_id = next_sub_id(&env);
let token = get_token(&env);
let token_client = TokenClient::new(&env, &token);

let has_trial = service.trial_period_secs > 0;
// Trial is only granted once per (subscriber, service). Re-subscribers
// who already consumed the trial fall through to the paid branch.
let has_trial = service.trial_period_secs > 0 && !trial_already_used;

let sub = if has_trial {
let trial_end = checked_add_ts(now, service.trial_period_secs)?;
Expand Down Expand Up @@ -450,6 +453,19 @@ impl SubscriptionContract {
env.storage().persistent().set(&pair_key, &sub_id);
bump_persistent(&env, &pair_key, ps);

// Record trial consumption with a TTL large enough to outlive the
// subscription lifecycle, so the trial-abuse gate above remains
// effective after the current subscription's Sub record is recycled.
if has_trial {
env.storage().persistent().set(&trial_used_key, &true);
let max_ttl = env.storage().max_ttl().saturating_sub(1);
env.storage().persistent().extend_ttl(
&trial_used_key,
PERSISTENT_TTL_THRESHOLD,
max_ttl,
);
}

// Append to subscriber's list
let ss_key = DataKey::SubscriberSubs(subscriber.clone());
let mut sub_ids: Vec<u64> = env
Expand Down
97 changes: 87 additions & 10 deletions contracts/subscription/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,8 +314,10 @@ fn test_subscribe_trial_abuse_blocked() {
let s = setup();
let svc = register_trial_service(&s);

// First trial subscription (no auto_renew)
s.client.subscribe(&s.subscriber, &svc.service_id, &false);
// First trial subscription consumes the trial for this (subscriber, service)
let sub1 = s.client.subscribe(&s.subscriber, &svc.service_id, &false);
assert_eq!(sub1.trial_period_secs, WEEK);
assert_eq!(s.token.balance(&s.subscriber), INITIAL_BALANCE);

// Wait for trial to expire
advance_time(&s.env, WEEK + 1);
Expand All @@ -325,15 +327,15 @@ fn test_subscribe_trial_abuse_blocked() {
false
);

// Attempt to get another free trial — should be blocked
let result = s
// Re-subscribing is allowed but NO new trial is granted — the subscriber
// pays for the first period immediately.
let sub2 = s
.client
.try_subscribe(&s.subscriber, &svc.service_id, &false);
assert_eq!(result, Err(Ok(ContractError::AlreadySubscribed)));

// But re-subscribing with auto_renew=true should work
let sub2 = s.client.subscribe(&s.subscriber, &svc.service_id, &true);
assert_eq!(sub2.auto_renew, true);
.subscribe(&s.subscriber, &svc.service_id, &false);
assert_eq!(sub2.trial_period_secs, 0);
assert_eq!(sub2.trial_end_ts, 0);
assert_eq!(s.token.balance(&s.subscriber), INITIAL_BALANCE - PRICE);
assert_ne!(sub2.sub_id, sub1.sub_id);
}

#[test]
Expand Down Expand Up @@ -942,3 +944,78 @@ fn test_timestamp_overflow() {
.try_subscribe(&s.subscriber, &svc.service_id, &true);
assert_eq!(result, Err(Ok(ContractError::TimestampOverflow)));
}

// ===========================================================================
// Trial-abuse regression: once a trial is consumed it is not granted again,
// even when the subscriber induces a post-trial payment failure to reset the
// subscription state.
// ===========================================================================

#[test]
fn test_trial_not_regranted_after_payment_failure() {
let s = setup();
let svc = register_trial_service(&s);

let sub1 = s.client.subscribe(&s.subscriber, &svc.service_id, &true);
assert_eq!(sub1.trial_period_secs, WEEK);
assert_eq!(s.token.balance(&s.subscriber), INITIAL_BALANCE);

s.token.approve(&s.subscriber, &s.contract_addr, &0, &0);

advance_time(&s.env, WEEK + 1);
let result = s.client.process(&s.merchant, &svc.service_id, &0, &100);
assert_eq!(result.failed, 1);

let sub2 = s.client.subscribe(&s.subscriber, &svc.service_id, &true);
assert_eq!(sub2.trial_period_secs, 0);
assert_eq!(sub2.trial_end_ts, 0);
assert_eq!(s.token.balance(&s.subscriber), INITIAL_BALANCE - PRICE);
assert_ne!(sub2.sub_id, sub1.sub_id);
}

#[test]
fn test_trial_not_regranted_after_balance_drain() {
let s = setup();
let svc = register_trial_service(&s);

let sub1 = s.client.subscribe(&s.subscriber, &svc.service_id, &true);
assert_eq!(sub1.trial_period_secs, WEEK);

let drain = s.token.balance(&s.subscriber);
s.token.transfer(&s.subscriber, &s.admin, &drain);

advance_time(&s.env, WEEK + 1);
let result = s.client.process(&s.merchant, &svc.service_id, &0, &100);
assert_eq!(result.failed, 1);

s.token_admin.mint(&s.subscriber, &INITIAL_BALANCE);

let sub2 = s.client.subscribe(&s.subscriber, &svc.service_id, &true);
assert_eq!(sub2.trial_period_secs, 0);
assert_eq!(sub2.trial_end_ts, 0);
assert_eq!(s.token.balance(&s.subscriber), INITIAL_BALANCE - PRICE);
}

#[test]
fn test_paid_subscriber_does_not_set_trial_used() {
let s = setup();
let svc = register_default_service(&s);

let sub1 = s.client.subscribe(&s.subscriber, &svc.service_id, &true);
assert_eq!(sub1.trial_period_secs, 0);
s.client.cancel(&s.subscriber, &sub1.sub_id);

advance_time(&s.env, MONTH + 1);

let trial_svc = s.client.register_service(
&s.merchant2,
&String::from_str(&s.env, "Trial"),
&PRICE,
&MONTH,
&WEEK,
&12,
);

let sub2 = s.client.subscribe(&s.subscriber, &trial_svc.service_id, &true);
assert_eq!(sub2.trial_period_secs, WEEK);
}
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,57 @@
1036800
]
],
[
{
"contract_data": {
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4",
"key": {
"vec": [
{
"symbol": "TrialUsed"
},
{
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM"
},
{
"u64": 0
}
]
},
"durability": "persistent"
}
},
[
{
"last_modified_ledger_seq": 0,
"data": {
"contract_data": {
"ext": "v0",
"contract": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAITA4",
"key": {
"vec": [
{
"symbol": "TrialUsed"
},
{
"address": "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAK3IM"
},
{
"u64": 0
}
]
},
"durability": "persistent",
"val": {
"bool": true
}
}
},
"ext": "v0"
},
6311998
]
],
[
{
"contract_data": {
Expand Down
Loading