Skip to content

Commit 9613618

Browse files
authored
Merge pull request #1129 from Mikey-222/mikey/snapshots-and-tests
Add snapshot docs, concurrent streams test, cumulative fee rounding d…
2 parents 968754f + 305bdf8 commit 9613618

3 files changed

Lines changed: 140 additions & 0 deletions

File tree

contracts/stream_contract/README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ Error codes from `src/errors.rs`:
104104
| 10 | `InvalidTokenAddress` | Token address is not a token contract |
105105
| 11 | `InvalidRate` | `amount / duration` rounds to zero |
106106

107+
## Test Snapshots
108+
109+
The Soroban test runner can generate storage snapshots under `test_snapshots/` when `SOROBAN_TEST_SNAPSHOTS=1` is set. See [`test_snapshots/README.md`](test_snapshots/README.md) for what they are, when they regenerate, and how to handle a snapshot diff in your PR.
110+
107111
## Typical flow
108112

109113
1. Admin calls `initialize` with treasury and fee rate.

contracts/stream_contract/src/test.rs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2717,6 +2717,115 @@ fn event_field_names(env: &Env, payload: &soroban_sdk::Val) -> std::vec::Vec<std
27172717
names
27182718
}
27192719

2720+
// ─── Concurrent streams (same sender/recipient/token) ─────────────────────────
2721+
2722+
#[test]
2723+
fn test_concurrent_streams_same_tuple_independent_state() {
2724+
let env = Env::default();
2725+
env.mock_all_auths();
2726+
let (token, _) = create_token(&env);
2727+
let sender = Address::generate(&env);
2728+
let recipient = Address::generate(&env);
2729+
mint(&env, &token, &sender, 2_000);
2730+
2731+
let client = create_contract(&env);
2732+
let id1 = client.create_stream(&sender, &recipient, &token, &1_000, &100);
2733+
let id2 = client.create_stream(&sender, &recipient, &token, &1_000, &100);
2734+
2735+
// Both streams must exist and have distinct IDs.
2736+
assert_ne!(id1, id2);
2737+
let s1 = client.get_stream(&id1).unwrap();
2738+
let s2 = client.get_stream(&id2).unwrap();
2739+
assert_eq!(s1.deposited_amount, 1_000);
2740+
assert_eq!(s2.deposited_amount, 1_000);
2741+
assert_eq!(s1.withdrawn_amount, 0);
2742+
assert_eq!(s2.withdrawn_amount, 0);
2743+
2744+
// Advance time and withdraw from stream 1 only.
2745+
env.ledger().with_mut(|l| l.timestamp += 50);
2746+
let claimed1 = client.withdraw(&recipient, &id1);
2747+
assert_eq!(claimed1, 500); // 50 s * (1 000 / 100) = 500
2748+
2749+
// Stream 2 must be unaffected.
2750+
let s2_after = client.get_stream(&id2).unwrap();
2751+
assert_eq!(s2_after.withdrawn_amount, 0);
2752+
assert_eq!(s2_after.deposited_amount, 1_000);
2753+
2754+
// Advance more time and withdraw from stream 2.
2755+
env.ledger().with_mut(|l| l.timestamp += 50);
2756+
let claimed2 = client.withdraw(&recipient, &id2);
2757+
assert_eq!(claimed2, 1_000); // 100 s * 10 rate = 1 000 (full stream)
2758+
2759+
// Stream 1 must still have its original withdrawn amount unchanged.
2760+
let s1_final = client.get_stream(&id1).unwrap();
2761+
assert_eq!(s1_final.withdrawn_amount, 500);
2762+
}
2763+
2764+
// ─── Cumulative fee rounding drift ────────────────────────────────────────────
2765+
//
2766+
// The protocol fee uses integer division: fee = amount * fee_rate_bps / 10_000.
2767+
// When many small deposits are made sequentially, each individual fee may round
2768+
// down (due to integer truncation), causing the sum of collected fees to be
2769+
// slightly less than fee_rate_bps/10_000 of the gross total. This test verifies
2770+
// the drift stays within an acceptable tolerance.
2771+
//
2772+
// Rounding direction: favours the user (the protocol receives ≤ the ideal fee).
2773+
2774+
#[test]
2775+
fn test_cumulative_fee_rounding_drift() {
2776+
let env = Env::default();
2777+
env.mock_all_auths();
2778+
let (token, _) = create_token(&env);
2779+
let sender = Address::generate(&env);
2780+
let treasury = Address::generate(&env);
2781+
let admin = Address::generate(&env);
2782+
let recipient = Address::generate(&env);
2783+
2784+
let fee_rate_bps: u32 = 199;
2785+
mint(&env, &token, &sender, 10_000_000);
2786+
2787+
let client = create_contract(&env);
2788+
let token_client = token::Client::new(&env, &token);
2789+
client.initialize(&admin, &treasury, &fee_rate_bps);
2790+
2791+
let id = client.create_stream(&sender, &recipient, &token, &100_000, &10_000);
2792+
2793+
// Perform 200 small sequential top-ups, each for 101 tokens.
2794+
// Per top-up: fee = 101 * 199 / 10_000 = 20_099 / 10_000 = 2 (rounded down).
2795+
let top_up_count = 200;
2796+
let per_top_up = 101i128;
2797+
for _ in 0..top_up_count {
2798+
mint(&env, &token, &sender, per_top_up);
2799+
client.top_up_stream(&sender, &id, &per_top_up);
2800+
}
2801+
2802+
let total_gross = 100_000i128 + (top_up_count as i128) * per_top_up;
2803+
let ideal_fee = (total_gross * fee_rate_bps as i128) / 10_000;
2804+
let actual_fee = token_client.balance(&treasury);
2805+
2806+
// Each individual top-up of 101 * 199 / 10000 = 2.0099 → 2, losing 0.0099 per op.
2807+
// Over 200 ops: at most 200 * 0.0099 ≈ 1.98 tokens of downward drift.
2808+
// Allow tolerance of 2 tokens (enforced by `max_drift`).
2809+
let max_drift = top_up_count as i128;
2810+
let drift = ideal_fee - actual_fee;
2811+
assert!(
2812+
drift >= 0,
2813+
"Fee collected ({}) exceeds ideal ({}) — rounding favoured protocol (unexpected)",
2814+
actual_fee,
2815+
ideal_fee
2816+
);
2817+
assert!(
2818+
drift <= max_drift,
2819+
"Fee drift too large: ideal={ideal_fee}, actual={actual_fee}, drift={drift}, max={max_drift}"
2820+
);
2821+
}
2822+
2823+
// ─── update_fee_config ceiling enforcement ─────────────────────────────────────
2824+
//
2825+
// The existing test `test_update_fee_config_rejects_invalid_fee_rate` at line 171
2826+
// already verifies that `update_fee_config` rejects a rate above MAX_FEE_RATE_BPS
2827+
// (1 000). The implementation check is at `lib.rs:95-97`.
2828+
27202829
#[test]
27212830
fn test_stream_created_event_field_names_match_decoder_expectations() {
27222831
let env = Env::default();
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# Test Snapshots
2+
3+
This directory is auto-generated by the Soroban test runner when snapshot testing is enabled. Snapshots record the persistent storage state and event output of each `#[test]` — changes in a snapshot diff signal that a test's on-chain behavior has changed.
4+
5+
## When snapshots are regenerated
6+
7+
Snapshots are regenerated whenever you run `cargo test` (or the workspace equivalent) with the `SOROBAN_TEST_SNAPSHOTS` environment variable set:
8+
9+
```shell
10+
SOROBAN_TEST_SNAPSHOTS=1 cargo test
11+
```
12+
13+
Without this variable, snapshot files are not written and existing snapshots are ignored.
14+
15+
## What to do when a snapshot diff appears in your PR
16+
17+
A snapshot diff in `git status` means either:
18+
19+
1. **You intentionally changed contract behavior** (e.g. a new feature, a bug fix, a storage layout change) — regenerate the snapshots on your branch and commit the updated files so CI matches.
20+
21+
2. **You did not expect behavior to change** — investigate whether the diff reveals a real logic regression. The snapshot captures the raw storage keys and event payloads; a change in `deposited_amount`, `rate_per_second`, or a missing event may indicate a subtle bug.
22+
23+
**Always regenerate and commit snapshots alongside the code change that causes them.** A PR that alters contract logic without updating snapshots will fail CI and confuse reviewers.
24+
25+
## Out of scope
26+
27+
Snapshots are a Soroban framework feature, not a custom testing tool. This file explains their workflow purpose only.

0 commit comments

Comments
 (0)