Skip to content

Commit 8cf5d81

Browse files
authored
Merge pull request #978 from Killerjunior/pr-946-clean
Pr 946 clean
2 parents 297d231 + c075679 commit 8cf5d81

4 files changed

Lines changed: 150 additions & 0 deletions

File tree

contracts/stream_contract/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
name = "stream_contract"
33
version = "0.1.0"
44
edition = "2021"
5+
description = "Soroban payment-streaming contract with protocol fees"
56

67
[lib]
78
crate-type = ["cdylib"]
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# `stream_contract`
2+
3+
Soroban smart contract for time-based token streaming with optional protocol fees.
4+
5+
## Overview
6+
7+
`stream_contract` lets a sender deposit tokens into a stream that accrues linearly to a recipient over time.
8+
The contract supports stream creation, top-ups, withdrawals, cancellation, pause/resume controls, and protocol fee administration.
9+
10+
- Fee cap: `MAX_FEE_RATE_BPS = 1000` (10%)
11+
- Fee unit: basis points (`bps`), where `100 bps = 1%`
12+
- Fee collection points: `create_stream`, `top_up_stream`
13+
14+
## Public API
15+
16+
All entrypoints are in `src/lib.rs` under `impl StreamContract`.
17+
18+
### Protocol administration
19+
20+
| Function | Purpose |
21+
|---|---|
22+
| `initialize(env, admin, treasury, fee_rate_bps)` | One-time protocol config setup |
23+
| `update_fee_config(env, admin, treasury, fee_rate_bps)` | Update treasury and/or fee rate (admin-only) |
24+
| `transfer_admin(env, current_admin, new_admin)` | Transfer admin role |
25+
| `get_fee_config(env)` | Read current fee config (`Option<ProtocolConfig>`) |
26+
27+
### Stream lifecycle
28+
29+
| Function | Purpose |
30+
|---|---|
31+
| `create_stream(env, sender, recipient, token_address, amount, duration)` | Create stream from deposited funds |
32+
| `top_up_stream(env, sender, stream_id, amount)` | Add more funds to an active stream |
33+
| `withdraw(env, recipient, stream_id)` | Recipient withdraws currently claimable amount |
34+
| `cancel_stream(env, sender, stream_id)` | Sender cancels stream and receives remaining balance |
35+
| `pause_stream(env, sender, stream_id)` | Freeze accrual on an active stream |
36+
| `resume_stream(env, sender, stream_id)` | Resume accrual and recompute stream end time |
37+
38+
### Read-only queries
39+
40+
| Function | Purpose |
41+
|---|---|
42+
| `get_stream(env, stream_id)` | Return full stream record (`Option<Stream>`) |
43+
| `is_stream_completed(env, stream_id)` | Return completion status |
44+
| `get_claimable_amount(env, stream_id)` | Compute current claimable amount without state changes |
45+
46+
## Fee and treasury model
47+
48+
Protocol fee config is optional; if not initialized, fee collection is a no-op.
49+
50+
When initialized and `fee_rate_bps > 0`:
51+
52+
- Fee formula: `fee = amount * fee_rate_bps / 10_000`
53+
- Net credited to stream: `amount - fee`
54+
- Fee recipient: configured `treasury` address
55+
- Fee event: `fee_collected` is emitted only when `fee > 0`
56+
57+
### Rounding behavior
58+
59+
Fee math uses integer division. For tiny amounts, fee can round down to zero.
60+
61+
Example:
62+
- `amount = 1`
63+
- `fee_rate_bps = 200` (2%)
64+
- `fee = 1 * 200 / 10_000 = 0`
65+
66+
In this case:
67+
- no transfer to treasury occurs,
68+
- no `fee_collected` event is emitted,
69+
- full amount is credited to the stream.
70+
71+
## Event topics
72+
73+
Events are emitted with the following topics (see `src/events.rs`):
74+
75+
| Event struct | Topic |
76+
|---|---|
77+
| `InitializedEvent` | `("initialized",)` |
78+
| `FeeConfigUpdatedEvent` | `("fee_config_updated",)` |
79+
| `AdminTransferredEvent` | `("admin_transferred",)` |
80+
| `StreamCreatedEvent` | `("stream_created", stream_id)` |
81+
| `StreamToppedUpEvent` | `("stream_topped_up", stream_id)` |
82+
| `TokensWithdrawnEvent` | `("tokens_withdrawn", stream_id)` |
83+
| `StreamCancelledEvent` | `("stream_cancelled", stream_id)` |
84+
| `StreamPausedEvent` | `("stream_paused", stream_id)` |
85+
| `StreamResumedEvent` | `("stream_resumed", stream_id)` |
86+
| `StreamCompletedEvent` | `("stream_completed", stream_id)` |
87+
| `FeeCollectedEvent` | `("fee_collected", stream_id)` |
88+
89+
## `StreamError` reference
90+
91+
Error codes from `src/errors.rs`:
92+
93+
| Code | Variant | Meaning |
94+
|---:|---|---|
95+
| 1 | `InvalidAmount` | Amount is zero/negative/out of range |
96+
| 2 | `StreamNotFound` | Stream ID does not exist |
97+
| 3 | `Unauthorized` | Caller not authorized for stream action |
98+
| 4 | `StreamInactive` | Operation requires an active stream |
99+
| 5 | `AlreadyInitialized` | `initialize` called more than once |
100+
| 6 | `NotAdmin` | Caller is not protocol admin |
101+
| 7 | `InvalidFeeRate` | Fee exceeds `MAX_FEE_RATE_BPS` |
102+
| 8 | `NotInitialized` | Protocol config not initialized |
103+
| 9 | `InvalidDuration` | Duration is zero |
104+
| 10 | `InvalidTokenAddress` | Token address is not a token contract |
105+
| 11 | `InvalidRate` | `amount / duration` rounds to zero |
106+
107+
## Typical flow
108+
109+
1. Admin calls `initialize` with treasury and fee rate.
110+
2. Sender calls `create_stream`.
111+
3. Sender may call `top_up_stream`, `pause_stream`, `resume_stream`, or `cancel_stream`.
112+
4. Recipient calls `withdraw` over time until fully drained.
113+
5. Final withdrawal emits `stream_completed`.

contracts/stream_contract/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#![no_std]
2+
#![doc = include_str!("../README.md")]
23

34
mod errors;
45
mod events;
@@ -667,6 +668,7 @@ impl StreamContract {
667668
/// emits a `fee_collected` event, and returns the net amount.
668669
///
669670
/// If no protocol config exists or the fee rate is 0, returns `amount` unchanged.
671+
/// If fee calculation truncates to 0, no transfer/event occurs and `amount` is unchanged.
670672
/// Time complexity: O(1).
671673
fn collect_fee(env: &Env, token_address: &Address, amount: i128, stream_id: u64) -> i128 {
672674
match try_load_config(env) {

contracts/stream_contract/src/test.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,40 @@ fn test_no_fee_event_when_fee_rate_is_zero() {
885885
);
886886
}
887887

888+
#[test]
889+
fn test_no_fee_transfer_or_event_when_fee_rounds_to_zero() {
890+
let env = Env::default();
891+
env.mock_all_auths();
892+
let (token, _) = create_token(&env);
893+
let sender = Address::generate(&env);
894+
let admin = Address::generate(&env);
895+
let treasury = Address::generate(&env);
896+
mint(&env, &token, &sender, 1_000);
897+
898+
let client = create_contract(&env);
899+
let token_client = token::Client::new(&env, &token);
900+
901+
// Non-zero fee rate, but tiny amount => fee rounds down to 0:
902+
// 1 * 200 / 10_000 = 0
903+
client.initialize(&admin, &treasury, &200);
904+
let id = client.create_stream(&sender, &Address::generate(&env), &token, &1, &1);
905+
906+
assert_eq!(token_client.balance(&treasury), 0);
907+
908+
let s = client.get_stream(&id).unwrap();
909+
assert_eq!(s.deposited_amount, 1);
910+
911+
let events = env.events().all();
912+
let fee_event = events.iter().find(|e| {
913+
Symbol::try_from_val(&env, &e.1.get(0).unwrap()).unwrap()
914+
== Symbol::new(&env, "fee_collected")
915+
});
916+
assert!(
917+
fee_event.is_none(),
918+
"fee_collected must not fire when rounded fee is 0"
919+
);
920+
}
921+
888922
#[test]
889923
fn test_no_fee_without_protocol_config() {
890924
let env = Env::default();

0 commit comments

Comments
 (0)