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
1 change: 1 addition & 0 deletions contracts/stream_contract/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
name = "stream_contract"
version = "0.1.0"
edition = "2021"
description = "Soroban payment-streaming contract with protocol fees"

[lib]
crate-type = ["cdylib"]
Expand Down
113 changes: 113 additions & 0 deletions contracts/stream_contract/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# `stream_contract`

Soroban smart contract for time-based token streaming with optional protocol fees.

## Overview

`stream_contract` lets a sender deposit tokens into a stream that accrues linearly to a recipient over time.
The contract supports stream creation, top-ups, withdrawals, cancellation, pause/resume controls, and protocol fee administration.

- Fee cap: `MAX_FEE_RATE_BPS = 1000` (10%)
- Fee unit: basis points (`bps`), where `100 bps = 1%`
- Fee collection points: `create_stream`, `top_up_stream`

## Public API

All entrypoints are in `src/lib.rs` under `impl StreamContract`.

### Protocol administration

| Function | Purpose |
|---|---|
| `initialize(env, admin, treasury, fee_rate_bps)` | One-time protocol config setup |
| `update_fee_config(env, admin, treasury, fee_rate_bps)` | Update treasury and/or fee rate (admin-only) |
| `transfer_admin(env, current_admin, new_admin)` | Transfer admin role |
| `get_fee_config(env)` | Read current fee config (`Option<ProtocolConfig>`) |

### Stream lifecycle

| Function | Purpose |
|---|---|
| `create_stream(env, sender, recipient, token_address, amount, duration)` | Create stream from deposited funds |
| `top_up_stream(env, sender, stream_id, amount)` | Add more funds to an active stream |
| `withdraw(env, recipient, stream_id)` | Recipient withdraws currently claimable amount |
| `cancel_stream(env, sender, stream_id)` | Sender cancels stream and receives remaining balance |
| `pause_stream(env, sender, stream_id)` | Freeze accrual on an active stream |
| `resume_stream(env, sender, stream_id)` | Resume accrual and recompute stream end time |

### Read-only queries

| Function | Purpose |
|---|---|
| `get_stream(env, stream_id)` | Return full stream record (`Option<Stream>`) |
| `is_stream_completed(env, stream_id)` | Return completion status |
| `get_claimable_amount(env, stream_id)` | Compute current claimable amount without state changes |

## Fee and treasury model

Protocol fee config is optional; if not initialized, fee collection is a no-op.

When initialized and `fee_rate_bps > 0`:

- Fee formula: `fee = amount * fee_rate_bps / 10_000`
- Net credited to stream: `amount - fee`
- Fee recipient: configured `treasury` address
- Fee event: `fee_collected` is emitted only when `fee > 0`

### Rounding behavior

Fee math uses integer division. For tiny amounts, fee can round down to zero.

Example:
- `amount = 1`
- `fee_rate_bps = 200` (2%)
- `fee = 1 * 200 / 10_000 = 0`

In this case:
- no transfer to treasury occurs,
- no `fee_collected` event is emitted,
- full amount is credited to the stream.

## Event topics

Events are emitted with the following topics (see `src/events.rs`):

| Event struct | Topic |
|---|---|
| `InitializedEvent` | `("initialized",)` |
| `FeeConfigUpdatedEvent` | `("fee_config_updated",)` |
| `AdminTransferredEvent` | `("admin_transferred",)` |
| `StreamCreatedEvent` | `("stream_created", stream_id)` |
| `StreamToppedUpEvent` | `("stream_topped_up", stream_id)` |
| `TokensWithdrawnEvent` | `("tokens_withdrawn", stream_id)` |
| `StreamCancelledEvent` | `("stream_cancelled", stream_id)` |
| `StreamPausedEvent` | `("stream_paused", stream_id)` |
| `StreamResumedEvent` | `("stream_resumed", stream_id)` |
| `StreamCompletedEvent` | `("stream_completed", stream_id)` |
| `FeeCollectedEvent` | `("fee_collected", stream_id)` |

## `StreamError` reference

Error codes from `src/errors.rs`:

| Code | Variant | Meaning |
|---:|---|---|
| 1 | `InvalidAmount` | Amount is zero/negative/out of range |
| 2 | `StreamNotFound` | Stream ID does not exist |
| 3 | `Unauthorized` | Caller not authorized for stream action |
| 4 | `StreamInactive` | Operation requires an active stream |
| 5 | `AlreadyInitialized` | `initialize` called more than once |
| 6 | `NotAdmin` | Caller is not protocol admin |
| 7 | `InvalidFeeRate` | Fee exceeds `MAX_FEE_RATE_BPS` |
| 8 | `NotInitialized` | Protocol config not initialized |
| 9 | `InvalidDuration` | Duration is zero |
| 10 | `InvalidTokenAddress` | Token address is not a token contract |
| 11 | `InvalidRate` | `amount / duration` rounds to zero |

## Typical flow

1. Admin calls `initialize` with treasury and fee rate.
2. Sender calls `create_stream`.
3. Sender may call `top_up_stream`, `pause_stream`, `resume_stream`, or `cancel_stream`.
4. Recipient calls `withdraw` over time until fully drained.
5. Final withdrawal emits `stream_completed`.
2 changes: 2 additions & 0 deletions contracts/stream_contract/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#![no_std]
#![doc = include_str!("../README.md")]

mod errors;
mod events;
Expand Down Expand Up @@ -661,6 +662,7 @@ impl StreamContract {
/// emits a `fee_collected` event, and returns the net amount.
///
/// If no protocol config exists or the fee rate is 0, returns `amount` unchanged.
/// If fee calculation truncates to 0, no transfer/event occurs and `amount` is unchanged.
/// Time complexity: O(1).
fn collect_fee(env: &Env, token_address: &Address, amount: i128, stream_id: u64) -> i128 {
match try_load_config(env) {
Expand Down
34 changes: 34 additions & 0 deletions contracts/stream_contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,40 @@ fn test_no_fee_event_when_fee_rate_is_zero() {
);
}

#[test]
fn test_no_fee_transfer_or_event_when_fee_rounds_to_zero() {
let env = Env::default();
env.mock_all_auths();
let (token, _) = create_token(&env);
let sender = Address::generate(&env);
let admin = Address::generate(&env);
let treasury = Address::generate(&env);
mint(&env, &token, &sender, 1_000);

let client = create_contract(&env);
let token_client = token::Client::new(&env, &token);

// Non-zero fee rate, but tiny amount => fee rounds down to 0:
// 1 * 200 / 10_000 = 0
client.initialize(&admin, &treasury, &200);
let id = client.create_stream(&sender, &Address::generate(&env), &token, &1, &1);

assert_eq!(token_client.balance(&treasury), 0);

let s = client.get_stream(&id).unwrap();
assert_eq!(s.deposited_amount, 1);

let events = env.events().all();
let fee_event = events.iter().find(|e| {
Symbol::try_from_val(&env, &e.1.get(0).unwrap()).unwrap()
== Symbol::new(&env, "fee_collected")
});
assert!(
fee_event.is_none(),
"fee_collected must not fire when rounded fee is 0"
);
}

#[test]
fn test_no_fee_without_protocol_config() {
let env = Env::default();
Expand Down
Loading
Loading