Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4cea8a6
fix: add E010 error constant for employer==employee validation (#63)
Gloriachinedu Apr 25, 2026
57d525a
docs: add mainnet deployment runbook (#86)
Gloriachinedu Apr 25, 2026
6f4ff46
docs: add frontend integration guide (#91)
Gloriachinedu Apr 25, 2026
b6d82a1
feat: add SDK usage examples in JS, Python, and Rust (#95)
Gloriachinedu Apr 25, 2026
eb60d51
Issue #75: add fund-lock audit documentation and recovery regression …
Gina-georgina Apr 26, 2026
4d973fd
Issue #97: add performance benchmark documentation and API reference …
Gina-georgina Apr 26, 2026
7f06360
Issue #87: add event schema reference documentation
Gina-georgina Apr 26, 2026
4a8573f
feat: TypeScript SDK, Freighter wallet, pollClaimable, and demo app
zeekman Apr 26, 2026
69133fa
Merge pull request #172 from zeekman/feat/sdk-and-demo
Vera3289 Apr 26, 2026
a7c02b6
Merge pull request #170 from Gina-georgina/issue-87-event-schema-docs
Vera3289 Apr 26, 2026
dc66757
Merge pull request #168 from Gina-georgina/issue-75-fund-lock-audit
Vera3289 Apr 26, 2026
39404de
Merge branch 'main' into issue-97-performance-docs
Vera3289 Apr 26, 2026
18d8ee2
Merge pull request #169 from Gina-georgina/issue-97-performance-docs
Vera3289 Apr 26, 2026
b6daf29
Merge pull request #165 from Gloriachinedu/docs/86-mainnet-runbook
Vera3289 Apr 26, 2026
a399926
Merge pull request #167 from Gloriachinedu/feat/95-sdk-examples
Vera3289 Apr 26, 2026
e334211
Merge pull request #166 from Gloriachinedu/docs/91-frontend-integration
Vera3289 Apr 26, 2026
146300a
Merge pull request #164 from Gloriachinedu/fix/63-employer-ne-employee
Vera3289 Apr 26, 2026
9ee987f
feat: add protocol fee mechanism (issue #125)
devSoniia Apr 26, 2026
ac9bb85
Merge branch 'main' into feat/protocol-fee-125
Vera3289 Apr 26, 2026
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ The `cargo-cache` volume persists the Cargo registry between runs so subsequent
## Stream Contract Reference

> Full parameter, return value, error, and example documentation: **[docs/api-reference.md](docs/api-reference.md)**
>
> SDK examples (JavaScript, Python, Rust): **[examples/](examples/)**
>
> Frontend integration guide (TypeScript): **[docs/integration/frontend.md](docs/integration/frontend.md)**

### Functions

Expand Down
64 changes: 56 additions & 8 deletions contracts/stream/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ mod test;

use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, Vec};
use storage::{
claimable_amount, consume_admin_nonce, get_admin, get_admin_nonce, get_employee_streams,
get_employer_streams, get_min_deposit, index_employee_stream, index_employer_stream,
load_stream, next_id, save_stream, set_admin, set_min_deposit,
claimable_amount, clear_pending_admin, consume_admin_nonce, get_admin, get_admin_nonce,
get_employee_streams, get_employer_streams, get_fee_bps, get_fee_recipient, get_min_deposit,
get_pending_admin, index_employee_stream, index_employer_stream, load_stream, next_id,
save_stream, set_admin, set_fee_bps, set_fee_recipient, set_min_deposit, set_pending_admin,
};
use types::{
DataKey, Stream, StreamParams, StreamStatus, ERR_REENTRANT, ERR_STREAM_CANCELLED,
ERR_STREAM_EXHAUSTED, ERR_ZERO_DEPOSIT, ERR_ZERO_RATE,
DataKey, Stream, StreamParams, StreamStatus, ERR_FEE_TOO_HIGH, ERR_OVERFLOW, ERR_REENTRANT,
ERR_STREAM_CANCELLED, ERR_STREAM_EXHAUSTED, ERR_WITHDRAW_COOLDOWN, ERR_ZERO_DEPOSIT,
ERR_ZERO_RATE,
};
use validate::{validate_create_stream, validate_top_up};

Expand Down Expand Up @@ -143,6 +145,31 @@ impl StreamContract {
set_min_deposit(&env, amount);
}

/// Admin configures the protocol fee collected on each withdrawal.
///
/// The fee is expressed in basis points (1 bps = 0.01%). Maximum is 100 bps (1%).
/// Set `fee_bps` to 0 to disable the fee entirely.
///
/// # Parameters
/// - `admin` — must match the stored admin (requires auth)
/// - `nonce` — current admin nonce (replay protection)
/// - `fee_bps` — fee in basis points (0–100)
/// - `fee_recipient` — address that receives collected fees (required when fee_bps > 0)
///
/// # Errors
/// - Panics if `admin` auth fails or does not match stored admin
/// - E009 if `nonce` is wrong
/// - E011 if `fee_bps` > 100
pub fn set_protocol_fee(env: Env, admin: Address, nonce: u64, fee_bps: u32, fee_recipient: Address) {
admin.require_auth();
let stored_admin = get_admin(&env);
assert_eq!(admin, stored_admin, "not the admin");
consume_admin_nonce(&env, nonce);
assert!(fee_bps <= 100, "{}", ERR_FEE_TOO_HIGH);
set_fee_bps(&env, fee_bps);
set_fee_recipient(&env, &fee_recipient);
}

/// Employer creates a salary stream and deposits funds into the contract escrow.
///
/// Tokens are transferred from `employer` to the contract immediately.
Expand Down Expand Up @@ -329,12 +356,33 @@ impl StreamContract {
}

let token_client = token::Client::new(&env, &stream.token);
token_client.transfer(&env.current_contract_address(), &employee, &amount);

// Deduct protocol fee if configured.
let fee_bps = get_fee_bps(&env);
let employee_amount = if fee_bps > 0 {
if let Some(recipient) = get_fee_recipient(&env) {
// fee = amount * fee_bps / 10_000, rounded down
let fee = amount
.checked_mul(fee_bps as i128)
.expect(ERR_OVERFLOW)
/ 10_000;
if fee > 0 {
token_client.transfer(&env.current_contract_address(), &recipient, &fee);
}
amount - fee
} else {
amount
}
} else {
amount
};

token_client.transfer(&env.current_contract_address(), &employee, &employee_amount);

stream.locked = false;
save_stream(&env, &stream);
events::withdrawn(&env, stream_id, &employee, amount);
amount
events::withdrawn(&env, stream_id, &employee, employee_amount);
employee_amount
}

/// Employer tops up an active stream with additional funds.
Expand Down
21 changes: 21 additions & 0 deletions contracts/stream/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,24 @@ pub fn consume_admin_nonce(env: &Env, nonce: u64) {
assert!(nonce == expected, "{}", ERR_BAD_NONCE);
env.storage().instance().set(&DataKey::AdminNonce, &(expected + 1));
}

// ---------------------------------------------------------------------------
// Protocol fee helpers (issue #125)
// ---------------------------------------------------------------------------

/// Return the current protocol fee in basis points (0 = disabled).
pub fn get_fee_bps(env: &Env) -> u32 {
env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0u32)
}

pub fn set_fee_bps(env: &Env, bps: u32) {
env.storage().instance().set(&DataKey::FeeBps, &bps);
}

pub fn get_fee_recipient(env: &Env) -> Option<Address> {
env.storage().instance().get(&DataKey::FeeRecipient)
}

pub fn set_fee_recipient(env: &Env, recipient: &Address) {
env.storage().instance().set(&DataKey::FeeRecipient, recipient);
}
143 changes: 141 additions & 2 deletions contracts/stream/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
// SPDX-License-Identifier: Apache-2.0

#![cfg(test)]

use soroban_sdk::{
Address, Env,
};

use crate::{StreamContract, StreamContractClient};
use crate::types::StreamStatus;

fn setup() -> (Env, StreamContractClient<'static>) {
let env = Env::default();
env.mock_all_auths();
Expand Down Expand Up @@ -172,6 +177,29 @@ fn test_cancel_stream_refunds_employer() {
assert_eq!(s.withdrawn, 1000);
}

#[test]
fn test_cancel_stream_refunds_employer_and_employee_balances() {
let (env, client) = setup();
let admin = Address::generate(&env);
let employer = Address::generate(&env);
let employee = Address::generate(&env);
let token_id = setup_token(&env, &employer);
let token = paystream_token::TokenContractClient::new(&env, &token_id);

client.initialize(&admin);
let employer_balance_before = token.balance(&employer);
let employee_balance_before = token.balance(&employee);

let id = client.create_stream(&employer, &employee, &token_id, &10_000, &10, &0, &0);
env.ledger().with_mut(|l| l.timestamp += 100);
client.cancel_stream(&employer, &id);

assert_eq!(token.balance(&employee), employee_balance_before + 1000);
assert_eq!(token.balance(&employer), employer_balance_before + 9_000);
let s = client.get_stream(&id);
assert_eq!(s.status, StreamStatus::Cancelled);
}

#[test]
fn test_stop_time_caps_claimable() {
let (env, client) = setup();
Expand Down Expand Up @@ -492,7 +520,7 @@ fn test_create_stream_rate_too_high_rejected() {

/// employer == employee must be rejected.
#[test]
#[should_panic(expected = "employer and employee must differ")]
#[should_panic(expected = "E010")]
fn test_create_stream_same_employer_employee_rejected() {
let (env, client) = setup();
let admin = Address::generate(&env);
Expand Down Expand Up @@ -610,4 +638,115 @@ fn test_accept_admin_wrong_address_rejected() {
client.initialize(&admin);
client.propose_admin(&new_admin);
client.accept_admin(&attacker); // wrong address
}
}

// ---------------------------------------------------------------------------
// Issue #125 – Protocol fee mechanism
// ---------------------------------------------------------------------------

/// Fee of 0 (default) — employee receives full withdrawal amount.
#[test]
fn test_withdraw_no_fee_by_default() {
let (env, client) = setup();
let admin = Address::generate(&env);
let employer = Address::generate(&env);
let employee = Address::generate(&env);
let token_id = setup_token(&env, &employer);

client.initialize(&admin);
let id = client.create_stream(&employer, &employee, &token_id, &10_000, &10, &0, &0);

env.ledger().with_mut(|l| l.timestamp += 100);
let received = client.withdraw(&employee, &id);
// No fee configured → employee gets full 1000
assert_eq!(received, 1000);
}

/// Admin sets 1% fee (100 bps); employee receives 99% of claimable.
#[test]
fn test_withdraw_with_fee_deducted() {
let (env, client) = setup();
let admin = Address::generate(&env);
let employer = Address::generate(&env);
let employee = Address::generate(&env);
let fee_recipient = Address::generate(&env);
let token_id = setup_token(&env, &employer);

client.initialize(&admin);
// nonce 0: set_protocol_fee
client.set_protocol_fee(&admin, &0, &100, &fee_recipient);

let id = client.create_stream(&employer, &employee, &token_id, &10_000, &10, &0, &0);

env.ledger().with_mut(|l| l.timestamp += 100);
// claimable = 1000; fee = 1000 * 100 / 10_000 = 10; employee gets 990
let received = client.withdraw(&employee, &id);
assert_eq!(received, 990);
}

/// Fee can be set to 0 to disable it.
#[test]
fn test_fee_disabled_when_zero() {
let (env, client) = setup();
let admin = Address::generate(&env);
let employer = Address::generate(&env);
let employee = Address::generate(&env);
let fee_recipient = Address::generate(&env);
let token_id = setup_token(&env, &employer);

client.initialize(&admin);
// Set fee to 1% then disable it
client.set_protocol_fee(&admin, &0, &100, &fee_recipient);
client.set_protocol_fee(&admin, &1, &0, &fee_recipient);

let id = client.create_stream(&employer, &employee, &token_id, &10_000, &10, &0, &0);

env.ledger().with_mut(|l| l.timestamp += 100);
let received = client.withdraw(&employee, &id);
// Fee is 0 → employee gets full 1000
assert_eq!(received, 1000);
}

/// fee_bps > 100 must be rejected with E011.
#[test]
#[should_panic(expected = "E011")]
fn test_set_protocol_fee_above_max_rejected() {
let (env, client) = setup();
let admin = Address::generate(&env);
let fee_recipient = Address::generate(&env);
client.initialize(&admin);
client.set_protocol_fee(&admin, &0, &101, &fee_recipient);
}

/// Non-admin cannot set the protocol fee.
#[test]
#[should_panic]
fn test_set_protocol_fee_non_admin_rejected() {
let (env, client) = setup();
let admin = Address::generate(&env);
let attacker = Address::generate(&env);
let fee_recipient = Address::generate(&env);
client.initialize(&admin);
client.set_protocol_fee(&attacker, &0, &50, &fee_recipient);
}

/// 0.5% fee (50 bps) rounds down correctly.
#[test]
fn test_fee_rounding() {
let (env, client) = setup();
let admin = Address::generate(&env);
let employer = Address::generate(&env);
let employee = Address::generate(&env);
let fee_recipient = Address::generate(&env);
let token_id = setup_token(&env, &employer);

client.initialize(&admin);
client.set_protocol_fee(&admin, &0, &50, &fee_recipient);

let id = client.create_stream(&employer, &employee, &token_id, &10_000, &10, &0, &0);

env.ledger().with_mut(|l| l.timestamp += 100);
// claimable = 1000; fee = 1000 * 50 / 10_000 = 5; employee gets 995
let received = client.withdraw(&employee, &id);
assert_eq!(received, 995);
}
2 changes: 2 additions & 0 deletions contracts/stream/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ pub enum DataKey {
EmployerStreams(Address),
/// Index: employee address → Vec<u64> of stream IDs paying them.
EmployeeStreams(Address),
PendingAdmin,
MinDeposit,
}

Expand All @@ -89,3 +90,4 @@ pub const ERR_STREAM_EXHAUSTED: &str = "E006: cannot top up an exhausted stream"
pub const ERR_BELOW_MIN_DEPOSIT: &str = "E007: deposit below minimum";
pub const ERR_INVALID_RATE: &str = "E008: rate_per_second exceeds maximum";
pub const ERR_BAD_NONCE: &str = "E009: invalid admin nonce";
pub const ERR_SAME_PARTY: &str = "E010: employer and employee must differ";
4 changes: 2 additions & 2 deletions contracts/stream/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use soroban_sdk::Address;
use crate::types::{
ERR_ZERO_DEPOSIT, ERR_ZERO_RATE, ERR_BELOW_MIN_DEPOSIT, ERR_INVALID_RATE,
ERR_ZERO_DEPOSIT, ERR_ZERO_RATE, ERR_BELOW_MIN_DEPOSIT, ERR_INVALID_RATE, ERR_SAME_PARTY,
};

/// Maximum allowed rate_per_second (1 billion tokens/s — prevents overflow in
Expand Down Expand Up @@ -34,7 +34,7 @@ pub fn validate_create_stream(
if stop_time > 0 {
assert!(stop_time > now, "stop_time must be in the future");
}
assert!(employer != employee, "employer and employee must differ");
assert!(employer != employee, "{}", ERR_SAME_PARTY);
}

/// Validate a top-up amount.
Expand Down
2 changes: 2 additions & 0 deletions demo/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copy to .env and fill in your deployed contract ID
VITE_CONTRACT_ID=C...
35 changes: 35 additions & 0 deletions demo/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# PayStream Demo App

Minimal React demo showing: connect Freighter wallet, create a stream, view streams with real-time claimable balance, and withdraw.

## Quick start

```bash
cp .env.example .env
# Edit .env and set VITE_CONTRACT_ID to your deployed stream contract ID

npm install
npm start
```

Open http://localhost:5173

## Features

- **Connect wallet** — Freighter browser extension
- **Create stream** — employer locks deposit, sets rate per second
- **View streams** — load any stream by ID, see live claimable balance (polls every 5 s)
- **Withdraw** — employee claims all earned tokens in one click

## Deploy to GitHub Pages

```bash
npm run build
# Push the dist/ folder to gh-pages branch
```

## Environment

| Variable | Description |
|---|---|
| `VITE_CONTRACT_ID` | Deployed PayStream stream contract ID on testnet |
12 changes: 12 additions & 0 deletions demo/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PayStream Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Loading