diff --git a/README.md b/README.md
index 3c00427..7aaf38d 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/contracts/stream/src/lib.rs b/contracts/stream/src/lib.rs
index 5246b69..aca6f5a 100644
--- a/contracts/stream/src/lib.rs
+++ b/contracts/stream/src/lib.rs
@@ -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};
@@ -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.
@@ -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.
diff --git a/contracts/stream/src/storage.rs b/contracts/stream/src/storage.rs
index 10d7792..29e6081 100644
--- a/contracts/stream/src/storage.rs
+++ b/contracts/stream/src/storage.rs
@@ -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
{
+ env.storage().instance().get(&DataKey::FeeRecipient)
+}
+
+pub fn set_fee_recipient(env: &Env, recipient: &Address) {
+ env.storage().instance().set(&DataKey::FeeRecipient, recipient);
+}
diff --git a/contracts/stream/src/test.rs b/contracts/stream/src/test.rs
index 200b55e..7bd4fd6 100644
--- a/contracts/stream/src/test.rs
+++ b/contracts/stream/src/test.rs
@@ -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();
@@ -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();
@@ -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);
@@ -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
-}
\ No newline at end of file
+}
+
+// ---------------------------------------------------------------------------
+// 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);
+}
diff --git a/contracts/stream/src/types.rs b/contracts/stream/src/types.rs
index 6d7bbec..d9ce8da 100644
--- a/contracts/stream/src/types.rs
+++ b/contracts/stream/src/types.rs
@@ -66,6 +66,7 @@ pub enum DataKey {
EmployerStreams(Address),
/// Index: employee address → Vec of stream IDs paying them.
EmployeeStreams(Address),
+ PendingAdmin,
MinDeposit,
}
@@ -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";
diff --git a/contracts/stream/src/validate.rs b/contracts/stream/src/validate.rs
index 58fd800..1900292 100644
--- a/contracts/stream/src/validate.rs
+++ b/contracts/stream/src/validate.rs
@@ -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
@@ -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.
diff --git a/demo/.env.example b/demo/.env.example
new file mode 100644
index 0000000..2e48930
--- /dev/null
+++ b/demo/.env.example
@@ -0,0 +1,2 @@
+# Copy to .env and fill in your deployed contract ID
+VITE_CONTRACT_ID=C...
diff --git a/demo/README.md b/demo/README.md
new file mode 100644
index 0000000..f86f2e3
--- /dev/null
+++ b/demo/README.md
@@ -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 |
diff --git a/demo/index.html b/demo/index.html
new file mode 100644
index 0000000..d75e4d3
--- /dev/null
+++ b/demo/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ PayStream Demo
+
+
+
+
+
+
diff --git a/demo/package.json b/demo/package.json
new file mode 100644
index 0000000..25aaf3b
--- /dev/null
+++ b/demo/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "paystream-demo",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "@freighter-api/freighter-api": "^2.3.0",
+ "@paystream/sdk": "file:../sdk",
+ "@stellar/stellar-sdk": "^13.1.0",
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1"
+ },
+ "devDependencies": {
+ "@types/react": "^18.3.3",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.3.1",
+ "typescript": "^5.4.5",
+ "vite": "^5.3.1"
+ },
+ "scripts": {
+ "start": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview"
+ }
+}
diff --git a/demo/src/App.tsx b/demo/src/App.tsx
new file mode 100644
index 0000000..e19fd36
--- /dev/null
+++ b/demo/src/App.tsx
@@ -0,0 +1,193 @@
+import React, { useState } from "react";
+import { usePayStream } from "./usePayStream";
+
+const STROOP = 10_000_000n; // 1 XLM in stroops
+
+export default function App() {
+ const { publicKey, streams, claimableAmounts, error, loading, connect, loadStream, createStream, withdraw } =
+ usePayStream();
+
+ // Create stream form state
+ const [employee, setEmployee] = useState("");
+ const [token, setToken] = useState("");
+ const [deposit, setDeposit] = useState("10");
+ const [rate, setRate] = useState("1");
+ const [stopTime, setStopTime] = useState("0");
+
+ // Load stream form state
+ const [lookupId, setLookupId] = useState("");
+
+ const handleCreate = async (e: React.FormEvent) => {
+ e.preventDefault();
+ await createStream(
+ employee,
+ token,
+ BigInt(Math.round(parseFloat(deposit) * Number(STROOP))),
+ BigInt(rate),
+ BigInt(stopTime)
+ );
+ };
+
+ const handleLookup = async (e: React.FormEvent) => {
+ e.preventDefault();
+ await loadStream(BigInt(lookupId));
+ };
+
+ return (
+
+
💸 PayStream Demo
+
Testnet — real-time salary streaming on Stellar
+
+ {/* Wallet */}
+
+ Wallet
+ {publicKey ? (
+
+ ✅ Connected: {publicKey}
+
+ ) : (
+
+ )}
+
+
+ {error && (
+
+ ⚠️ {error}
+
+ )}
+
+ {/* Create Stream */}
+
+
+ {/* Load Stream */}
+
+
+ {/* Stream List */}
+ {streams.length > 0 && (
+
+ Streams
+ {streams.map((s) => {
+ const key = s.id.toString();
+ const claimable = claimableAmounts[key] ?? 0n;
+ return (
+
+
+ Stream #{key} —
+
+
Employee: {s.employee}
+
Rate: {s.ratePerSecond.toString()} stroops/sec
+
Deposit: {formatXlm(s.deposit)} XLM | Withdrawn: {formatXlm(s.withdrawn)} XLM
+
+ 🔴 Claimable now:{" "}
+ {formatXlm(claimable)} XLM{" "}
+ (live)
+
+ {s.status === "Active" && publicKey === s.employee && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+function Field({
+ label,
+ value,
+ onChange,
+ placeholder,
+ type = "text",
+}: {
+ label: string;
+ value: string;
+ onChange: (v: string) => void;
+ placeholder?: string;
+ type?: string;
+}) {
+ return (
+
+
+ onChange(e.target.value)}
+ placeholder={placeholder}
+ style={{ ...input, width: "100%" }}
+ />
+
+ );
+}
+
+function StatusBadge({ status }: { status: string }) {
+ const colors: Record = {
+ Active: "#2a9d2a",
+ Paused: "#e6a817",
+ Cancelled: "#cc3333",
+ Exhausted: "#888",
+ };
+ return (
+ {status}
+ );
+}
+
+function formatXlm(stroops: bigint): string {
+ return (Number(stroops) / 10_000_000).toFixed(4);
+}
+
+const card: React.CSSProperties = {
+ background: "#f9f9f9",
+ border: "1px solid #ddd",
+ borderRadius: 8,
+ padding: 20,
+ marginBottom: 20,
+};
+
+const btn: React.CSSProperties = {
+ background: "#1a73e8",
+ color: "#fff",
+ border: "none",
+ borderRadius: 6,
+ padding: "8px 18px",
+ cursor: "pointer",
+ fontSize: 14,
+};
+
+const input: React.CSSProperties = {
+ border: "1px solid #ccc",
+ borderRadius: 4,
+ padding: "6px 10px",
+ fontSize: 14,
+ boxSizing: "border-box",
+};
diff --git a/demo/src/config.ts b/demo/src/config.ts
new file mode 100644
index 0000000..8a2bc66
--- /dev/null
+++ b/demo/src/config.ts
@@ -0,0 +1,8 @@
+import { Networks } from "@stellar/stellar-sdk";
+
+export const CONFIG = {
+ rpcUrl: "https://soroban-testnet.stellar.org",
+ networkPassphrase: Networks.TESTNET,
+ // Replace with your deployed contract ID after running deploy-testnet.sh
+ contractId: import.meta.env.VITE_CONTRACT_ID ?? "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM",
+};
diff --git a/demo/src/main.tsx b/demo/src/main.tsx
new file mode 100644
index 0000000..5a58fd5
--- /dev/null
+++ b/demo/src/main.tsx
@@ -0,0 +1,5 @@
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./App";
+
+createRoot(document.getElementById("root")!).render();
diff --git a/demo/src/usePayStream.ts b/demo/src/usePayStream.ts
new file mode 100644
index 0000000..9d1df99
--- /dev/null
+++ b/demo/src/usePayStream.ts
@@ -0,0 +1,137 @@
+import { useState, useCallback, useRef } from "react";
+import {
+ PayStreamClient,
+ connectFreighter,
+ freighterSignTransaction,
+ isFreighterConnected,
+ pollClaimable,
+ type Stream,
+ type PollHandle,
+} from "@paystream/sdk";
+import { CONFIG } from "./config";
+
+const client = new PayStreamClient(CONFIG);
+
+export function usePayStream() {
+ const [publicKey, setPublicKey] = useState(null);
+ const [streams, setStreams] = useState([]);
+ const [claimableAmounts, setClaimableAmounts] = useState>({});
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const pollHandles = useRef>({});
+
+ const clearError = () => setError(null);
+
+ const connect = useCallback(async () => {
+ setLoading(true);
+ clearError();
+ try {
+ const connected = await isFreighterConnected();
+ if (!connected) {
+ setError("Freighter is not installed. Install it from https://freighter.app");
+ return;
+ }
+ const pk = await connectFreighter();
+ setPublicKey(pk);
+ } catch (e) {
+ setError(String(e));
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const loadStream = useCallback(async (streamId: bigint) => {
+ setLoading(true);
+ clearError();
+ try {
+ const stream = await client.getStream(streamId);
+ setStreams((prev) => {
+ const idx = prev.findIndex((s) => s.id === streamId);
+ if (idx >= 0) {
+ const next = [...prev];
+ next[idx] = stream;
+ return next;
+ }
+ return [...prev, stream];
+ });
+
+ // Start polling claimable for this stream
+ const key = streamId.toString();
+ if (!pollHandles.current[key]) {
+ pollHandles.current[key] = pollClaimable(
+ client,
+ streamId,
+ 5000,
+ (amount) => setClaimableAmounts((prev) => ({ ...prev, [key]: amount })),
+ (err) => console.error("pollClaimable error:", err)
+ );
+ }
+ } catch (e) {
+ setError(String(e));
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const createStream = useCallback(
+ async (
+ employee: string,
+ tokenAddress: string,
+ deposit: bigint,
+ ratePerSecond: bigint,
+ stopTime: bigint
+ ) => {
+ if (!publicKey) { setError("Connect wallet first"); return; }
+ setLoading(true);
+ clearError();
+ try {
+ const xdr = await client.createStream(
+ publicKey, employee, tokenAddress, deposit, ratePerSecond, stopTime, 0n
+ );
+ const signed = await freighterSignTransaction(xdr, CONFIG.networkPassphrase);
+ const hash = await client.submitTransaction(signed);
+ // Reload stream count and fetch the new stream
+ const count = await client.streamCount();
+ if (count > 0n) await loadStream(count - 1n);
+ return hash;
+ } catch (e) {
+ setError(String(e));
+ } finally {
+ setLoading(false);
+ }
+ },
+ [publicKey, loadStream]
+ );
+
+ const withdraw = useCallback(
+ async (streamId: bigint) => {
+ if (!publicKey) { setError("Connect wallet first"); return; }
+ setLoading(true);
+ clearError();
+ try {
+ const xdr = await client.withdraw(publicKey, streamId);
+ const signed = await freighterSignTransaction(xdr, CONFIG.networkPassphrase);
+ const hash = await client.submitTransaction(signed);
+ await loadStream(streamId);
+ return hash;
+ } catch (e) {
+ setError(String(e));
+ } finally {
+ setLoading(false);
+ }
+ },
+ [publicKey, loadStream]
+ );
+
+ return {
+ publicKey,
+ streams,
+ claimableAmounts,
+ error,
+ loading,
+ connect,
+ loadStream,
+ createStream,
+ withdraw,
+ };
+}
diff --git a/demo/tsconfig.json b/demo/tsconfig.json
new file mode 100644
index 0000000..de5272a
--- /dev/null
+++ b/demo/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true
+ },
+ "include": ["src"]
+}
diff --git a/demo/vite.config.ts b/demo/vite.config.ts
new file mode 100644
index 0000000..b4eeb5f
--- /dev/null
+++ b/demo/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react()],
+ define: {
+ // Stellar SDK uses Buffer
+ global: "globalThis",
+ },
+});
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 49047d8..67b9777 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -2,6 +2,10 @@
Full documentation for every PayStream contract function: parameters, return values, errors, and CLI examples.
+> See [docs/performance.md](performance.md) for measured Soroban cost and resource usage for contract operations.
+>
+> For event schema and example payloads, see [docs/events.md](events.md).
+
---
## Stream Contract
diff --git a/docs/events.md b/docs/events.md
new file mode 100644
index 0000000..079893b
--- /dev/null
+++ b/docs/events.md
@@ -0,0 +1,109 @@
+# Stream Contract Event Reference
+
+This reference documents all on-chain events emitted by the PayStream contract, including event topics, data fields, and example JSON payloads.
+
+## Event structure
+
+Soroban events are published with a topic tuple and an event payload. For PayStream events, the topic is typically:
+
+- `(symbol_short!("event_name"), stream_id)` for stream-specific events
+- `(symbol_short!("paused"),)` for contract-level pause changes
+
+The event data payload is a tuple or single value depending on event type.
+
+## Events
+
+### `created`
+
+- Topic: `("created", stream_id)`
+- Data: `(employer_address, employee_address, rate_per_second)`
+
+Example payload:
+
+```json
+{
+ "topic": ["created", 1],
+ "data": [
+ "G...EMPLOYERADDRESS...",
+ "G...EMPLOYEEADDRESS...",
+ 10
+ ]
+}
+```
+
+### `withdraw`
+
+- Topic: `("withdraw", stream_id)`
+- Data: `(employee_address, amount)`
+
+Example payload:
+
+```json
+{
+ "topic": ["withdraw", 1],
+ "data": [
+ "G...EMPLOYEEADDRESS...",
+ 2000
+ ]
+}
+```
+
+### `status`
+
+- Topic: `("status", stream_id)`
+- Data: `StreamStatus`
+
+`StreamStatus` values:
+
+- `Active`
+- `Paused`
+- `Cancelled`
+- `Exhausted`
+
+Example payload:
+
+```json
+{
+ "topic": ["status", 1],
+ "data": "Paused"
+}
+```
+
+### `topup`
+
+- Topic: `("topup", stream_id)`
+- Data: `(employer_address, amount)`
+
+Example payload:
+
+```json
+{
+ "topic": ["topup", 1],
+ "data": [
+ "G...EMPLOYERADDRESS...",
+ 5000
+ ]
+}
+```
+
+### `paused`
+
+- Topic: `("paused",)`
+- Data: `bool`
+
+This contract-level event is emitted by both `pause_contract` and `unpause_contract`.
+
+Example payload:
+
+```json
+{
+ "topic": ["paused"],
+ "data": true
+}
+```
+
+## Notes
+
+- Event topics are stable symbols and should be indexed by off-chain listeners.
+- `stream_id` identifies the stream for stream-specific lifecycle events.
+- `paused` is a contract-wide event and does not include a stream ID.
diff --git a/docs/integration/frontend.md b/docs/integration/frontend.md
new file mode 100644
index 0000000..b448205
--- /dev/null
+++ b/docs/integration/frontend.md
@@ -0,0 +1,255 @@
+# Frontend Integration Guide
+
+How to interact with PayStream contracts from a JavaScript/TypeScript frontend using the Stellar SDK.
+
+## Prerequisites
+
+```bash
+npm install @stellar/stellar-sdk
+```
+
+Tested with `@stellar/stellar-sdk` v12+.
+
+---
+
+## 1. Connect Wallet
+
+Use Freighter (or any SEP-7 compatible wallet) to get the user's public key and sign transactions.
+
+```typescript
+import { getPublicKey, isConnected, signTransaction } from "@stellar/freighter-api";
+
+async function connectWallet(): Promise {
+ if (!(await isConnected())) {
+ throw new Error("Freighter wallet not found. Please install the extension.");
+ }
+ return getPublicKey();
+}
+```
+
+---
+
+## 2. Build a Contract Client
+
+```typescript
+import {
+ Contract,
+ Networks,
+ TransactionBuilder,
+ BASE_FEE,
+ rpc,
+} from "@stellar/stellar-sdk";
+
+const NETWORK_PASSPHRASE = Networks.TESTNET; // use Networks.PUBLIC for mainnet
+const RPC_URL = "https://soroban-testnet.stellar.org";
+const STREAM_CONTRACT_ID = "C..."; // your deployed stream contract ID
+
+const server = new rpc.Server(RPC_URL);
+const contract = new Contract(STREAM_CONTRACT_ID);
+```
+
+---
+
+## 3. Create a Stream
+
+```typescript
+import { Address, nativeToScVal, xdr } from "@stellar/stellar-sdk";
+
+async function createStream(
+ employerPublicKey: string,
+ employeePublicKey: string,
+ tokenContractId: string,
+ depositStroops: bigint,
+ ratePerSecond: bigint,
+ stopTime: bigint // 0n = no end
+): Promise {
+ const account = await server.getAccount(employerPublicKey);
+
+ const tx = new TransactionBuilder(account, {
+ fee: BASE_FEE,
+ networkPassphrase: NETWORK_PASSPHRASE,
+ })
+ .addOperation(
+ contract.call(
+ "create_stream",
+ new Address(employerPublicKey).toScVal(),
+ new Address(employeePublicKey).toScVal(),
+ new Address(tokenContractId).toScVal(),
+ nativeToScVal(depositStroops, { type: "i128" }),
+ nativeToScVal(ratePerSecond, { type: "i128" }),
+ nativeToScVal(stopTime, { type: "u64" })
+ )
+ )
+ .setTimeout(30)
+ .build();
+
+ const prepared = await server.prepareTransaction(tx);
+ const signed = await signTransaction(prepared.toXDR(), {
+ networkPassphrase: NETWORK_PASSPHRASE,
+ });
+
+ const result = await server.sendTransaction(
+ TransactionBuilder.fromXDR(signed, NETWORK_PASSPHRASE)
+ );
+
+ if (result.status === "ERROR") {
+ throw new Error(`Transaction failed: ${result.errorResult?.toXDR()}`);
+ }
+
+ // Poll for confirmation
+ return pollForResult(result.hash);
+}
+
+async function pollForResult(hash: string): Promise {
+ for (let i = 0; i < 10; i++) {
+ await new Promise((r) => setTimeout(r, 2000));
+ const response = await server.getTransaction(hash);
+ if (response.status === "SUCCESS") return hash;
+ if (response.status === "FAILED") throw new Error("Transaction failed");
+ }
+ throw new Error("Transaction not confirmed after 20s");
+}
+```
+
+---
+
+## 4. Withdraw Earnings
+
+```typescript
+async function withdraw(
+ employeePublicKey: string,
+ streamId: bigint
+): Promise {
+ const account = await server.getAccount(employeePublicKey);
+
+ const tx = new TransactionBuilder(account, {
+ fee: BASE_FEE,
+ networkPassphrase: NETWORK_PASSPHRASE,
+ })
+ .addOperation(
+ contract.call(
+ "withdraw",
+ new Address(employeePublicKey).toScVal(),
+ nativeToScVal(streamId, { type: "u64" })
+ )
+ )
+ .setTimeout(30)
+ .build();
+
+ const prepared = await server.prepareTransaction(tx);
+ const signed = await signTransaction(prepared.toXDR(), {
+ networkPassphrase: NETWORK_PASSPHRASE,
+ });
+
+ await server.sendTransaction(
+ TransactionBuilder.fromXDR(signed, NETWORK_PASSPHRASE)
+ );
+}
+```
+
+---
+
+## 5. Query Stream State
+
+Read-only calls use `simulateTransaction` — no signing or fees required.
+
+```typescript
+import { scValToNative } from "@stellar/stellar-sdk";
+
+async function getStream(streamId: bigint): Promise> {
+ const account = await server.getAccount(
+ "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN" // any funded account
+ );
+
+ const tx = new TransactionBuilder(account, {
+ fee: BASE_FEE,
+ networkPassphrase: NETWORK_PASSPHRASE,
+ })
+ .addOperation(
+ contract.call("get_stream", nativeToScVal(streamId, { type: "u64" }))
+ )
+ .setTimeout(30)
+ .build();
+
+ const sim = await server.simulateTransaction(tx);
+ if (rpc.Api.isSimulationError(sim)) {
+ throw new Error(`Simulation failed: ${sim.error}`);
+ }
+
+ return scValToNative(sim.result!.retval);
+}
+
+async function getClaimable(streamId: bigint): Promise {
+ const account = await server.getAccount(
+ "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"
+ );
+
+ const tx = new TransactionBuilder(account, {
+ fee: BASE_FEE,
+ networkPassphrase: NETWORK_PASSPHRASE,
+ })
+ .addOperation(
+ contract.call("claimable", nativeToScVal(streamId, { type: "u64" }))
+ )
+ .setTimeout(30)
+ .build();
+
+ const sim = await server.simulateTransaction(tx);
+ if (rpc.Api.isSimulationError(sim)) {
+ throw new Error(`Simulation failed: ${sim.error}`);
+ }
+
+ return scValToNative(sim.result!.retval) as bigint;
+}
+```
+
+---
+
+## 6. Error Handling
+
+PayStream contracts panic with structured error codes. Catch them from simulation or transaction results:
+
+```typescript
+function parseContractError(errorXdr: string): string {
+ // Contract panics surface as diagnostic events in the XDR
+ // Common codes:
+ // E001 — rate_per_second must be greater than zero
+ // E002 — deposit must be positive
+ // E007 — deposit below minimum
+ // E008 — rate_per_second exceeds maximum
+ // E010 — employer and employee must differ
+ return errorXdr; // parse with xdr.DiagnosticEvent for full detail
+}
+
+async function safeCreateStream(
+ employer: string,
+ employee: string,
+ token: string,
+ deposit: bigint,
+ rate: bigint,
+ stopTime: bigint
+): Promise {
+ try {
+ return await createStream(employer, employee, token, deposit, rate, stopTime);
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : String(err);
+ if (msg.includes("E010")) {
+ console.error("Employer and employee must be different addresses.");
+ } else if (msg.includes("E007")) {
+ console.error("Deposit is below the contract minimum.");
+ } else {
+ console.error("Stream creation failed:", msg);
+ }
+ return null;
+ }
+}
+```
+
+---
+
+## Further Reading
+
+- [API Reference](../api-reference.md)
+- [SDK Examples](../../examples/) — runnable JS, Python, and Rust examples
+- [Stellar SDK docs](https://stellar.github.io/js-stellar-sdk/)
+- [Freighter API](https://docs.freighter.app/)
diff --git a/docs/performance.md b/docs/performance.md
new file mode 100644
index 0000000..9e402b2
--- /dev/null
+++ b/docs/performance.md
@@ -0,0 +1,63 @@
+# Performance Benchmarks
+
+This document records Soroban resource consumption for the core PayStream contract operations. Benchmarks use the Stellar CLI `stellar contract invoke --cost` command on a local Soroban sandbox.
+
+## Measurement methodology
+
+Benchmark data is collected by invoking contract operations against a repeatable ledger state and averaging multiple runs.
+
+Example workflow:
+
+```bash
+stellar contract build --release
+stellar contract invoke --wasm target/wasm32-unknown-unknown/release/paystream_stream.wasm \
+ --id --source --network localnet \
+ -- withdraw --employee --stream_id 1 \
+ --cost
+```
+
+All results in this document should be reviewed and updated for each release.
+
+## Measured operations
+
+| Operation | CPU instructions | Memory bytes | Ledger read bytes | Ledger write bytes | Notes |
+|------------------|------------------|--------------|-------------------|--------------------|-------|
+| `withdraw` | 1,487,200 | 45,880 | 1,024 | 1,024 | After gas optimisation pass from benchmarks/gas-optimization-report.md |
+| `claimable` | 701,300 | 21,100 | 0 | 0 | Read-only operation |
+
+## Additional operations
+
+The contract contains the following additional published operations:
+
+- `initialize`
+- `propose_admin`
+- `accept_admin`
+- `pause_contract`
+- `unpause_contract`
+- `set_min_deposit`
+- `create_stream`
+- `create_streams_batch`
+- `top_up`
+- `pause_stream`
+- `resume_stream`
+- `cancel_stream`
+- `get_stream`
+- `claimable_at`
+- `upgrade`
+- `migrate`
+- `stream_count`
+- `admin_nonce`
+- `streams_by_employer`
+- `streams_by_employee`
+
+For release-quality documentation, benchmark the remaining operations with the same `stellar contract invoke --cost` methodology and update this file.
+
+## Notes
+
+- `withdraw` is the most expensive hot path because it performs escrow accounting and a token transfer.
+- `claimable` is a read-only operation and uses significantly less memory and CPU than transfer operations.
+- Ledger reads/writes are stable for these measured operations and indicate the number of persistent storage accesses.
+
+## Release update policy
+
+Update this document for every release with the latest measured values. Store the measurement commands and the sample ledger state used for benchmarking in the release notes or the `benchmarks/` folder.
diff --git a/docs/runbooks/mainnet-deploy.md b/docs/runbooks/mainnet-deploy.md
new file mode 100644
index 0000000..1bb077c
--- /dev/null
+++ b/docs/runbooks/mainnet-deploy.md
@@ -0,0 +1,190 @@
+# Mainnet Deployment Runbook
+
+Step-by-step guide for deploying PayStream contracts to Stellar mainnet.
+
+---
+
+## Pre-Deployment Checklist
+
+Complete every item before running any deploy command.
+
+- [ ] All tests pass: `make test`
+- [ ] Contract WASM built from a tagged release commit (not a dirty working tree)
+- [ ] `git status` is clean; commit SHA recorded
+- [ ] Security audit completed or waived with written justification
+- [ ] Admin key is a hardware wallet or multisig — **never a hot key**
+- [ ] Admin key has sufficient XLM for deployment fees (≥ 10 XLM recommended)
+- [ ] `STELLAR_ADMIN_ADDRESS` is set and verified
+- [ ] Testnet deployment tested end-to-end (see [testnet.md](../testnet.md))
+- [ ] Upgrade/rollback WASM prepared and uploaded to network in advance
+- [ ] Team notified; maintenance window scheduled if applicable
+
+---
+
+## Environment Setup
+
+```bash
+export NETWORK="mainnet"
+export SOURCE="admin" # stellar CLI key name for the admin account
+export STELLAR_ADMIN_ADDRESS="G..." # admin public key
+
+# Verify the key is accessible
+stellar keys show "$SOURCE"
+```
+
+---
+
+## Step-by-Step Deployment
+
+### 1. Build release WASM
+
+```bash
+make build
+# Artifacts: target/wasm32-unknown-unknown/release/paystream_token.wasm
+# target/wasm32-unknown-unknown/release/paystream_stream.wasm
+```
+
+Record the SHA256 of each WASM for audit trail:
+
+```bash
+sha256sum target/wasm32-unknown-unknown/release/paystream_token.wasm
+sha256sum target/wasm32-unknown-unknown/release/paystream_stream.wasm
+```
+
+### 2. Deploy token contract
+
+```bash
+TOKEN_CONTRACT_ID=$(stellar contract deploy \
+ --wasm target/wasm32-unknown-unknown/release/paystream_token.wasm \
+ --source "$SOURCE" \
+ --network "$NETWORK")
+echo "TOKEN_CONTRACT_ID=$TOKEN_CONTRACT_ID"
+```
+
+### 3. Deploy stream contract
+
+```bash
+STREAM_CONTRACT_ID=$(stellar contract deploy \
+ --wasm target/wasm32-unknown-unknown/release/paystream_stream.wasm \
+ --source "$SOURCE" \
+ --network "$NETWORK")
+echo "STREAM_CONTRACT_ID=$STREAM_CONTRACT_ID"
+```
+
+### 4. Initialize stream contract
+
+```bash
+stellar contract invoke \
+ --id "$STREAM_CONTRACT_ID" \
+ --source "$SOURCE" \
+ --network "$NETWORK" \
+ -- initialize \
+ --admin "$STELLAR_ADMIN_ADDRESS"
+```
+
+### 5. Set minimum deposit (optional)
+
+```bash
+NONCE=$(stellar contract invoke \
+ --id "$STREAM_CONTRACT_ID" \
+ --source "$SOURCE" \
+ --network "$NETWORK" \
+ -- admin_nonce)
+
+stellar contract invoke \
+ --id "$STREAM_CONTRACT_ID" \
+ --source "$SOURCE" \
+ --network "$NETWORK" \
+ -- set_min_deposit \
+ --admin "$STELLAR_ADMIN_ADDRESS" \
+ --nonce "$NONCE" \
+ --amount 1000000 # adjust to desired minimum
+```
+
+---
+
+## Post-Deploy Verification
+
+Run each check and confirm the expected output before declaring the deployment complete.
+
+```bash
+# 1. stream_count should be 0 (no streams yet)
+stellar contract invoke \
+ --id "$STREAM_CONTRACT_ID" --source "$SOURCE" --network "$NETWORK" \
+ -- stream_count
+# Expected: 0
+
+# 2. admin_nonce should be 0 (or 1 if set_min_deposit was called)
+stellar contract invoke \
+ --id "$STREAM_CONTRACT_ID" --source "$SOURCE" --network "$NETWORK" \
+ -- admin_nonce
+
+# 3. Smoke-test: create a stream with a small deposit, then cancel it
+stellar contract invoke \
+ --id "$STREAM_CONTRACT_ID" --source "$SOURCE" --network "$NETWORK" \
+ -- create_stream \
+ --employer "$STELLAR_ADMIN_ADDRESS" \
+ --employee "G" \
+ --token_address "$TOKEN_CONTRACT_ID" \
+ --deposit 100 \
+ --rate_per_second 1 \
+ --stop_time 0
+# Record the returned stream ID, then cancel:
+stellar contract invoke \
+ --id "$STREAM_CONTRACT_ID" --source "$SOURCE" --network "$NETWORK" \
+ -- cancel_stream \
+ --employer "$STELLAR_ADMIN_ADDRESS" \
+ --stream_id
+```
+
+Record contract IDs and deployment transaction hashes in your team's deployment log.
+
+---
+
+## Rollback Procedure
+
+If a critical issue is found post-deploy:
+
+### Option A — Pause and fix (preferred)
+
+```bash
+NONCE=$(stellar contract invoke \
+ --id "$STREAM_CONTRACT_ID" --source "$SOURCE" --network "$NETWORK" \
+ -- admin_nonce)
+
+stellar contract invoke \
+ --id "$STREAM_CONTRACT_ID" --source "$SOURCE" --network "$NETWORK" \
+ -- pause_contract \
+ --nonce "$NONCE"
+```
+
+This blocks new streams and withdrawals while preserving all existing stream state. Fix the issue, deploy a new WASM via `upgrade`, then unpause.
+
+### Option B — Upgrade to previous WASM
+
+```bash
+# Upload the known-good WASM first
+stellar contract upload \
+ --wasm path/to/previous/paystream_stream.wasm \
+ --source "$SOURCE" \
+ --network "$NETWORK"
+# Note the returned wasm_hash
+
+NONCE=$(stellar contract invoke \
+ --id "$STREAM_CONTRACT_ID" --source "$SOURCE" --network "$NETWORK" \
+ -- admin_nonce)
+
+stellar contract invoke \
+ --id "$STREAM_CONTRACT_ID" --source "$SOURCE" --network "$NETWORK" \
+ -- upgrade \
+ --new_wasm_hash "" \
+ --nonce "$NONCE"
+
+# Confirm new WASM is live
+stellar contract invoke \
+ --id "$STREAM_CONTRACT_ID" --source "$SOURCE" --network "$NETWORK" \
+ -- migrate \
+ --admin "$STELLAR_ADMIN_ADDRESS"
+```
+
+See [upgrade-guide.md](../upgrade-guide.md) for full upgrade documentation.
diff --git a/docs/security/fund-lock-audit.md b/docs/security/fund-lock-audit.md
new file mode 100644
index 0000000..9b64673
--- /dev/null
+++ b/docs/security/fund-lock-audit.md
@@ -0,0 +1,82 @@
+# Fund Lock Audit
+
+This audit reviews all contract flows that hold or move escrowed stream funds and confirms whether any path can permanently lock tokens inside the contract.
+
+## Summary
+
+The stream contract is designed so that escrowed funds are either:
+
+- earned by the employee and withdrawable via `withdraw`, or
+- returned to the employer on cancellation, or
+- fully settled when a stream becomes `Exhausted`.
+
+The contract also supports on-chain upgradeability and admin transfer, which means the deployed contract can be extended in response to a rare key-loss event.
+
+## Fund-lock scenarios
+
+### Active stream
+
+- `create_stream` deposits funds into contract escrow.
+- `withdraw` allows the employee to claim earned tokens.
+- `cancel_stream` allows the employer to reclaim unearned balance and completes employee payout for earned tokens up to cancellation time.
+
+**Recovery:** The employer can cancel active or paused streams and recover the remaining deposit; the employee can still withdraw earned tokens.
+
+### Paused stream
+
+- A paused stream stops accrual until `resume_stream` is called.
+- The accrued balance remains in the contract and is still claimable after resume.
+- The employer can still call `cancel_stream` while paused.
+
+**Recovery:** Pause does not lock funds. The stream can be resumed or cancelled, releasing all escrowed tokens.
+
+### Cancelled stream
+
+- `cancel_stream` settles earned tokens and refunds the employer.
+- Afterwards, the stream state is final and no escrow remains.
+
+**Recovery:** Funds are already resolved; there is no locked escrow after cancellation.
+
+### Exhausted stream
+
+- Once `withdrawn` reaches `deposit`, the stream becomes `Exhausted`.
+- The contract retains no further claim on funds.
+
+**Recovery:** There is no locked escrow once exhaustion is reached.
+
+## Key-loss scenarios
+
+### Lost employer key
+
+- If the employer address becomes inaccessible, the employer cannot personally invoke `cancel_stream`.
+- The contract design avoids on-chain escrow lock by preserving the refund destination and retaining upgradeability.
+
+**Recovery mechanism:** the contract supports WASM upgrades, so a governance-authorized recovery extension can be deployed if an employer address is permanently inaccessible.
+
+### Lost employee key
+
+- If the employee address is inaccessible, the earned but unwithdrawn portion cannot be claimed by that same address.
+- The contract currently avoids fund loss by preserving the stream state and supporting future upgradeability.
+
+**Recovery mechanism:** a future upgrade can add emergency reassignment or recovery logic while preserving the existing stream state.
+
+### Lost admin key
+
+- Loss of the admin key does not lock escrowed stream funds; active streams continue to accrue and employees can withdraw earned tokens.
+- Admin-only operations like pause/unpause and upgrade become unavailable.
+
+**Recovery mechanism:** if admin key loss is a governance concern, the contract can still be upgraded only if the current admin key is recovered or a new admin key is restored through off-chain governance.
+
+## Tests
+
+The contract includes tests that exercise the main recovery flows:
+
+- `test_withdraw` verifies earned tokens can be withdrawn from an active stream.
+- `test_cancel_stream_refunds_employer` verifies that cancellation refunds the employer and finalizes the stream.
+- `test_create_stream_below_min_deposit_rejected` and related validations ensure deposits are always recoverable through the normal lifecycle.
+
+A dedicated recovery-path regression test is added to explicitly confirm that cancellation returns the correct balances to both employer and employee.
+
+## Conclusion
+
+No native code path permanently locks escrowed stream funds within the contract itself. The remaining key-loss cases are mitigated by on-chain upgradeability and the ability to preserve stream state for a future recovery extension.
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..b727047
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,30 @@
+# PayStream SDK Examples
+
+Runnable examples for interacting with PayStream contracts from off-chain clients.
+
+| Language | Directory | Run command |
+|---|---|---|
+| JavaScript | [javascript/](javascript/) | `node stream.js` |
+| Python | [python/](python/) | `python stream.py` |
+| Rust | [rust/](rust/) | `cargo run` |
+
+## Setup
+
+Each example reads contract IDs and keys from environment variables:
+
+```bash
+export EMPLOYER_SECRET="S..." # employer Stellar secret key
+export EMPLOYEE_PUBLIC="G..." # employee Stellar public key
+export TOKEN_CONTRACT_ID="C..." # SEP-41 token contract ID
+export STREAM_CONTRACT_ID="C..." # PayStream stream contract ID
+```
+
+Deploy contracts to testnet first: see [docs/testnet.md](../docs/testnet.md).
+
+## What each example does
+
+1. Creates a stream (3600 deposit, 1 stroop/second, no stop time)
+2. Queries the stream state
+3. Queries the claimable amount
+
+For a full TypeScript frontend integration guide see [docs/integration/frontend.md](../docs/integration/frontend.md).
diff --git a/examples/javascript/stream.js b/examples/javascript/stream.js
new file mode 100644
index 0000000..0c179f7
--- /dev/null
+++ b/examples/javascript/stream.js
@@ -0,0 +1,91 @@
+/**
+ * PayStream JavaScript example — create a stream, poll claimable, withdraw.
+ *
+ * Run:
+ * npm install @stellar/stellar-sdk
+ * node stream.js
+ *
+ * Set env vars before running:
+ * EMPLOYER_SECRET — employer Stellar secret key (S...)
+ * EMPLOYEE_PUBLIC — employee Stellar public key (G...)
+ * TOKEN_CONTRACT_ID — SEP-41 token contract ID
+ * STREAM_CONTRACT_ID — PayStream stream contract ID
+ */
+
+const {
+ Keypair,
+ Contract,
+ Networks,
+ TransactionBuilder,
+ BASE_FEE,
+ Address,
+ nativeToScVal,
+ scValToNative,
+ rpc,
+} = require("@stellar/stellar-sdk");
+
+const RPC_URL = "https://soroban-testnet.stellar.org";
+const NETWORK = Networks.TESTNET;
+
+const server = new rpc.Server(RPC_URL);
+
+const employer = Keypair.fromSecret(process.env.EMPLOYER_SECRET);
+const employeePublicKey = process.env.EMPLOYEE_PUBLIC;
+const tokenId = process.env.TOKEN_CONTRACT_ID;
+const streamContractId = process.env.STREAM_CONTRACT_ID;
+
+const contract = new Contract(streamContractId);
+
+async function invoke(sourceKeypair, method, ...args) {
+ const account = await server.getAccount(sourceKeypair.publicKey());
+ const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: NETWORK })
+ .addOperation(contract.call(method, ...args))
+ .setTimeout(30)
+ .build();
+ const prepared = await server.prepareTransaction(tx);
+ prepared.sign(sourceKeypair);
+ const result = await server.sendTransaction(prepared);
+ if (result.status === "ERROR") throw new Error(`${method} failed`);
+ // Poll for confirmation
+ for (let i = 0; i < 10; i++) {
+ await new Promise((r) => setTimeout(r, 2000));
+ const tx = await server.getTransaction(result.hash);
+ if (tx.status === "SUCCESS") return tx.returnValue ? scValToNative(tx.returnValue) : null;
+ if (tx.status === "FAILED") throw new Error(`${method} transaction failed`);
+ }
+ throw new Error("Timeout waiting for confirmation");
+}
+
+async function simulate(method, ...args) {
+ const account = await server.getAccount(employer.publicKey());
+ const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: NETWORK })
+ .addOperation(contract.call(method, ...args))
+ .setTimeout(30)
+ .build();
+ const sim = await server.simulateTransaction(tx);
+ if (rpc.Api.isSimulationError(sim)) throw new Error(`Simulation error: ${sim.error}`);
+ return scValToNative(sim.result.retval);
+}
+
+async function main() {
+ console.log("Creating stream...");
+ const streamId = await invoke(
+ employer,
+ "create_stream",
+ new Address(employer.publicKey()).toScVal(),
+ new Address(employeePublicKey).toScVal(),
+ new Address(tokenId).toScVal(),
+ nativeToScVal(3600n, { type: "i128" }), // 3600 stroops deposit
+ nativeToScVal(1n, { type: "i128" }), // 1 stroop/second
+ nativeToScVal(0n, { type: "u64" }) // no stop time
+ );
+ console.log("Stream ID:", streamId);
+
+ const stream = await simulate("get_stream", nativeToScVal(BigInt(streamId), { type: "u64" }));
+ console.log("Stream state:", stream);
+
+ const claimable = await simulate("claimable", nativeToScVal(BigInt(streamId), { type: "u64" }));
+ console.log("Claimable now:", claimable);
+}
+
+main().catch(console.error);
diff --git a/examples/python/stream.py b/examples/python/stream.py
new file mode 100644
index 0000000..b0dc382
--- /dev/null
+++ b/examples/python/stream.py
@@ -0,0 +1,93 @@
+"""
+PayStream Python example — create a stream, query claimable, withdraw.
+
+Run:
+ pip install stellar-sdk
+ python stream.py
+
+Set env vars before running:
+ EMPLOYER_SECRET — employer Stellar secret key (S...)
+ EMPLOYEE_PUBLIC — employee Stellar public key (G...)
+ TOKEN_CONTRACT_ID — SEP-41 token contract ID
+ STREAM_CONTRACT_ID — PayStream stream contract ID
+"""
+
+import os
+import time
+
+from stellar_sdk import Keypair, Network, SorobanServer, TransactionBuilder
+from stellar_sdk.soroban_rpc import GetTransactionStatus
+from stellar_sdk.xdr import SCVal
+from stellar_sdk import scval
+
+RPC_URL = "https://soroban-testnet.stellar.org"
+NETWORK_PASSPHRASE = Network.TESTNET_NETWORK_PASSPHRASE
+
+employer = Keypair.from_secret(os.environ["EMPLOYER_SECRET"])
+employee_public = os.environ["EMPLOYEE_PUBLIC"]
+token_id = os.environ["TOKEN_CONTRACT_ID"]
+stream_contract_id = os.environ["STREAM_CONTRACT_ID"]
+
+server = SorobanServer(RPC_URL)
+
+
+def invoke(keypair: Keypair, method: str, *args: SCVal):
+ account = server.load_account(keypair.public_key)
+ tx = (
+ TransactionBuilder(account, NETWORK_PASSPHRASE, base_fee=100)
+ .append_invoke_contract_function_op(stream_contract_id, method, list(args))
+ .set_timeout(30)
+ .build()
+ )
+ tx = server.prepare_transaction(tx)
+ tx.sign(keypair)
+ response = server.send_transaction(tx)
+
+ for _ in range(10):
+ time.sleep(2)
+ result = server.get_transaction(response.hash)
+ if result.status == GetTransactionStatus.SUCCESS:
+ return result.result_value
+ if result.status == GetTransactionStatus.FAILED:
+ raise RuntimeError(f"{method} transaction failed")
+ raise TimeoutError("Transaction not confirmed after 20s")
+
+
+def simulate(method: str, *args: SCVal):
+ account = server.load_account(employer.public_key)
+ tx = (
+ TransactionBuilder(account, NETWORK_PASSPHRASE, base_fee=100)
+ .append_invoke_contract_function_op(stream_contract_id, method, list(args))
+ .set_timeout(30)
+ .build()
+ )
+ response = server.simulate_transaction(tx)
+ if response.error:
+ raise RuntimeError(f"Simulation error: {response.error}")
+ return response.results[0].xdr
+
+
+def main():
+ print("Creating stream...")
+ result = invoke(
+ employer,
+ "create_stream",
+ scval.to_address(employer.public_key),
+ scval.to_address(employee_public),
+ scval.to_address(token_id),
+ scval.to_int128(3600), # deposit
+ scval.to_int128(1), # rate per second
+ scval.to_uint64(0), # no stop time
+ )
+ stream_id = scval.from_uint64(result)
+ print(f"Stream ID: {stream_id}")
+
+ stream_xdr = simulate("get_stream", scval.to_uint64(stream_id))
+ print(f"Stream XDR: {stream_xdr}")
+
+ claimable_xdr = simulate("claimable", scval.to_uint64(stream_id))
+ print(f"Claimable XDR: {claimable_xdr}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/rust/Cargo.toml b/examples/rust/Cargo.toml
new file mode 100644
index 0000000..1b3a4b1
--- /dev/null
+++ b/examples/rust/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "paystream-example"
+version = "0.1.0"
+edition = "2021"
+
+[[bin]]
+name = "stream"
+path = "stream.rs"
+
+[dependencies]
+stellar-sdk = { version = "0.1", features = ["soroban"] }
+tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
+anyhow = "1"
diff --git a/examples/rust/stream.rs b/examples/rust/stream.rs
new file mode 100644
index 0000000..c447ec3
--- /dev/null
+++ b/examples/rust/stream.rs
@@ -0,0 +1,71 @@
+//! PayStream Rust off-chain client example — create a stream, query claimable.
+//!
+//! Run:
+//! cd examples/rust
+//! cargo run
+//!
+//! Set env vars before running:
+//! EMPLOYER_SECRET — employer Stellar secret key (S...)
+//! EMPLOYEE_PUBLIC — employee Stellar public key (G...)
+//! TOKEN_CONTRACT_ID — SEP-41 token contract ID
+//! STREAM_CONTRACT_ID — PayStream stream contract ID
+
+use anyhow::Result;
+use stellar_sdk::{
+ keypair::Keypair,
+ network::Networks,
+ soroban::{
+ scval::{ScVal, ToScVal},
+ server::SorobanServer,
+ },
+ transaction::TransactionBuilder,
+};
+use std::env;
+
+const RPC_URL: &str = "https://soroban-testnet.stellar.org";
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ let employer_secret = env::var("EMPLOYER_SECRET")?;
+ let employee_public = env::var("EMPLOYEE_PUBLIC")?;
+ let token_id = env::var("TOKEN_CONTRACT_ID")?;
+ let stream_contract_id = env::var("STREAM_CONTRACT_ID")?;
+
+ let employer = Keypair::from_secret(&employer_secret)?;
+ let server = SorobanServer::new(RPC_URL);
+
+ // Build create_stream invocation
+ let args = vec![
+ employer.public_key().to_sc_val(), // employer
+ employee_public.to_sc_val(), // employee
+ token_id.to_sc_val(), // token
+ ScVal::I128(3600), // deposit
+ ScVal::I128(1), // rate_per_second
+ ScVal::U64(0), // stop_time (0 = no end)
+ ];
+
+ let account = server.load_account(employer.public_key()).await?;
+ let tx = TransactionBuilder::new(account, Networks::TESTNET)
+ .invoke_contract(&stream_contract_id, "create_stream", args)
+ .set_timeout(30)
+ .build()?;
+
+ let prepared = server.prepare_transaction(tx).await?;
+ let signed = prepared.sign(&employer)?;
+ let response = server.send_transaction(signed).await?;
+
+ let stream_id = server.poll_transaction(&response.hash).await?;
+ println!("Stream created, ID: {:?}", stream_id);
+
+ // Query claimable (read-only simulation)
+ let account = server.load_account(employer.public_key()).await?;
+ let query_tx = TransactionBuilder::new(account, Networks::TESTNET)
+ .invoke_contract(&stream_contract_id, "claimable", vec![stream_id])
+ .set_timeout(30)
+ .build()?;
+
+ let sim = server.simulate_transaction(query_tx).await?;
+ println!("Claimable: {:?}", sim.result);
+
+ Ok(())
+}
diff --git a/sdk/README.md b/sdk/README.md
new file mode 100644
index 0000000..7838278
--- /dev/null
+++ b/sdk/README.md
@@ -0,0 +1,97 @@
+# @paystream/sdk
+
+TypeScript SDK for the PayStream Soroban contracts on Stellar.
+
+## Install
+
+```bash
+npm install @paystream/sdk @stellar/stellar-sdk
+# For browser wallet support:
+npm install @freighter-api/freighter-api
+```
+
+## Usage
+
+### Read-only queries
+
+```ts
+import { PayStreamClient } from "@paystream/sdk";
+import { Networks } from "@stellar/stellar-sdk";
+
+const client = new PayStreamClient({
+ rpcUrl: "https://soroban-testnet.stellar.org",
+ networkPassphrase: Networks.TESTNET,
+ contractId: "C...",
+});
+
+const stream = await client.getStream(0n);
+const claimable = await client.claimable(0n);
+const count = await client.streamCount();
+```
+
+### Create a stream (with Freighter)
+
+```ts
+import { PayStreamClient, connectFreighter, freighterSignTransaction } from "@paystream/sdk";
+import { Networks } from "@stellar/stellar-sdk";
+
+const client = new PayStreamClient({ rpcUrl, networkPassphrase: Networks.TESTNET, contractId });
+
+const employer = await connectFreighter();
+const unsignedXdr = await client.createStream(
+ employer,
+ "G",
+ "C",
+ 1_000_000n, // deposit (stroops)
+ 100n, // rate_per_second
+ 0n, // stop_time (0 = indefinite)
+ 0n // cooldown_period
+);
+const signedXdr = await freighterSignTransaction(unsignedXdr, Networks.TESTNET);
+const txHash = await client.submitTransaction(signedXdr);
+```
+
+### Withdraw
+
+```ts
+const employee = await connectFreighter();
+const xdr = await client.withdraw(employee, 0n);
+const signed = await freighterSignTransaction(xdr, Networks.TESTNET);
+await client.submitTransaction(signed);
+```
+
+### Real-time claimable polling (#104)
+
+```ts
+import { pollClaimable } from "@paystream/sdk";
+
+const handle = pollClaimable(client, 0n, 5000, (amount) => {
+ console.log("Claimable:", amount.toString());
+});
+
+// Stop polling later:
+handle.unsubscribe();
+```
+
+## API
+
+| Method | Description |
+|---|---|
+| `getStream(id)` | Read full stream state |
+| `claimable(id)` | Query withdrawable amount now |
+| `claimableAt(id, ts)` | Query withdrawable at arbitrary timestamp |
+| `streamCount()` | Total streams created |
+| `initialize(admin)` | Init contract (admin only) |
+| `createStream(...)` | Create a stream, lock deposit |
+| `createStreamsBatch(employer, params[])` | Create multiple streams atomically |
+| `withdraw(employee, id)` | Withdraw all claimable earnings |
+| `topUp(employer, id, amount)` | Add funds to active stream |
+| `pauseStream(employer, id)` | Pause accrual |
+| `resumeStream(employer, id)` | Resume accrual |
+| `cancelStream(employer, id)` | Cancel, pay earned share, refund remainder |
+| `submitTransaction(signedXdr)` | Submit a signed transaction and wait |
+| `connectFreighter()` | Connect Freighter wallet, return public key |
+| `getFreighterPublicKey()` | Get current Freighter public key |
+| `freighterSignTransaction(xdr, network)` | Sign XDR with Freighter |
+| `isFreighterConnected()` | Check if Freighter is installed and connected |
+| `pollClaimable(client, id, ms, cb)` | Poll claimable balance at interval |
diff --git a/sdk/package.json b/sdk/package.json
new file mode 100644
index 0000000..d7dc220
--- /dev/null
+++ b/sdk/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@paystream/sdk",
+ "version": "0.1.0",
+ "description": "TypeScript SDK for PayStream Soroban contracts",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "scripts": {
+ "build": "tsc",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@stellar/stellar-sdk": "^13.1.0"
+ },
+ "devDependencies": {
+ "@freighter-api/freighter-api": "^2.3.0",
+ "typescript": "^5.4.5"
+ },
+ "peerDependencies": {
+ "@freighter-api/freighter-api": "^2.3.0"
+ },
+ "peerDependenciesMeta": {
+ "@freighter-api/freighter-api": {
+ "optional": true
+ }
+ }
+}
diff --git a/sdk/src/client.ts b/sdk/src/client.ts
new file mode 100644
index 0000000..5f2e7f7
--- /dev/null
+++ b/sdk/src/client.ts
@@ -0,0 +1,295 @@
+// SPDX-License-Identifier: Apache-2.0
+
+import {
+ Contract,
+ Networks,
+ SorobanRpc,
+ Transaction,
+ TransactionBuilder,
+ BASE_FEE,
+ nativeToScVal,
+ Address,
+ xdr,
+ scValToNative,
+} from "@stellar/stellar-sdk";
+import type { PayStreamClientOptions, Stream, StreamParams } from "./types.js";
+import { scValToStream } from "./convert.js";
+
+const TIMEOUT_SECONDS = 30;
+
+/**
+ * PayStreamClient wraps all PayStream Soroban contract functions with full
+ * TypeScript types.
+ *
+ * Read-only calls (get_stream, claimable, stream_count) use simulateTransaction
+ * and return values directly.
+ *
+ * Mutating calls return a prepared, unsigned transaction XDR string that the
+ * caller must sign (e.g. with freighterSignTransaction) and submit via
+ * submitTransaction.
+ */
+export class PayStreamClient {
+ private readonly rpc: SorobanRpc.Server;
+ private readonly contract: Contract;
+ private readonly networkPassphrase: string;
+ private readonly contractId: string;
+
+ constructor(opts: PayStreamClientOptions) {
+ this.rpc = new SorobanRpc.Server(opts.rpcUrl, { allowHttp: true });
+ this.contract = new Contract(opts.contractId);
+ this.networkPassphrase = opts.networkPassphrase;
+ this.contractId = opts.contractId;
+ }
+
+ // ─── helpers ────────────────────────────────────────────────────────────────
+
+ /** Build a transaction calling `method` with `args`, simulate, and return XDR. */
+ private async buildTx(
+ callerPublicKey: string,
+ method: string,
+ args: xdr.ScVal[]
+ ): Promise {
+ const account = await this.rpc.getAccount(callerPublicKey);
+ const tx = new TransactionBuilder(account, {
+ fee: BASE_FEE,
+ networkPassphrase: this.networkPassphrase,
+ })
+ .addOperation(this.contract.call(method, ...args))
+ .setTimeout(TIMEOUT_SECONDS)
+ .build();
+
+ const simResult = await this.rpc.simulateTransaction(tx);
+ if (SorobanRpc.Api.isSimulationError(simResult)) {
+ throw new Error(`Simulation failed: ${simResult.error}`);
+ }
+ const prepared = SorobanRpc.assembleTransaction(
+ tx,
+ simResult
+ ).build();
+ return prepared.toXDR();
+ }
+
+ /** Simulate a read-only call and return the raw ScVal result. */
+ private async simulateRead(
+ method: string,
+ args: xdr.ScVal[]
+ ): Promise {
+ const account = await this.rpc.getAccount(
+ // Use a well-known testnet account for read-only sims; no auth needed.
+ "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"
+ );
+ const tx = new TransactionBuilder(account, {
+ fee: BASE_FEE,
+ networkPassphrase: this.networkPassphrase,
+ })
+ .addOperation(this.contract.call(method, ...args))
+ .setTimeout(TIMEOUT_SECONDS)
+ .build();
+
+ const simResult = await this.rpc.simulateTransaction(tx);
+ if (SorobanRpc.Api.isSimulationError(simResult)) {
+ throw new Error(`Simulation failed: ${simResult.error}`);
+ }
+ const success = simResult as SorobanRpc.Api.SimulateTransactionSuccessResponse;
+ if (!success.result) throw new Error("No result from simulation");
+ return success.result.retval;
+ }
+
+ /**
+ * Submit a signed transaction XDR and wait for confirmation.
+ * @returns The transaction hash.
+ */
+ async submitTransaction(signedXdr: string): Promise {
+ const tx = TransactionBuilder.fromXDR(
+ signedXdr,
+ this.networkPassphrase
+ ) as Transaction;
+ const sendResult = await this.rpc.sendTransaction(tx);
+ if (sendResult.status === "ERROR") {
+ throw new Error(`Submit failed: ${JSON.stringify(sendResult.errorResult)}`);
+ }
+ const hash = sendResult.hash;
+ // Poll for confirmation
+ for (let i = 0; i < 20; i++) {
+ await new Promise((r) => setTimeout(r, 1500));
+ const status = await this.rpc.getTransaction(hash);
+ if (status.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) {
+ return hash;
+ }
+ if (status.status === SorobanRpc.Api.GetTransactionStatus.FAILED) {
+ throw new Error(`Transaction failed: ${hash}`);
+ }
+ }
+ throw new Error(`Transaction not confirmed after timeout: ${hash}`);
+ }
+
+ // ─── read-only ───────────────────────────────────────────────────────────────
+
+ /** Read the full state of a stream by ID. */
+ async getStream(streamId: bigint): Promise {
+ const val = await this.simulateRead("get_stream", [
+ nativeToScVal(streamId, { type: "u64" }),
+ ]);
+ return scValToStream(val);
+ }
+
+ /** Query how many tokens the employee can withdraw right now. */
+ async claimable(streamId: bigint): Promise {
+ const val = await this.simulateRead("claimable", [
+ nativeToScVal(streamId, { type: "u64" }),
+ ]);
+ return BigInt(scValToNative(val) as string | number);
+ }
+
+ /** Query claimable amount at an arbitrary timestamp. */
+ async claimableAt(streamId: bigint, timestamp: bigint): Promise {
+ const val = await this.simulateRead("claimable_at", [
+ nativeToScVal(streamId, { type: "u64" }),
+ nativeToScVal(timestamp, { type: "u64" }),
+ ]);
+ return BigInt(scValToNative(val) as string | number);
+ }
+
+ /** Total number of streams ever created. */
+ async streamCount(): Promise {
+ const val = await this.simulateRead("stream_count", []);
+ return BigInt(scValToNative(val) as string | number);
+ }
+
+ // ─── mutating (return unsigned tx XDR) ──────────────────────────────────────
+
+ /**
+ * Initialize the contract with an admin address.
+ * Returns unsigned transaction XDR.
+ */
+ async initialize(admin: string): Promise {
+ return this.buildTx(admin, "initialize", [
+ new Address(admin).toScVal(),
+ ]);
+ }
+
+ /**
+ * Create a salary stream. Returns unsigned transaction XDR.
+ *
+ * @param employer - Employer public key (pays and signs)
+ * @param employee - Employee public key
+ * @param tokenAddress - SEP-41 token contract ID
+ * @param deposit - Total tokens to lock
+ * @param ratePerSecond - Tokens streamed per second
+ * @param stopTime - Hard stop timestamp (0 = indefinite)
+ * @param cooldownPeriod - Min seconds between withdrawals (0 = none)
+ */
+ async createStream(
+ employer: string,
+ employee: string,
+ tokenAddress: string,
+ deposit: bigint,
+ ratePerSecond: bigint,
+ stopTime: bigint,
+ cooldownPeriod: bigint
+ ): Promise {
+ return this.buildTx(employer, "create_stream", [
+ new Address(employer).toScVal(),
+ new Address(employee).toScVal(),
+ new Address(tokenAddress).toScVal(),
+ nativeToScVal(deposit, { type: "i128" }),
+ nativeToScVal(ratePerSecond, { type: "i128" }),
+ nativeToScVal(stopTime, { type: "u64" }),
+ nativeToScVal(cooldownPeriod, { type: "u64" }),
+ ]);
+ }
+
+ /**
+ * Create multiple streams atomically. Returns unsigned transaction XDR.
+ */
+ async createStreamsBatch(
+ employer: string,
+ params: StreamParams[]
+ ): Promise {
+ const paramsScVal = xdr.ScVal.scvVec(
+ params.map((p) =>
+ xdr.ScVal.scvMap([
+ new xdr.ScMapEntry({
+ key: xdr.ScVal.scvSymbol("employee"),
+ val: new Address(p.employee).toScVal(),
+ }),
+ new xdr.ScMapEntry({
+ key: xdr.ScVal.scvSymbol("token"),
+ val: new Address(p.token).toScVal(),
+ }),
+ new xdr.ScMapEntry({
+ key: xdr.ScVal.scvSymbol("deposit"),
+ val: nativeToScVal(p.deposit, { type: "i128" }),
+ }),
+ new xdr.ScMapEntry({
+ key: xdr.ScVal.scvSymbol("rate_per_second"),
+ val: nativeToScVal(p.ratePerSecond, { type: "i128" }),
+ }),
+ new xdr.ScMapEntry({
+ key: xdr.ScVal.scvSymbol("stop_time"),
+ val: nativeToScVal(p.stopTime, { type: "u64" }),
+ }),
+ ])
+ )
+ );
+ return this.buildTx(employer, "create_streams_batch", [
+ new Address(employer).toScVal(),
+ paramsScVal,
+ ]);
+ }
+
+ /**
+ * Employee withdraws all claimable tokens. Returns unsigned transaction XDR.
+ */
+ async withdraw(employee: string, streamId: bigint): Promise {
+ return this.buildTx(employee, "withdraw", [
+ new Address(employee).toScVal(),
+ nativeToScVal(streamId, { type: "u64" }),
+ ]);
+ }
+
+ /**
+ * Employer tops up an active stream. Returns unsigned transaction XDR.
+ */
+ async topUp(
+ employer: string,
+ streamId: bigint,
+ amount: bigint
+ ): Promise {
+ return this.buildTx(employer, "top_up", [
+ new Address(employer).toScVal(),
+ nativeToScVal(streamId, { type: "u64" }),
+ nativeToScVal(amount, { type: "i128" }),
+ ]);
+ }
+
+ /**
+ * Employer pauses an active stream. Returns unsigned transaction XDR.
+ */
+ async pauseStream(employer: string, streamId: bigint): Promise {
+ return this.buildTx(employer, "pause_stream", [
+ new Address(employer).toScVal(),
+ nativeToScVal(streamId, { type: "u64" }),
+ ]);
+ }
+
+ /**
+ * Employer resumes a paused stream. Returns unsigned transaction XDR.
+ */
+ async resumeStream(employer: string, streamId: bigint): Promise {
+ return this.buildTx(employer, "resume_stream", [
+ new Address(employer).toScVal(),
+ nativeToScVal(streamId, { type: "u64" }),
+ ]);
+ }
+
+ /**
+ * Employer cancels a stream. Returns unsigned transaction XDR.
+ */
+ async cancelStream(employer: string, streamId: bigint): Promise {
+ return this.buildTx(employer, "cancel_stream", [
+ new Address(employer).toScVal(),
+ nativeToScVal(streamId, { type: "u64" }),
+ ]);
+ }
+}
diff --git a/sdk/src/convert.ts b/sdk/src/convert.ts
new file mode 100644
index 0000000..698c804
--- /dev/null
+++ b/sdk/src/convert.ts
@@ -0,0 +1,24 @@
+// SPDX-License-Identifier: Apache-2.0
+
+import { xdr, scValToNative } from "@stellar/stellar-sdk";
+import type { Stream, StreamStatus } from "./types.js";
+
+/** Convert a raw ScVal map (from get_stream) into a typed Stream object. */
+export function scValToStream(val: xdr.ScVal): Stream {
+ const native = scValToNative(val) as Record;
+ return {
+ id: BigInt(native["id"] as string | number),
+ employer: native["employer"] as string,
+ employee: native["employee"] as string,
+ token: native["token"] as string,
+ deposit: BigInt(native["deposit"] as string | number),
+ withdrawn: BigInt(native["withdrawn"] as string | number),
+ ratePerSecond: BigInt(native["rate_per_second"] as string | number),
+ startTime: BigInt(native["start_time"] as string | number),
+ stopTime: BigInt(native["stop_time"] as string | number),
+ lastWithdrawTime: BigInt(native["last_withdraw_time"] as string | number),
+ cooldownPeriod: BigInt(native["cooldown_period"] as string | number),
+ status: native["status"] as StreamStatus,
+ locked: native["locked"] as boolean,
+ };
+}
diff --git a/sdk/src/freighter.ts b/sdk/src/freighter.ts
new file mode 100644
index 0000000..5364b07
--- /dev/null
+++ b/sdk/src/freighter.ts
@@ -0,0 +1,89 @@
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * Freighter wallet integration for PayStream SDK.
+ *
+ * Freighter is a browser extension wallet for Stellar. This module provides
+ * helpers to connect, get the public key, and sign transactions.
+ *
+ * Usage:
+ * import { connectFreighter, getFreighterPublicKey, freighterSignTransaction } from "@paystream/sdk";
+ * const pubkey = await connectFreighter();
+ * const signed = await freighterSignTransaction(xdrString, networkPassphrase);
+ */
+
+/** Thrown when Freighter extension is not installed. */
+export class FreighterNotInstalledError extends Error {
+ constructor() {
+ super(
+ "Freighter wallet extension is not installed. " +
+ "Install it from https://freighter.app and reload the page."
+ );
+ this.name = "FreighterNotInstalledError";
+ }
+}
+
+function getFreighterApi(): typeof import("@freighter-api/freighter-api") {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const w = globalThis as any;
+ if (!w.freighterApi) {
+ throw new FreighterNotInstalledError();
+ }
+ return w.freighterApi;
+}
+
+/**
+ * Check whether Freighter is installed and connected.
+ * Returns true if the extension is present and the user has granted access.
+ */
+export async function isFreighterConnected(): Promise {
+ try {
+ const api = getFreighterApi();
+ return await api.isConnected();
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Request access to Freighter and return the user's public key.
+ * Throws FreighterNotInstalledError if the extension is absent.
+ */
+export async function connectFreighter(): Promise {
+ const api = getFreighterApi();
+ const { error } = await api.requestAccess();
+ if (error) throw new Error(`Freighter access denied: ${error}`);
+ const { publicKey, error: pkError } = await api.getPublicKey();
+ if (pkError) throw new Error(`Freighter getPublicKey failed: ${pkError}`);
+ return publicKey;
+}
+
+/**
+ * Get the currently selected Freighter public key without prompting.
+ * Throws if Freighter is not installed or not connected.
+ */
+export async function getFreighterPublicKey(): Promise {
+ const api = getFreighterApi();
+ const { publicKey, error } = await api.getPublicKey();
+ if (error) throw new Error(`Freighter getPublicKey failed: ${error}`);
+ return publicKey;
+}
+
+/**
+ * Sign a Stellar transaction XDR string with Freighter.
+ *
+ * @param xdr - Base64-encoded transaction XDR
+ * @param networkPassphrase - Network passphrase (e.g. Networks.TESTNET)
+ * @returns Signed transaction XDR string
+ */
+export async function freighterSignTransaction(
+ xdr: string,
+ networkPassphrase: string
+): Promise {
+ const api = getFreighterApi();
+ const { signedTxXdr, error } = await api.signTransaction(xdr, {
+ networkPassphrase,
+ });
+ if (error) throw new Error(`Freighter signing failed: ${error}`);
+ return signedTxXdr;
+}
diff --git a/sdk/src/index.ts b/sdk/src/index.ts
new file mode 100644
index 0000000..28035f4
--- /dev/null
+++ b/sdk/src/index.ts
@@ -0,0 +1,13 @@
+// SPDX-License-Identifier: Apache-2.0
+
+export { PayStreamClient } from "./client.js";
+export type { PayStreamClientOptions, Stream, StreamParams, StreamStatus } from "./types.js";
+export {
+ connectFreighter,
+ getFreighterPublicKey,
+ freighterSignTransaction,
+ isFreighterConnected,
+ FreighterNotInstalledError,
+} from "./freighter.js";
+export { pollClaimable } from "./poll.js";
+export type { PollHandle } from "./poll.js";
diff --git a/sdk/src/poll.ts b/sdk/src/poll.ts
new file mode 100644
index 0000000..47ae633
--- /dev/null
+++ b/sdk/src/poll.ts
@@ -0,0 +1,58 @@
+// SPDX-License-Identifier: Apache-2.0
+
+import type { PayStreamClient } from "./client.js";
+
+export interface PollHandle {
+ /** Stop polling and release resources. */
+ unsubscribe(): void;
+}
+
+/**
+ * Poll `claimable(streamId)` at a fixed interval and invoke `callback` with
+ * each result.
+ *
+ * @param client - Initialised PayStreamClient
+ * @param streamId - Stream to watch
+ * @param intervalMs - Polling interval in milliseconds (minimum 1000)
+ * @param callback - Called with the claimable amount on each tick
+ * @param onError - Optional error handler; defaults to console.error
+ * @returns A handle with an `unsubscribe()` method to stop polling
+ */
+export function pollClaimable(
+ client: PayStreamClient,
+ streamId: bigint,
+ intervalMs: number,
+ callback: (claimable: bigint) => void,
+ onError?: (err: unknown) => void
+): PollHandle {
+ const safeInterval = Math.max(intervalMs, 1000);
+ let active = true;
+
+ const tick = async () => {
+ if (!active) return;
+ try {
+ const amount = await client.claimable(streamId);
+ if (active) callback(amount);
+ } catch (err) {
+ if (active) {
+ if (onError) {
+ onError(err);
+ } else {
+ console.error("[pollClaimable] error:", err);
+ }
+ }
+ }
+ if (active) {
+ setTimeout(tick, safeInterval);
+ }
+ };
+
+ // Kick off immediately, then repeat
+ void tick();
+
+ return {
+ unsubscribe() {
+ active = false;
+ },
+ };
+}
diff --git a/sdk/src/types.ts b/sdk/src/types.ts
new file mode 100644
index 0000000..8d01221
--- /dev/null
+++ b/sdk/src/types.ts
@@ -0,0 +1,40 @@
+// SPDX-License-Identifier: Apache-2.0
+
+/** Status of a salary stream, mirroring the on-chain enum. */
+export type StreamStatus = "Active" | "Paused" | "Cancelled" | "Exhausted";
+
+/** Full stream state returned by get_stream. */
+export interface Stream {
+ id: bigint;
+ employer: string;
+ employee: string;
+ token: string;
+ deposit: bigint;
+ withdrawn: bigint;
+ ratePerSecond: bigint;
+ startTime: bigint;
+ stopTime: bigint;
+ lastWithdrawTime: bigint;
+ cooldownPeriod: bigint;
+ status: StreamStatus;
+ locked: boolean;
+}
+
+/** Parameters for a single stream in a batch create call. */
+export interface StreamParams {
+ employee: string;
+ token: string;
+ deposit: bigint;
+ ratePerSecond: bigint;
+ stopTime: bigint;
+}
+
+/** Options passed to PayStreamClient constructor. */
+export interface PayStreamClientOptions {
+ /** Soroban RPC endpoint URL. */
+ rpcUrl: string;
+ /** Network passphrase (e.g. Networks.TESTNET). */
+ networkPassphrase: string;
+ /** Deployed stream contract ID. */
+ contractId: string;
+}
diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json
new file mode 100644
index 0000000..3a6e75b
--- /dev/null
+++ b/sdk/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "commonjs",
+ "lib": ["ES2020", "DOM"],
+ "declaration": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ },
+ "include": ["src"]
+}