Skip to content

Commit 4505ed1

Browse files
Merge pull request #90 from Daniel4000-dev/fix-tiny-streams-math
Fix tiny streams math
2 parents 8f84ba9 + 2961e35 commit 4505ed1

9 files changed

+144
-634
lines changed

contracts/substream_contracts/src/lib.rs

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
#![no_std]
22
#[cfg(test)]
33
extern crate std;
4-
54
use soroban_sdk::token::Client as TokenClient;
65
use soroban_sdk::{contract, contractevent, contractimpl, contracttype, vec, Address, Env, Vec};
76

@@ -12,6 +11,7 @@ const GRACE_PERIOD: u64 = 24 * 60 * 60;
1211
const GENESIS_NFT_ADDRESS: &str = "CAS3J7GYCCX7RRBHAHXDUY3OOWFMTIDDNVGCH6YOY7W7Y7G656H2HHMA";
1312
const DISCOUNT_BPS: i128 = 2000;
1413
const SIX_MONTHS: u64 = 180 * 24 * 60 * 60;
14+
const PRECISION_MULTIPLIER: i128 = 1_000_000_000;
1515

1616
// --- Helper: Charge Calculation ---
1717
fn calculate_discounted_charge(start_time: u64, charge_start: u64, now: u64, base_rate: i128) -> i128 {
@@ -74,11 +74,11 @@ pub struct Subscription {
7474
pub last_collected: u64,
7575
pub start_time: u64,
7676
pub last_funds_exhausted: u64,
77-
pub free_to_paid_emitted: bool,
78-
pub creators: Vec<Address>,
79-
pub percentages: Vec<u32>,
77+
pub creators: soroban_sdk::Vec<Address>,
78+
pub percentages: soroban_sdk::Vec<u32>,
8079
pub payer: Address,
8180
pub beneficiary: Address,
81+
pub accrued_remainder: i128, // Dust/fractional units that haven't been paid as tokens
8282
}
8383

8484
#[contracttype]
@@ -232,7 +232,7 @@ impl SubStreamContract {
232232
TipReceived { user, creator, token, amount }.publish(&env);
233233
}
234234

235-
pub fn subscribe_group(env: Env, payer: Address, channel_id: Address, token: Address, amount: i128, rate_per_second: i128, creators: Vec<Address>, percentages: Vec<u32>) {
235+
pub fn subscribe_group(env: Env, payer: Address, channel_id: Address, token: Address, amount: i128, rate_per_second: i128, creators: soroban_sdk::Vec<Address>, percentages: soroban_sdk::Vec<u32>) {
236236
// Validate exactly 5 creators
237237
if creators.len() != 5 {
238238
panic!("group channel must contain exactly 5 creators");
@@ -449,17 +449,19 @@ fn distribute_and_collect(env: &Env, beneficiary: &Address, stream_id: &Address,
449449
}
450450

451451
let available_balance = sub.balance.max(0);
452-
let amount_to_payout = amount_to_collect.min(available_balance);
452+
let total_accrued = amount_to_collect.saturating_add(sub.accrued_remainder);
453+
let amount_to_payout_nano = total_accrued.min(available_balance.saturating_add(sub.accrued_remainder));
454+
let amount_to_payout_tokens = amount_to_payout_nano / PRECISION_MULTIPLIER;
453455

454-
if amount_to_payout > 0 {
456+
if amount_to_payout_tokens > 0 {
455457
let token_client = TokenClient::new(env, &sub.token);
456458
let creators_len = sub.creators.len();
457-
let mut remaining = amount_to_payout;
459+
let mut remaining = amount_to_payout_tokens;
458460

459461
for i in 0..creators_len {
460462
let creator = sub.creators.get(i).unwrap();
461463
let share = sub.percentages.get(i).unwrap() as i128;
462-
let payout = if i + 1 == creators_len { remaining } else { (amount_to_payout * share) / 100 };
464+
let payout = if i + 1 == creators_len { remaining } else { (amount_to_payout_tokens * share) / 100 };
463465
remaining -= payout;
464466
if payout > 0 {
465467
credit_creator_earnings(env, &creator, payout);
@@ -469,6 +471,7 @@ fn distribute_and_collect(env: &Env, beneficiary: &Address, stream_id: &Address,
469471
}
470472

471473
sub.balance -= amount_to_collect;
474+
sub.accrued_remainder = total_accrued - (amount_to_payout_tokens * PRECISION_MULTIPLIER);
472475
sub.last_collected = now;
473476
set_subscription(env, &key, &sub);
474477
amount_to_collect
@@ -482,7 +485,7 @@ fn top_up_internal(env: &Env, beneficiary: &Address, stream_id: &Address, amount
482485
let token_client = TokenClient::new(env, &sub.token);
483486
token_client.transfer(&sub.payer, &env.current_contract_address(), &amount);
484487

485-
sub.balance += amount;
488+
sub.balance += amount * PRECISION_MULTIPLIER;
486489
if sub.balance > 0 { sub.last_funds_exhausted = 0; }
487490
set_subscription(env, &key, &sub);
488491
distribute_and_collect(env, beneficiary, stream_id, None);
@@ -500,7 +503,10 @@ fn cancel_internal(env: &Env, beneficiary: &Address, stream_id: &Address) {
500503

501504
if sub.balance > 0 {
502505
let token_client = TokenClient::new(env, &sub.token);
503-
token_client.transfer(&env.current_contract_address(), &sub.payer, &sub.balance);
506+
let refund_amount = sub.balance / PRECISION_MULTIPLIER;
507+
if refund_amount > 0 {
508+
token_client.transfer(&env.current_contract_address(), &sub.payer, &refund_amount);
509+
}
504510
}
505511
for i in 0..sub.creators.len() {
506512
let creator = sub.creators.get(i).unwrap();
@@ -510,7 +516,7 @@ fn cancel_internal(env: &Env, beneficiary: &Address, stream_id: &Address) {
510516
env.storage().temporary().remove(&key);
511517
}
512518

513-
fn subscribe_core(env: &Env, payer: &Address, beneficiary: &Address, stream_id: &Address, token: &Address, amount: i128, rate: i128, creators: Vec<Address>, percentages: Vec<u32>) {
519+
fn subscribe_core(env: &Env, payer: &Address, beneficiary: &Address, stream_id: &Address, token: &Address, amount: i128, rate: i128, creators: soroban_sdk::Vec<Address>, percentages: soroban_sdk::Vec<u32>) {
514520
payer.require_auth();
515521
let key = subscription_key(beneficiary, stream_id);
516522
if subscription_exists(env, &key) { panic!("exists"); }
@@ -523,7 +529,7 @@ fn subscribe_core(env: &Env, payer: &Address, beneficiary: &Address, stream_id:
523529
let sub = Subscription {
524530
token: token.clone(),
525531
tier: Tier { rate_per_second: rate, trial_duration: FREE_TRIAL_DURATION },
526-
balance: amount,
532+
balance: amount * PRECISION_MULTIPLIER,
527533
last_collected: now,
528534
start_time: now,
529535
last_funds_exhausted: 0,
@@ -532,6 +538,7 @@ fn subscribe_core(env: &Env, payer: &Address, beneficiary: &Address, stream_id:
532538
percentages,
533539
payer: payer.clone(),
534540
beneficiary: beneficiary.clone(),
541+
accrued_remainder: 0,
535542
};
536543
set_subscription(env, &key, &sub);
537544
for i in 0..creators_for_stats.len() {
@@ -549,3 +556,5 @@ fn is_creator_paused(env: &Env, creator: &Address) -> bool {
549556
mod test;
550557
#[cfg(test)]
551558
mod test_withdrawal_consistency;
559+
#[cfg(test)]
560+
mod test_tiny_streams;

contracts/substream_contracts/src/test.rs

Lines changed: 23 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#![cfg(test)]
1+
#![cfg(test)]
22

33
use super::*;
44
use soroban_sdk::{
@@ -41,7 +41,7 @@ fn test_is_subscribed_active() {
4141
let client = SubStreamContractClient::new(&env, &contract_id);
4242

4343
env.ledger().set_timestamp(100);
44-
client.subscribe(&subscriber, &creator, &token.address, &1000, &1);
44+
client.subscribe(&subscriber, &creator, &token.address, &1000, &1_000_000_000);
4545

4646
env.ledger().set_timestamp(105);
4747
assert!(client.is_subscribed(&subscriber, &creator));
@@ -64,7 +64,7 @@ fn test_is_subscribed_expired() {
6464
let client = SubStreamContractClient::new(&env, &contract_id);
6565

6666
env.ledger().set_timestamp(100);
67-
client.subscribe(&subscriber, &creator, &token.address, &10, &10);
67+
client.subscribe(&subscriber, &creator, &token.address, &10, &10_000_000_000);
6868

6969
env.ledger().set_timestamp(100 + WEEK + 2);
7070
assert!(!client.is_subscribed(&subscriber, &creator));
@@ -145,7 +145,7 @@ fn test_free_trial_ignores_claims_within_first_week() {
145145

146146
let start = 100u64;
147147
env.ledger().set_timestamp(start);
148-
client.subscribe(&subscriber, &creator, &token.address, &300, &3);
148+
client.subscribe(&subscriber, &creator, &token.address, &300, &3_000_000_000);
149149

150150
env.ledger().set_timestamp(start + WEEK - 1);
151151
client.collect(&subscriber, &creator);
@@ -207,7 +207,7 @@ fn test_cancel_before_minimum_duration() {
207207
let client = SubStreamContractClient::new(&env, &contract_id);
208208

209209
env.ledger().set_timestamp(100);
210-
client.subscribe(&subscriber, &creator, &token.address, &100, &1);
210+
client.subscribe(&subscriber, &creator, &token.address, &100, &1_000_000_000);
211211

212212
env.ledger().set_timestamp(100 + 3600);
213213
client.cancel(&subscriber, &creator);
@@ -231,7 +231,7 @@ fn test_cancel_after_minimum_duration() {
231231

232232
let start = 100u64;
233233
env.ledger().set_timestamp(start);
234-
client.subscribe(&subscriber, &creator, &token.address, &100, &1);
234+
client.subscribe(&subscriber, &creator, &token.address, &100, &1_000_000_000);
235235

236236
env.ledger().set_timestamp(start + DAY + 10);
237237
client.cancel(&subscriber, &creator);
@@ -257,7 +257,7 @@ fn test_cancel_exactly_at_minimum_duration() {
257257
let client = SubStreamContractClient::new(&env, &contract_id);
258258

259259
env.ledger().set_timestamp(100);
260-
client.subscribe(&subscriber, &creator, &token.address, &100, &1);
260+
client.subscribe(&subscriber, &creator, &token.address, &100, &1_000_000_000);
261261

262262
env.ledger().set_timestamp(100 + DAY);
263263
client.cancel(&subscriber, &creator);
@@ -288,7 +288,7 @@ fn test_top_up() {
288288
let client = SubStreamContractClient::new(&env, &contract_id);
289289

290290
env.ledger().set_timestamp(0);
291-
client.subscribe(&subscriber, &creator, &token.address, &100, &1);
291+
client.subscribe(&subscriber, &creator, &token.address, &100, &1_000_000_000);
292292
assert_eq!(token.balance(&contract_id), 100);
293293

294294
client.top_up(&subscriber, &creator, &50);
@@ -343,7 +343,7 @@ fn test_group_subscribe_and_collect_split() {
343343
&channel_id,
344344
&token.address,
345345
&500,
346-
&10,
346+
&10_000_000_000,
347347
&creators,
348348
&percentages,
349349
);
@@ -394,7 +394,7 @@ fn test_group_requires_exactly_five_creators() {
394394
&channel_id,
395395
&token.address,
396396
&100,
397-
&1,
397+
&1_000_000_000,
398398
&creators,
399399
&percentages,
400400
);
@@ -437,7 +437,7 @@ fn test_group_percentages_must_sum_to_100() {
437437
&channel_id,
438438
&token.address,
439439
&100,
440-
&1,
440+
&1_000_000_000,
441441
&creators,
442442
&percentages,
443443
);
@@ -480,7 +480,7 @@ fn test_group_cancel_collects_and_refunds_remaining_balance() {
480480
&channel_id,
481481
&token.address,
482482
&200,
483-
&1,
483+
&1_000_000_000,
484484
&creators,
485485
&percentages,
486486
);
@@ -592,7 +592,7 @@ fn test_flash_stream_attack_within_single_ledger() {
592592
env.ledger().set_timestamp(ledger_time);
593593

594594
// Attacker subscribes with minimal amount to bypass content gates
595-
client.subscribe(&attacker, &creator, &token.address, &10, &1);
595+
client.subscribe(&attacker, &creator, &token.address, &10, &1_000_000_000);
596596

597597
// Check that subscription is active immediately (within same ledger)
598598
assert!(client.is_subscribed(&attacker, &creator));
@@ -636,7 +636,7 @@ fn test_flash_stream_attack_multiple_quick_subscriptions() {
636636
let subscriber = Address::generate(&env);
637637

638638
// Subscribe with minimal amount
639-
client.subscribe(&subscriber, &creator, &token.address, &5, &1);
639+
client.subscribe(&subscriber, &creator, &token.address, &5, &1_000_000_000);
640640

641641
// Verify subscription is active
642642
assert!(client.is_subscribed(&subscriber, &creator));
@@ -668,7 +668,7 @@ fn test_flash_stream_attack_grace_period_exploitation() {
668668
env.ledger().set_timestamp(start_time);
669669

670670
// Subscribe with very small amount that will be exhausted quickly
671-
client.subscribe(&attacker, &creator, &token.address, &10, &100);
671+
client.subscribe(&attacker, &creator, &token.address, &10, &100_000_000_000);
672672

673673
// Fast forward to exhaust funds but stay within grace period
674674
let exhaust_time = start_time + 10; // 10 seconds later
@@ -686,7 +686,7 @@ fn test_flash_stream_attack_grace_period_exploitation() {
686686

687687
env.ledger().set_timestamp(exhaust_time + 1); // 1 second later
688688

689-
client.subscribe(&new_attacker, &creator, &token.address, &5, &1);
689+
client.subscribe(&new_attacker, &creator, &token.address, &5, &1_000_000_000);
690690

691691
// Both subscriptions should be active (original in grace period, new one active)
692692
assert!(client.is_subscribed(&attacker, &creator));
@@ -722,7 +722,7 @@ fn test_blacklist_user_prevents_subscription() {
722722

723723
// Attempt to subscribe should fail
724724
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
725-
client.subscribe(&malicious_user, &creator, &token.address, &100, &1);
725+
client.subscribe(&malicious_user, &creator, &token.address, &100, &1_000_000_000);
726726
}));
727727

728728
assert!(result.is_err());
@@ -754,7 +754,7 @@ fn test_unblacklist_user_allows_subscription() {
754754
assert!(!client.is_user_blacklisted(&creator, &user));
755755

756756
// Now subscription should work
757-
client.subscribe(&user, &creator, &token.address, &100, &1);
757+
client.subscribe(&user, &creator, &token.address, &100, &1_000_000_000);
758758
assert!(client.is_subscribed(&user, &creator));
759759
}
760760

@@ -835,7 +835,7 @@ fn test_blacklist_prevents_group_subscription() {
835835
&channel_id,
836836
&token.address,
837837
&100,
838-
&1,
838+
&1_000_000_000,
839839
&creators,
840840
&percentages,
841841
);
@@ -871,12 +871,12 @@ fn test_blacklist_only_affects_specific_creator() {
871871

872872
// Subscription to creator_1 should fail
873873
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
874-
client.subscribe(&user, &creator_1, &token.address, &100, &1);
874+
client.subscribe(&user, &creator_1, &token.address, &100, &1_000_000_000);
875875
}));
876876
assert!(result.is_err());
877877

878878
// Subscription to creator_2 should succeed
879-
client.subscribe(&user, &creator_2, &token.address, &100, &1);
879+
client.subscribe(&user, &creator_2, &token.address, &100, &1_000_000_000);
880880
assert!(client.is_subscribed(&user, &creator_2));
881881
}
882882

@@ -898,7 +898,7 @@ fn test_blacklist_with_existing_subscription() {
898898
let client = SubStreamContractClient::new(&env, &contract_id);
899899

900900
// User subscribes first
901-
client.subscribe(&user, &creator, &token.address, &100, &1);
901+
client.subscribe(&user, &creator, &token.address, &100, &1_000_000_000);
902902
assert!(client.is_subscribed(&user, &creator));
903903

904904
// Creator then blacklists the user
@@ -912,7 +912,7 @@ fn test_blacklist_with_existing_subscription() {
912912
client.cancel(&user, &creator);
913913

914914
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
915-
client.subscribe(&user, &creator, &token.address, &100, &1);
915+
client.subscribe(&user, &creator, &token.address, &100, &1_000_000_000);
916916
}));
917917
assert!(result.is_err());
918918
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#![cfg(test)]
2+
3+
use super::*;
4+
use soroban_sdk::{
5+
testutils::{Address as _, Ledger},
6+
token, Address, Env,
7+
};
8+
9+
fn create_token_contract<'a>(env: &Env, admin: &Address) -> token::Client<'a> {
10+
let sac = env.register_stellar_asset_contract_v2(admin.clone());
11+
token::Client::new(env, &sac.address())
12+
}
13+
14+
#[test]
15+
fn test_tiny_stream_rounding() {
16+
let env = Env::default();
17+
env.mock_all_auths();
18+
19+
let subscriber = Address::generate(&env);
20+
let creator = Address::generate(&env);
21+
let admin = Address::generate(&env);
22+
23+
let token = create_token_contract(&env, &admin);
24+
let token_admin = token::StellarAssetClient::new(&env, &token.address);
25+
token_admin.mint(&subscriber, &1_000_000_000);
26+
27+
let contract_id = env.register(SubStreamContract, ());
28+
let client = SubStreamContractClient::new(&env, &contract_id);
29+
30+
let start = 100u64;
31+
env.ledger().set_timestamp(start);
32+
33+
// Suppose we want a stream of 1 unit every 2 seconds (0.5 units/sec).
34+
// Now we can specify it as 0.5 * PRECISION_MULTIPLIER = 500,000_000
35+
let rate = 500_000_000;
36+
37+
// Subscribe with 1000 units
38+
client.subscribe(&subscriber, &creator, &token.address, &1000, &rate);
39+
40+
let week = 7 * 24 * 60 * 60;
41+
env.ledger().set_timestamp(start + week + 100);
42+
client.collect(&subscriber, &creator);
43+
44+
// 100 seconds after trial at 0.5 units/sec = 50 units.
45+
assert_eq!(token.balance(&creator), 50, "Rate 0.5 should stream 50 units in 100 seconds");
46+
47+
// Now let's try a very low but non-zero rate: 10 units per week.
48+
// 10 units / (7*24*3600) sec = 10 / 604800 units/sec.
49+
// Represented in nano: (10 * 10^9) / 604800 = 16534.
50+
let tiny_rate = 16534;
51+
let subscriber2 = Address::generate(&env);
52+
token_admin.mint(&subscriber2, &1_000_000_000);
53+
client.subscribe(&subscriber2, &creator, &token.address, &1000, &tiny_rate);
54+
55+
env.ledger().set_timestamp(start + week + week + week + 100);
56+
client.collect(&subscriber2, &creator);
57+
assert_eq!(token.balance(&creator), 50 + 9, "Should be 9 units (9.9997 accrued)");
58+
59+
// Wait 100 more seconds - the 0.0003 remainder + 100 * 16534 should reach 10 units
60+
env.ledger().set_timestamp(start + week + week + week + 200);
61+
client.collect(&subscriber2, &creator);
62+
assert_eq!(token.balance(&creator), 50 + 10, "Should have accumulated enough dust to reach 10th unit");
63+
}

0 commit comments

Comments
 (0)