From 4cea8a6c6d8077005957a569299e2639bf9bdf9b Mon Sep 17 00:00:00 2001 From: Gloriachinedu Date: Sat, 25 Apr 2026 04:36:06 +0000 Subject: [PATCH 1/9] fix: add E010 error constant for employer==employee validation (#63) - Add ERR_SAME_PARTY (E010) constant to types.rs for consistent error codes - Use ERR_SAME_PARTY in validate_create_stream() instead of inline string - Update test to match new error code prefix --- contracts/stream/src/test.rs | 2 +- contracts/stream/src/types.rs | 1 + contracts/stream/src/validate.rs | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/contracts/stream/src/test.rs b/contracts/stream/src/test.rs index f315b65..38184d1 100644 --- a/contracts/stream/src/test.rs +++ b/contracts/stream/src/test.rs @@ -457,7 +457,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); diff --git a/contracts/stream/src/types.rs b/contracts/stream/src/types.rs index 694cb4e..e656ce8 100644 --- a/contracts/stream/src/types.rs +++ b/contracts/stream/src/types.rs @@ -86,3 +86,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. From 57d525a14586cd3d44b9280f477b4d8117746ebf Mon Sep 17 00:00:00 2001 From: Gloriachinedu Date: Sat, 25 Apr 2026 04:37:02 +0000 Subject: [PATCH 2/9] docs: add mainnet deployment runbook (#86) - Pre-deployment checklist (tests, keys, audit, XLM balance) - Step-by-step deploy commands for token and stream contracts - Post-deploy verification steps - Rollback procedure: pause+upgrade or revert to previous WASM --- docs/runbooks/mainnet-deploy.md | 190 ++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 docs/runbooks/mainnet-deploy.md 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. From 6f4ff468c6a39432609ad478d97d0c6fa4397ced Mon Sep 17 00:00:00 2001 From: Gloriachinedu Date: Sat, 25 Apr 2026 04:37:46 +0000 Subject: [PATCH 3/9] docs: add frontend integration guide (#91) - Connect wallet via Freighter - Create stream, withdraw, query state with TypeScript examples - Error handling with PayStream error codes --- docs/integration/frontend.md | 255 +++++++++++++++++++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 docs/integration/frontend.md 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/) From b6d82a1a5b5021d0ce6127fdee309f5671fb3135 Mon Sep 17 00:00:00 2001 From: Gloriachinedu Date: Sat, 25 Apr 2026 04:38:57 +0000 Subject: [PATCH 4/9] feat: add SDK usage examples in JS, Python, and Rust (#95) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - examples/javascript/stream.js — create stream, query state (Node.js) - examples/python/stream.py — create stream, query state (stellar-sdk) - examples/rust/stream.rs — create stream, query claimable (off-chain client) - examples/README.md — setup instructions and links - README.md — link examples/ and docs/integration/frontend.md --- README.md | 4 ++ examples/README.md | 30 +++++++++++ examples/javascript/stream.js | 91 ++++++++++++++++++++++++++++++++++ examples/python/stream.py | 93 +++++++++++++++++++++++++++++++++++ examples/rust/Cargo.toml | 13 +++++ examples/rust/stream.rs | 71 ++++++++++++++++++++++++++ 6 files changed, 302 insertions(+) create mode 100644 examples/README.md create mode 100644 examples/javascript/stream.js create mode 100644 examples/python/stream.py create mode 100644 examples/rust/Cargo.toml create mode 100644 examples/rust/stream.rs diff --git a/README.md b/README.md index 25b03ea..87a9f2e 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,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/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(()) +} From eb60d51fd5be03d7eb010d28876900e1b985cb15 Mon Sep 17 00:00:00 2001 From: Gina-georgina Date: Sun, 26 Apr 2026 03:37:36 +0000 Subject: [PATCH 5/9] Issue #75: add fund-lock audit documentation and recovery regression test --- contracts/stream/src/test.rs | 23 +++++++++ contracts/stream/src/types.rs | 1 + docs/security/fund-lock-audit.md | 82 ++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+) create mode 100644 docs/security/fund-lock-audit.md diff --git a/contracts/stream/src/test.rs b/contracts/stream/src/test.rs index 200b55e..47996b6 100644 --- a/contracts/stream/src/test.rs +++ b/contracts/stream/src/test.rs @@ -172,6 +172,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(); diff --git a/contracts/stream/src/types.rs b/contracts/stream/src/types.rs index 6d7bbec..0ebe267 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, } 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. From 4d973fd0c44108c01dc32d9b67b3e35ccc5a290b Mon Sep 17 00:00:00 2001 From: Gina-georgina Date: Sun, 26 Apr 2026 03:39:50 +0000 Subject: [PATCH 6/9] Issue #97: add performance benchmark documentation and API reference link --- docs/api-reference.md | 2 ++ docs/performance.md | 63 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 docs/performance.md diff --git a/docs/api-reference.md b/docs/api-reference.md index 49047d8..1d5d06f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -2,6 +2,8 @@ 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. + --- ## Stream Contract 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. From 7f06360dd53db349d8d30b2c8230193faec21ef5 Mon Sep 17 00:00:00 2001 From: Gina-georgina Date: Sun, 26 Apr 2026 03:40:49 +0000 Subject: [PATCH 7/9] Issue #87: add event schema reference documentation --- docs/api-reference.md | 4 ++ docs/events.md | 109 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 docs/events.md 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. From 4a8573f662afbb132a8762ecab706e63d23ec242 Mon Sep 17 00:00:00 2001 From: zeekman <55257085+zeekman@users.noreply.github.com> Date: Sun, 26 Apr 2026 12:06:24 +0000 Subject: [PATCH 8/9] feat: TypeScript SDK, Freighter wallet, pollClaimable, and demo app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #101 — TypeScript SDK (sdk/ package) Closes #102 — Freighter wallet integration Closes #103 — Demo React app Closes #104 — pollClaimable utility - sdk/: PayStreamClient wrapping all 10 contract functions with full TypeScript types; read-only calls via simulateTransaction, mutating calls return unsigned XDR for caller to sign and submit - sdk/src/freighter.ts: connectFreighter, getFreighterPublicKey, freighterSignTransaction, isFreighterConnected helpers; throws FreighterNotInstalledError with install link when extension absent - sdk/src/poll.ts: pollClaimable(client, streamId, intervalMs, cb) polls claimable() at a configurable interval, stops on unsubscribe, handles network errors gracefully - demo/: Vite + React app connecting to testnet; connect wallet, create stream, load stream by ID, live claimable balance (5 s poll), withdraw button for employee --- demo/.env.example | 2 + demo/README.md | 35 +++++ demo/index.html | 12 ++ demo/package.json | 24 ++++ demo/src/App.tsx | 193 +++++++++++++++++++++++++ demo/src/config.ts | 8 ++ demo/src/main.tsx | 5 + demo/src/usePayStream.ts | 137 ++++++++++++++++++ demo/tsconfig.json | 14 ++ demo/vite.config.ts | 10 ++ sdk/README.md | 97 +++++++++++++ sdk/package.json | 26 ++++ sdk/src/client.ts | 295 +++++++++++++++++++++++++++++++++++++++ sdk/src/convert.ts | 24 ++++ sdk/src/freighter.ts | 89 ++++++++++++ sdk/src/index.ts | 13 ++ sdk/src/poll.ts | 58 ++++++++ sdk/src/types.ts | 40 ++++++ sdk/tsconfig.json | 14 ++ 19 files changed, 1096 insertions(+) create mode 100644 demo/.env.example create mode 100644 demo/README.md create mode 100644 demo/index.html create mode 100644 demo/package.json create mode 100644 demo/src/App.tsx create mode 100644 demo/src/config.ts create mode 100644 demo/src/main.tsx create mode 100644 demo/src/usePayStream.ts create mode 100644 demo/tsconfig.json create mode 100644 demo/vite.config.ts create mode 100644 sdk/README.md create mode 100644 sdk/package.json create mode 100644 sdk/src/client.ts create mode 100644 sdk/src/convert.ts create mode 100644 sdk/src/freighter.ts create mode 100644 sdk/src/index.ts create mode 100644 sdk/src/poll.ts create mode 100644 sdk/src/types.ts create mode 100644 sdk/tsconfig.json 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 */} +
+

Create Stream

+
+ + + + + + + +
+ + {/* Load Stream */} +
+

Load Stream by ID

+
+ setLookupId(e.target.value)} + placeholder="Stream ID" + style={input} + /> + +
+
+ + {/* 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/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"] +} From 9ee987fc2cc74efe6976ab0c2be787867f354bee Mon Sep 17 00:00:00 2001 From: devSoniia Date: Sun, 26 Apr 2026 13:45:39 +0000 Subject: [PATCH 9/9] feat: add protocol fee mechanism (issue #125) - Add FeeBps and FeeRecipient variants to DataKey enum - Add PendingAdmin to DataKey (was missing, used by storage.rs) - Add get/set_fee_bps and get/set_fee_recipient storage helpers - Add set_protocol_fee admin function (0-100 bps, E011 if exceeded) - Deduct fee from withdrawal amount; send to fee_recipient - Fee of 0 disables the mechanism entirely - Fix broken use statement in test.rs and add missing imports - Add 5 tests: no fee default, 1% fee deduction, disable fee, above-max rejected (E011), non-admin rejected, 0.5% rounding Closes #125 --- contracts/stream/src/lib.rs | 64 ++++++++++++++--- contracts/stream/src/storage.rs | 21 ++++++ contracts/stream/src/test.rs | 118 +++++++++++++++++++++++++++++++- contracts/stream/src/types.rs | 8 ++- 4 files changed, 201 insertions(+), 10 deletions(-) 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..b1691d6 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(); @@ -610,4 +615,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..8a407e6 100644 --- a/contracts/stream/src/types.rs +++ b/contracts/stream/src/types.rs @@ -66,7 +66,12 @@ pub enum DataKey { EmployerStreams(Address), /// Index: employee address → Vec of stream IDs paying them. EmployeeStreams(Address), - MinDeposit, + /// Pending admin address for two-step admin transfer. + PendingAdmin, + /// Protocol fee in basis points (0–100, i.e. 0–1%). + FeeBps, + /// Address that receives collected protocol fees. + FeeRecipient, } /// Contract error codes – panic messages reference these names so callers can @@ -89,3 +94,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_FEE_TOO_HIGH: &str = "E011: fee_bps exceeds maximum of 100 (1%)";