Skip to content
Merged
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,19 @@ This repo contains the on-chain logic for creating, starting, stopping, and sett
- **`start_stream(stream_id)`** — Start an existing stream.
- **`stop_stream(stream_id)`** — Stop an active stream.
- **`settle_stream(stream_id)`** — Compute and deduct streamed amount since last settlement; returns amount.
- **`archive_stream(stream_id)`** — Remove a fully-settled, inactive stream from storage (payer must auth).
- **`get_stream_info(stream_id)`** — Read stream metadata (payer, recipient, rate, balance, timestamps, active).
- **`version()`** — Returns the contract version as a `u32` (no auth required).

## Storage Model

Streams are stored in **Soroban persistent storage** with per-stream TTL
management. Each stream is an independent ledger entry that can expire
independently. The contract instance storage holds only the `next_id` counter.

See `docs/factory-pattern.md` for the full design rationale and future factory
pattern graduation path.

### Version encoding

The on-chain version uses a packed `u32` scheme: `major * 1_000_000 + minor * 1_000 + patch`.
Expand Down
179 changes: 176 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
//! StreamPay — Soroban smart contracts for continuous payment streaming.
//!
//! Provides: create_stream, start_stream, stop_stream, settle_stream, version.
//! Provides: create_stream, start_stream, stop_stream, settle_stream,
//! archive_stream, get_stream_info, version.

use soroban_sdk::{contract, contractimpl, contracttype, Address, Env, Symbol};

/// Contract version: major * 1_000_000 + minor * 1_000 + patch.
/// Current: 0.1.0 → 1_000
const VERSION: u32 = 1_000;

/// TTL threshold: extend when remaining TTL drops below ~1 day (17_280 ledgers at ~5s each).
const STREAM_TTL_THRESHOLD: u32 = 17_280;
/// TTL extend-to: refresh to ~30 days (518_400 ledgers).
const STREAM_TTL_EXTEND: u32 = 518_400;
/// Instance storage TTL threshold (~1 day).
const INSTANCE_TTL_THRESHOLD: u32 = 17_280;
/// Instance storage TTL extend-to (~30 days).
const INSTANCE_TTL_EXTEND: u32 = 518_400;

#[contracttype]
#[derive(Clone, Debug)]
pub struct StreamInfo {
Expand Down Expand Up @@ -49,6 +59,8 @@ impl StreamPayContract {
};
set_stream(&env, stream_id, &info);
set_next_stream_id(&env, stream_id + 1);
extend_stream_ttl(&env, stream_id);
extend_instance_ttl(&env);
stream_id
}

Expand All @@ -62,6 +74,8 @@ impl StreamPayContract {
info.is_active = true;
info.start_time = env.ledger().timestamp();
set_stream(&env, stream_id, &info);
extend_stream_ttl(&env, stream_id);
extend_instance_ttl(&env);
}

/// Stop an active stream.
Expand All @@ -74,6 +88,8 @@ impl StreamPayContract {
info.is_active = false;
info.end_time = env.ledger().timestamp();
set_stream(&env, stream_id, &info);
extend_stream_ttl(&env, stream_id);
extend_instance_ttl(&env);
}

/// Settle stream: compute streamed amount since start and deduct from balance.
Expand All @@ -90,6 +106,8 @@ impl StreamPayContract {
info.balance = info.balance.saturating_sub(amount);
info.start_time = now;
set_stream(&env, stream_id, &info);
extend_stream_ttl(&env, stream_id);
extend_instance_ttl(&env);
amount
}

Expand All @@ -102,6 +120,22 @@ impl StreamPayContract {
pub fn version(_env: Env) -> u32 {
VERSION
}

/// Archive (remove) a fully-settled, inactive stream. Payer-only.
/// Stream must be inactive and have zero balance to protect recipient entitlements.
pub fn archive_stream(env: Env, stream_id: u32) {
let info = get_stream(&env, stream_id);
info.payer.require_auth();
if info.is_active {
panic!("cannot archive active stream");
}
if info.balance != 0 {
panic!("cannot archive stream with unsettled balance");
}
let key = stream_key(&env, stream_id);
env.storage().persistent().remove(&key);
extend_instance_ttl(&env);
}
}

fn stream_key(env: &Env, stream_id: u32) -> (Symbol, u32) {
Expand All @@ -111,14 +145,14 @@ fn stream_key(env: &Env, stream_id: u32) -> (Symbol, u32) {
fn get_stream(env: &Env, stream_id: u32) -> StreamInfo {
let key = stream_key(env, stream_id);
env.storage()
.instance()
.persistent()
.get(&key)
.unwrap_or_else(|| panic!("stream not found"))
}

fn set_stream(env: &Env, stream_id: u32, info: &StreamInfo) {
let key = stream_key(env, stream_id);
env.storage().instance().set(&key, info);
env.storage().persistent().set(&key, info);
}

fn get_next_stream_id(env: &Env) -> u32 {
Expand All @@ -131,9 +165,23 @@ fn set_next_stream_id(env: &Env, id: u32) {
env.storage().instance().set(&key, &id);
}

fn extend_stream_ttl(env: &Env, stream_id: u32) {
let key = stream_key(env, stream_id);
env.storage()
.persistent()
.extend_ttl(&key, STREAM_TTL_THRESHOLD, STREAM_TTL_EXTEND);
}

fn extend_instance_ttl(env: &Env) {
env.storage()
.instance()
.extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND);
}

#[cfg(test)]
mod test {
use soroban_sdk::testutils::Address as _;
use soroban_sdk::testutils::Ledger as _;

use super::*;

Expand Down Expand Up @@ -213,4 +261,129 @@ mod test {
let client = StreamPayContractClient::new(&env, &contract_id);
assert!(client.version() > 0);
}

#[test]
fn test_stream_uses_persistent_storage() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(StreamPayContract, ());
let client = StreamPayContractClient::new(&env, &contract_id);

let payer = Address::generate(&env);
let recipient = Address::generate(&env);
let stream_id = client.create_stream(&payer, &recipient, &100_i128, &10_000_i128);

// Verify stream is retrievable (storage works)
let info = client.get_stream_info(&stream_id);
assert_eq!(info.balance, 10_000);
}

#[test]
fn test_create_stream_extends_ttl() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(StreamPayContract, ());
let client = StreamPayContractClient::new(&env, &contract_id);

let payer = Address::generate(&env);
let recipient = Address::generate(&env);
let stream_id = client.create_stream(&payer, &recipient, &100_i128, &10_000_i128);

// Advance ledger by a modest amount — stream should still be alive
// because create_stream extended its TTL
env.ledger().with_mut(|li| {
li.sequence_number += 1_000;
li.timestamp += 5_000;
});

let info = client.get_stream_info(&stream_id);
assert_eq!(info.balance, 10_000);
}

#[test]
fn test_archive_settled_stream() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(StreamPayContract, ());
let client = StreamPayContractClient::new(&env, &contract_id);

let payer = Address::generate(&env);
let recipient = Address::generate(&env);
// rate=100/s, balance=1000 → fully drained after 10s
let stream_id = client.create_stream(&payer, &recipient, &100_i128, &1_000_i128);
client.start_stream(&stream_id);

// Advance 10 seconds so balance drains to 0
env.ledger().with_mut(|li| {
li.timestamp += 10;
});
let amount = client.settle_stream(&stream_id);
assert_eq!(amount, 1_000);

client.stop_stream(&stream_id);
let info = client.get_stream_info(&stream_id);
assert_eq!(info.balance, 0);
assert!(!info.is_active);

// Now archive — stream is stopped and fully settled
client.archive_stream(&stream_id);
}

#[test]
#[should_panic]
fn test_archive_unsettled_stream_panics() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(StreamPayContract, ());
let client = StreamPayContractClient::new(&env, &contract_id);

let payer = Address::generate(&env);
let recipient = Address::generate(&env);
let stream_id = client.create_stream(&payer, &recipient, &100_i128, &10_000_i128);

// Stream is inactive but has balance > 0 — should panic
// to protect recipient's entitlement
client.archive_stream(&stream_id);
}

#[test]
#[should_panic]
fn test_archive_active_stream_panics() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(StreamPayContract, ());
let client = StreamPayContractClient::new(&env, &contract_id);

let payer = Address::generate(&env);
let recipient = Address::generate(&env);
let stream_id = client.create_stream(&payer, &recipient, &100_i128, &10_000_i128);
client.start_stream(&stream_id);

// Should panic — stream is active
client.archive_stream(&stream_id);
}

#[test]
#[should_panic]
fn test_archived_stream_not_found() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(StreamPayContract, ());
let client = StreamPayContractClient::new(&env, &contract_id);

let payer = Address::generate(&env);
let recipient = Address::generate(&env);
// Create, start, drain, stop, then archive
let stream_id = client.create_stream(&payer, &recipient, &100_i128, &1_000_i128);
client.start_stream(&stream_id);
env.ledger().with_mut(|li| {
li.timestamp += 10;
});
client.settle_stream(&stream_id);
client.stop_stream(&stream_id);
client.archive_stream(&stream_id);

// Should panic — stream was archived (removed from storage)
client.get_stream_info(&stream_id);
}
}
Loading
Loading