Skip to content

Commit 2192ed9

Browse files
Merge pull request #67 from ScriptedBro/feature/7-day-free-trial-tier
feat: add 7-day free trial before paid stream billing
2 parents 4956f96 + 1d87776 commit 2192ed9

9 files changed

Lines changed: 314 additions & 69 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ If the fan dislikes the content, they can cancel instantly and get their remaini
99
## Key Logic
1010

1111
- **subscribe**: User deposits a buffer (e.g., 50 XLM) and sets a rate.
12+
- **7-day free trial**: Streams accrue zero charges for the first 7 days, then automatically charge at the configured paid rate.
1213
- **collect**: Creator triggers the withdrawal of accumulated seconds.
1314
- **cancel**: Subscriber stops the stream and refunds unspent tokens (after minimum duration).
1415
- **pause_channel / unpause_channel**: Creator can pause their channel to stop charges across all active incoming streams during hiatus.

contracts/substream_contracts/src/lib.rs

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use soroban_sdk::{
77

88
// Minimum flow duration: 24 hours in seconds (24 * 60 * 60 = 86400)
99
const MINIMUM_FLOW_DURATION: u64 = 86400;
10+
const FREE_TRIAL_DURATION: u64 = 7 * 24 * 60 * 60;
1011

1112
#[contracttype]
1213
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -18,11 +19,18 @@ pub enum DataKey {
1819
ChannelPaused(Address), // creator -> bool
1920
}
2021

22+
#[contracttype]
23+
#[derive(Clone, Debug, Eq, PartialEq)]
24+
pub struct Tier {
25+
pub rate_per_second: i128,
26+
pub trial_duration: u64,
27+
}
28+
2129
#[contracttype]
2230
#[derive(Clone, Debug, Eq, PartialEq)]
2331
pub struct Stream {
2432
pub token: Address,
25-
pub rate_per_second: i128,
33+
pub tier: Tier,
2634
pub balance: i128,
2735
pub last_collected: u64,
2836
pub start_time: u64,
@@ -147,7 +155,10 @@ fn subscribe_internal(
147155
let now = env.ledger().timestamp();
148156
let stream = Stream {
149157
token: token.clone(),
150-
rate_per_second,
158+
tier: Tier {
159+
rate_per_second,
160+
trial_duration: FREE_TRIAL_DURATION,
161+
},
151162
balance: amount,
152163
last_collected: now,
153164
start_time: now,
@@ -185,6 +196,23 @@ fn distribute_and_collect(
185196
}
186197
}
187198

199+
let trial_end = stream
200+
.start_time
201+
.saturating_add(stream.tier.trial_duration);
202+
let charge_start = if stream.last_collected > trial_end {
203+
stream.last_collected
204+
} else {
205+
trial_end
206+
};
207+
208+
if now <= charge_start {
209+
return 0;
210+
}
211+
212+
let elapsed = (now - charge_start) as i128;
213+
let mut amount_to_collect = elapsed
214+
.checked_mul(stream.tier.rate_per_second)
215+
188216
let elapsed = (now - stream.last_collected) as i128;
189217
let mut amount_to_collect = elapsed
190218
.checked_mul(stream.rate_per_second)
@@ -455,7 +483,7 @@ impl SubStreamContract {
455483
}
456484

457485
let stream_before: Stream = env.storage().persistent().get(&key).unwrap();
458-
let old_rate = stream_before.rate_per_second;
486+
let old_rate = stream_before.tier.rate_per_second;
459487

460488
distribute_and_collect(&env, &subscriber, &creator, Some(&creator));
461489

@@ -476,7 +504,7 @@ impl SubStreamContract {
476504
}
477505
}
478506

479-
stream.rate_per_second = new_rate_per_second;
507+
stream.tier.rate_per_second = new_rate_per_second;
480508
stream.balance = balance;
481509

482510
if additional_deposit > 0 {

contracts/substream_contracts/src/test.rs

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ use soroban_sdk::{
66
token, vec, Address, Env,
77
};
88

9+
const DAY: u64 = 24 * 60 * 60;
10+
const WEEK: u64 = 7 * DAY;
11+
912
fn create_token_contract<'a>(env: &Env, admin: &Address) -> token::Client<'a> {
1013
let sac = env.register_stellar_asset_contract_v2(admin.clone());
1114
token::Client::new(env, &sac.address())
@@ -27,18 +30,29 @@ fn test_subscribe_and_collect() {
2730
let contract_id = env.register(SubStreamContract, ());
2831
let client = SubStreamContractClient::new(&env, &contract_id);
2932

33+
let start = 100u64;
34+
env.ledger().set_timestamp(start);
3035
env.ledger().set_timestamp(100);
3136
client.subscribe(&subscriber, &creator, &token.address, &100, &2);
3237

3338
assert_eq!(token.balance(&subscriber), 900);
3439
assert_eq!(token.balance(&contract_id), 100);
3540

41+
// Still inside trial: no charges.
42+
env.ledger().set_timestamp(start + 10);
43+
client.collect(&subscriber, &creator);
44+
assert_eq!(token.balance(&creator), 0);
45+
46+
// 10 paid seconds after trial.
47+
env.ledger().set_timestamp(start + WEEK + 10);
3648
env.ledger().set_timestamp(110);
3749
client.collect(&subscriber, &creator);
3850

3951
assert_eq!(token.balance(&creator), 20);
4052
assert_eq!(token.balance(&contract_id), 80);
4153

54+
// Additional 50 paid seconds, capped by remaining balance.
55+
env.ledger().set_timestamp(start + WEEK + 60);
4256
env.ledger().set_timestamp(160);
4357
client.collect(&subscriber, &creator);
4458

@@ -47,6 +61,37 @@ fn test_subscribe_and_collect() {
4761
assert_eq!(client.get_total_streamed(&subscriber, &creator), 100);
4862
}
4963

64+
#[test]
65+
fn test_free_trial_ignores_claims_within_first_week() {
66+
#[should_panic(expected = "cannot cancel stream: minimum duration not met")]
67+
fn test_cancel_before_minimum_duration() {
68+
let env = Env::default();
69+
env.mock_all_auths();
70+
71+
let subscriber = Address::generate(&env);
72+
let creator = Address::generate(&env);
73+
let admin = Address::generate(&env);
74+
75+
let token = create_token_contract(&env, &admin);
76+
let token_admin = token::StellarAssetClient::new(&env, &token.address);
77+
token_admin.mint(&subscriber, &1000);
78+
79+
let contract_id = env.register(SubStreamContract, ());
80+
let client = SubStreamContractClient::new(&env, &contract_id);
81+
82+
let start = 100u64;
83+
env.ledger().set_timestamp(start);
84+
client.subscribe(&subscriber, &creator, &token.address, &300, &3);
85+
86+
env.ledger().set_timestamp(start + WEEK - 1);
87+
client.collect(&subscriber, &creator);
88+
assert_eq!(token.balance(&creator), 0);
89+
90+
env.ledger().set_timestamp(start + WEEK + 9);
91+
client.collect(&subscriber, &creator);
92+
assert_eq!(token.balance(&creator), 27);
93+
}
94+
5095
#[test]
5196
#[should_panic(expected = "cannot cancel stream: minimum duration not met")]
5297
fn test_cancel_before_minimum_duration() {
@@ -87,6 +132,16 @@ fn test_cancel_after_minimum_duration() {
87132
let contract_id = env.register(SubStreamContract, ());
88133
let client = SubStreamContractClient::new(&env, &contract_id);
89134

135+
let start = 100u64;
136+
env.ledger().set_timestamp(start);
137+
client.subscribe(&subscriber, &creator, &token.address, &100, &1);
138+
139+
// Minimum duration has passed, but still inside free trial.
140+
env.ledger().set_timestamp(start + DAY + 10);
141+
client.cancel(&subscriber, &creator);
142+
143+
assert_eq!(token.balance(&creator), 0);
144+
assert_eq!(token.balance(&subscriber), 1000);
90145
env.ledger().set_timestamp(100);
91146
client.subscribe(&subscriber, &creator, &token.address, &100, &1);
92147

@@ -118,6 +173,7 @@ fn test_top_up() {
118173
client.subscribe(&subscriber, &creator, &token.address, &100, &1);
119174
client.top_up(&subscriber, &creator, &50);
120175

176+
env.ledger().set_timestamp(WEEK + 120);
121177
env.ledger().set_timestamp(120);
122178
client.collect(&subscriber, &creator);
123179

@@ -156,6 +212,8 @@ fn test_group_subscribe_and_collect_split() {
156212
];
157213
let percentages = vec![&env, 40u32, 25u32, 15u32, 10u32, 10u32];
158214

215+
let start = 100u64;
216+
env.ledger().set_timestamp(start);
159217
env.ledger().set_timestamp(100);
160218
client.subscribe_group(
161219
&subscriber,
@@ -167,6 +225,7 @@ fn test_group_subscribe_and_collect_split() {
167225
&percentages,
168226
);
169227

228+
env.ledger().set_timestamp(start + WEEK + 10);
170229
env.ledger().set_timestamp(110);
171230
client.collect_group(&subscriber, &channel_id);
172231

@@ -235,6 +294,21 @@ fn test_pause_channel_blocks_charges_and_unpause_resumes() {
235294
let contract_id = env.register(SubStreamContract, ());
236295
let client = SubStreamContractClient::new(&env, &contract_id);
237296

297+
let start = 100u64;
298+
env.ledger().set_timestamp(start);
299+
client.subscribe(&subscriber, &creator, &token.address, &300, &2);
300+
301+
env.ledger().set_timestamp(start + WEEK + 10);
302+
client.collect(&subscriber, &creator);
303+
assert_eq!(token.balance(&creator), 20);
304+
305+
env.ledger().set_timestamp(start + WEEK + 20);
306+
client.pause_channel(&creator);
307+
assert!(client.is_channel_paused(&creator));
308+
assert_eq!(token.balance(&creator), 40);
309+
310+
env.ledger().set_timestamp(start + WEEK + 100);
311+
client.collect(&subscriber, &creator);
238312
env.ledger().set_timestamp(100);
239313
client.subscribe(&subscriber, &creator, &token.address, &300, &2);
240314

@@ -256,6 +330,7 @@ fn test_pause_channel_blocks_charges_and_unpause_resumes() {
256330
client.unpause_channel(&creator);
257331
assert!(!client.is_channel_paused(&creator));
258332

333+
env.ledger().set_timestamp(start + WEEK + 110);
259334
env.ledger().set_timestamp(210);
260335
client.collect(&subscriber, &creator);
261336
assert_eq!(token.balance(&creator), 60);
@@ -280,6 +355,21 @@ fn test_pause_channel_applies_to_all_subscribers() {
280355
let contract_id = env.register(SubStreamContract, ());
281356
let client = SubStreamContractClient::new(&env, &contract_id);
282357

358+
let start = 100u64;
359+
env.ledger().set_timestamp(start);
360+
client.subscribe(&subscriber_1, &creator, &token.address, &200, &1);
361+
client.subscribe(&subscriber_2, &creator, &token.address, &200, &1);
362+
363+
env.ledger().set_timestamp(start + WEEK + 30);
364+
client.pause_channel(&creator);
365+
assert_eq!(token.balance(&creator), 60);
366+
367+
env.ledger().set_timestamp(start + WEEK + 130);
368+
client.unpause_channel(&creator);
369+
370+
env.ledger().set_timestamp(start + WEEK + 140);
371+
let total = client.withdraw_all(&creator, &10);
372+
283373
env.ledger().set_timestamp(100);
284374
client.subscribe(&subscriber_1, &creator, &token.address, &200, &1);
285375
client.subscribe(&subscriber_2, &creator, &token.address, &200, &1);
@@ -318,6 +408,12 @@ fn test_cliff_threshold_access() {
318408

319409
client.set_cliff_threshold(&creator, &50);
320410

411+
let start = 100u64;
412+
env.ledger().set_timestamp(start);
413+
client.subscribe(&subscriber, &creator, &token.address, &100, &1);
414+
415+
env.ledger().set_timestamp(start + WEEK + 30);
416+
321417
env.ledger().set_timestamp(100);
322418
client.subscribe(&subscriber, &creator, &token.address, &100, &1);
323419

@@ -326,12 +422,38 @@ fn test_cliff_threshold_access() {
326422
assert!(!client.has_unlocked_access(&subscriber, &creator));
327423
assert_eq!(client.get_access_tier(&subscriber, &creator), 0);
328424

425+
env.ledger().set_timestamp(start + WEEK + 50);
329426
env.ledger().set_timestamp(150);
330427
client.collect(&subscriber, &creator);
331428
assert!(client.has_unlocked_access(&subscriber, &creator));
332429
assert_eq!(client.get_access_tier(&subscriber, &creator), 1);
333430
}
334431

432+
#[test]
433+
fn test_migrate_tier_downgrade_prorates_refund() {
434+
let env = Env::default();
435+
env.mock_all_auths();
436+
437+
let subscriber = Address::generate(&env);
438+
let creator = Address::generate(&env);
439+
let admin = Address::generate(&env);
440+
441+
let token = create_token_contract(&env, &admin);
442+
let token_admin = token::StellarAssetClient::new(&env, &token.address);
443+
token_admin.mint(&subscriber, &1000);
444+
445+
let contract_id = env.register(SubStreamContract, ());
446+
let client = SubStreamContractClient::new(&env, &contract_id);
447+
448+
let start = 100u64;
449+
env.ledger().set_timestamp(start);
450+
client.subscribe(&subscriber, &creator, &token.address, &100, &10);
451+
452+
env.ledger().set_timestamp(start + WEEK + 5);
453+
client.migrate_tier(&subscriber, &creator, &5, &0);
454+
455+
assert_eq!(token.balance(&creator), 50);
456+
335457
#[test]
336458
fn test_migrate_tier_downgrade_prorates_refund() {
337459
let env = Env::default();

0 commit comments

Comments
 (0)