Continuous, linearly-vesting streaming payments on Stellar / Soroban.
Stream salaries, grants, and unlocks token-by-token, in real time — on-chain, self-custodial, and denominated in any SEP-41 asset (including SAC-wrapped XLM, USDC, EURC, …).
A stream is a time-release payment. A sender locks a token amount for a fixed duration, and the receiver can withdraw the pro-rata amount that has accrued so far — on demand, at any time, without further involvement from the sender.
min(t, end) − start
accrued(t) = total × ─────────────────
end − start
withdrawable(t) = accrued(t) − withdrawn
- Sender →
create_stream(...)locks funds in the vault and starts the clock. - Receiver →
withdraw(...)pulls the accrued, un-withdrawn amount at any time. - Sender or receiver →
cancel(...)stops the stream, pays out the vested remainder to the receiver, and refunds the unvested remainder to the sender.
Every mutating call enforces require_auth() on the acting party, and all token
movement flows through the standard SEP-41
token::Client interface — so streams work identically for Stellar Asset
Contracts (SAC) and custom tokens.
Full C4-style system diagrams (context, containers, key flows) live in docs/architecture.md.
┌─────────────────────────────┐
│ Employer (sender) │
│ Freighter wallet (G…) │
└──────────────┬──────────────┘
│ create_stream(token, amount, duration)
│ require_auth(sender) · locks tokens
▼
┌─────────────────────────────┐
│ Soroban Vault │
│ StreamingContract (C…) │
│ │
│ stream = { │
│ sender, receiver, token, │
│ total, start, end, │
│ withdrawn, cancelled │
│ } │
└───────┬──────────────┬──────┘
│ │
withdraw(accrued) │ │ cancel(refund)
require_auth(receiver) │ │ require_auth(sender or receiver)
▼ ▼
┌────────────────────────┐ ┌────────────────────────┐
│ Receiver (payee) │ │ Employer (refund) │
│ withdraws on demand │ │ unvested remainder │
└────────────────────────┘ └────────────────────────┘
── roadmap (not yet implemented) ──
Employer ─► Vault ─► Payer ─► Split / Dependency streams ─► Sub-payees
- Contract (
contracts/core/) — holds locked funds and enforces the linear vesting schedule. Emitscreated,withdrawn, andcancelledevents for indexers. - Backend (
backend/) — Express API that maintains an in-memory index of stream state from the contract'screated/withdrawn/cancelledevents (seed +getEventspoll; seebackend/src/indexer.js), plus Horizon classic balances, serving the frontend with decorated stream data (accrued,withdrawable,progress). - Frontend (
frontend/) — Freighter wallet integration; builds, simulates, assembles, signs, and submitscreate_stream/withdraw/canceltransactions with@stellar/stellar-sdk.
- 🔐 Soroban smart contract —
#![no_std], strictrequire_auth()on every actor, check-effects-interactions ordering, overflow-checked arithmetic, and a complete unit/integration test suite with authorization-tree assertions. - 🧊 Frozen core, composable extensions —
stream-core(API v1) is small, auditable, and immutable; future capabilities (splits, schedules, payroll) ship as separate contracts that compose the core rather than extending it. - 🧪 Property-based invariant tests —
proptestrandomizes the vesting math across thousands of cases, and a settlement-invariant test proves no tokens are created or destroyed across create/withdraw/cancel scenarios. - ⛓️ SAC & SEP-41 native — streams any token through the standard token interface; no separate mint/clawback/admin logic.
- 🗂️ Node indexer / API — maintains an in-memory index of stream state from
the contract's lifecycle events (seed +
getEventspoll), plus Horizon account balances, with computed accrual/withdrawable/progress. - ⚛️ React + Freighter DApp — connect wallet, create streams, withdraw accrued amounts, and cancel streams from a clean dashboard.
- 📡 Lifecycle events —
created/withdrawn/cancelledtopics for event-based indexing and notifications. - 🔋 Rent-bumped leases — persistent entries, the contract instance, and the
Wasm code all have their TTL re-armed to the network maximum on every
lifecycle write and via permissionless keepers (
bump/bump_many/bump_instance), so long-running streams and an idle vault are never archived. - 🛡️ Pre-flight + decoded errors — every wallet action is validated (wallet
ready, gas, token balance, receiver trustline) before the Freighter dialog,
and raw Soroban
HostError/contract codes are decoded into human-readable messages.
stellar-stream-pay/
├── contracts/ # Rust / Soroban contracts (Cargo workspace)
│ ├── Cargo.toml # workspace root + release profile
│ └── core/ # frozen stream-core primitive (API v1)
│ ├── src/lib.rs # create_stream, withdraw, cancel + views
│ ├── src/test.rs # unit, integration, property & invariant tests
│ ├── Cargo.toml # stream-core package (soroban-sdk, proptest)
│ └── test_snapshots/ # committed SDK snapshot tests
├── backend/ # Node.js / Express indexer & API
│ ├── src/index.js # API routes (served from the event index)
│ ├── src/indexer.js # in-memory event index (seed + getEvents poll)
│ ├── src/keeper.js # TTL relayer: bump / bump_many / bump_instance
│ └── Dockerfile # container image
├── frontend/ # React / Vite DApp with Freighter wallet
│ ├── src/App.tsx # dashboard: view/manage streams, trigger actions
│ ├── src/lib/stellar/ # wallet, rpc, tx, error-decoding, pre-flight helpers
│ ├── src/lib/contracts/ # stream-core client (invoke + TTL keepers)
│ └── Dockerfile # multi-stage build + nginx serve
├── docs/ # architecture & design docs
│ ├── architecture.md # C4 system design
│ └── architecture-refactor.md # modular /sdk decoupling blueprint
├── .github/workflows/ # CI (contract, backend, frontend, PR title lint)
├── docker-compose.yml # local orchestration (backend + frontend)
├── CONTRIBUTING.md # development workflow & conventions
├── CODE_OF_CONDUCT.md # community guidelines
├── CHANGELOG.md # version history
├── SECURITY.md # vulnerability disclosure policy
└── README.md
| Tool | Why | Version |
|---|---|---|
| Rust | build the contract | 1.84+ |
stellar-cli |
build/deploy contracts | latest |
| Node.js | backend + frontend | 20+ (22 recommended) |
| Freighter | browser wallet for signing | latest |
Install stellar-cli:
cargo install stellar-cli --lockedVerify:
rustc --version # >= 1.84
stellar --version
node --version # >= 20cd contracts/core
# Compile to ../target/wasm32v1-none/release/stream_core.wasm.
# Always use `stellar contract build` (never plain `cargo build`).
stellar contract build
# Run the vesting-math + integration + property-based invariant tests.
cargo testUpload (install) the Wasm, then deploy an instance on Testnet:
# Install the compiled Wasm; prints a hex wasm hash.
stellar contract upload \
--wasm ../target/wasm32v1-none/release/stream_core.wasm \
--network testnet
# Deploy an instance from that hash; prints the contract id (C...).
stellar contract deploy \
--wasm-hash <WASM_HASH> \
--network testnetCopy the contract id — the backend and frontend both need it.
cd backend
cp .env.example .env # set STREAM_CONTRACT_ID to your contract id
npm install
npm start # or: npm run dev (watch mode)The server listens on http://localhost:4000:
| Endpoint | Description |
|---|---|
GET /health |
RPC connectivity + network info |
GET /api/stream/:address |
Streams where :address is sender or receiver (Soroban RPC) |
GET /api/account/:address |
Classic/SAC balances for an address (Horizon) |
GET /api/events |
Lifecycle events (created / withdraw / cancelled) for the contract |
curl http://localhost:4000/api/stream/GBVZ...YOUR...ADDRESScd frontend
cp .env.example .env # set VITE_CONTRACT_ID to your contract id
npm install
npm run dev # http://localhost:5173With the backend running (VITE_BACKEND_URL):
- Click Connect Freighter.
- Create a stream — enter a receiver, the SAC/token contract id, the amount in base units, and a duration in seconds.
- Streams where you are the receiver show a Withdraw button once something has vested; streams you sent show a Cancel button.
Both services ship container images plus a docker-compose.yml:
export STREAM_CONTRACT_ID=C... # backend
VITE_CONTRACT_ID=C... docker compose up --buildServes the backend on http://localhost:4000 and the frontend (with
VITE_CONTRACT_ID baked in at build time) on http://localhost:8080. All env
vars have Testnet defaults — see the .env.example files.
stream-core v4's permissionless bump / bump_many / bump_instance
entrypoints re-arm the contract instance, Wasm code, and stream entries so
they are never archived while holding funds — but someone has to call them.
backend/src/keeper.js is a scheduled relayer that does exactly that:
- every pass sends
bump_instance()once (re-arms the instance + code + admin/pause config + id counter), - reads every stream entry's remaining TTL via RPC and sends
bump_many([ids])— chunked intoKEEPER_BUMP_BATCH-sized batches (default 4) — only for streams below the threshold (default 30 days), so a fleet pays one tx per batch per pass and gas spend tracks need. If a batch fails (the deployed contract predatesbump_many, or the batch exceeds the per-tx footprint) it falls back to per-streambumpcalls, so nothing is ever left un-re-armed.
It needs a funded keeper account — any account works, the calls only pay gas. Run it as a long-lived process, or once per cron tick:
KEEPER_SECRET=S... npm run keeper # scheduler (daily by default)
KEEPER_SECRET=S... npm run keeper:once # single pass, for cron
KEEPER_SECRET=S... npm run keeper -- --dry-run # plan only, sends nothingOr as a Compose service (enabled by setting KEEPER_SECRET):
KEEPER_SECRET=S... docker compose up -d keeperTune with KEEPER_INTERVAL_MIN (pass cadence, default 1440 = daily),
KEEPER_TTL_THRESHOLD (bump streams below this remaining TTL, in ledgers —
518,400 ledgers ≈ 30 days), KEEPER_BUMP_BATCH (stream ids per bump_many
call, clamped to the contract's cap of 32; default 4), and
NETWORK_PASSPHRASE. See backend/.env.example for all variables.
Every pass emits one machine-parseable JSON line on stdout, so log shippers (Loki, Datadog, ELK) can alert on it directly:
{"event":"keeper_pass","ts":"…","ok":true,"streams":2,"due":2,"covered":2,"instanceBumped":true,"dryRun":false,"failed":[],"durationMs":123}Alert when ok is false, failed is non-empty, or covered < due.
In scheduler mode the keeper also serves a tiny health endpoint
(KEEPER_HEALTH_PORT, default 4300; 0 disables):
GET /health— liveness: the process is up.GET /status— last pass result (ok, counts, failures,durationMs),nextPassInSec, andlastErrorwhen a pass failed outright. The endpoint reports"degraded"after a failed pass.GET /metrics— Prometheus text-format exposition for scrapers (Prometheus, Grafana, VictoriaMetrics): monotonic counters (stream_core_keeper_passes_total,_pass_failures_total,_bumps_total,_batches_total,_failures_total) and last-pass gauges (_last_pass_timestamp_seconds,_last_pass_ok,_last_pass_duration_seconds,_streams,_due_streams,_covered_streams,_instance_bumped,_up). Counters accumulate across passes; gauges reflect the most recent pass. A fresh process emits zeroed series so alerting rules work from the first scrape.
docker compose up -d keeper runs a healthcheck against /health, so
docker compose ps shows the keeper's health. For k8s readiness probes, set
KEEPER_HEALTH_HOST=0.0.0.0.
Grafana alerting. Point a Prometheus scrape at
http://<keeper-host>:4300/metrics (publish KEEPER_HEALTH_PORT on the
container, or scrape the host directly) and alert on:
stream_core_keeper_last_pass_ok == 0— the most recent pass failed;increase(stream_core_keeper_failures_total[1h]) > 0— actions are failing;- a stalled keeper:
increase(stream_core_keeper_bumps_total[7d]) == 0whilestream_core_keeper_due_streams > 0— streams are expiring and nothing is re-arming them; - liveness:
up{job="stream-core-keeper"} == 0after scraping the job.
| Function | Auth | Description |
|---|---|---|
create_stream(sender, receiver, token, amount, duration_seconds) -> u64 |
sender |
Locks amount (base units) from sender and starts a stream. |
withdraw(receiver, stream_id) -> i128 |
receiver |
Pays out the currently accrued (un-withdrawn) amount. |
cancel(caller, stream_id) -> i128 |
sender or receiver |
Settles the stream: pays the vested remainder to the receiver and refunds the unvested remainder to the sender. |
get_stream(stream_id) -> Stream |
— | Read a stream. |
streamed_amount(stream_id) -> i128 |
— | Current vested amount (pro-rata). |
get_withdrawable(stream_id) -> i128 |
— | Amount currently available to withdraw. |
get_stream_count() -> u64 |
— | Total streams ever created. |
version() -> u32 |
— | Pinned core API version. |
bump(stream_id) |
— | Permissionless keeper: re-arms the stream entry's TTL lease (and the instance/code lease). |
bump_many(stream_ids) |
— | Permissionless keeper, batched: re-arms several streams (≤ MAX_BUMP_BATCH = 32) in one transaction; atomic — a missing id reverts the whole batch. |
bump_instance() |
— | Permissionless keeper: re-arms the contract instance + code TTL so an idle vault is never archived. |
- Streams: persistent storage, keyed by
DataKey::Stream(u64). - Stream counter: persistent storage, keyed by
DataKey::Counter.
The backend reads both directly via RPC getContractData.
- Authorization —
create_stream,withdraw, andcanceleach callrequire_auth()on the acting party; token pulls/pushes flow through the SEP-41token::Client, which enforces its own auth. - Input guards —
InvalidDurationrejectsduration_seconds == 0before any storage write (no divide-by-zero in vesting math);InvalidAmountrejects non-positive amounts;InvalidPartiesrejects sender == receiver; a missing stream id returnsStreamNotFoundand a secondwithdrawon a fully-drawn stream returnsNothingToWithdraw/StreamCancelled— cleanErrorcodes, never panics. - Check-effects-interactions — stream state is updated before external token
calls, and arithmetic overflow traps (rather than wraps) via
overflow-checks = truein the release profile (contractCargo.toml). - Circuit breaker (pause) — the deploy-time constructor binds a single,
never-re-settable admin address.
pause/unpause(admin-authed) gate NEW stream creation only;withdrawandcancelnever consult the flag, so receivers keep unconditional access to already-vested funds during a pause. - SAC compatibility — transfers use the standard SEP-41
token::Client, which works for SAC-wrapped native assets and custom tokens alike. The admin-onlytoken::StellarAssetClient(mint/clawback) is intentionally unused. - Frontend signing — transactions are simulated + assembled via
prepareTransaction(so auth entries and footprints are correct) before Freighter signs; the app never touches private keys. - Pre-flight validation — wallet readiness, gas, token balance, and receiver
trustlines are checked before the wallet dialog; simulation and submission
errors are decoded into readable messages (
decodeSorobanError). - Rent bumping —
create/withdraw/cancel/bump/bump_many/bump_instanceextend the persistent entries and the contract instance/code TTL to the network maximum, so neither streams nor the vault are archived mid-term. - Indexer scaling — the backend maintains an in-memory index of stream state
by seeding once and polling the contract's
created/withdrawn/cancelledevents (getEvents) forward, so/api/streamsand/api/stream/:addressare served from the index instead of scanning storage ids0..countper request.
See SECURITY.md for how to report vulnerabilities.
The repo is verified at every layer — CI (.github/workflows/) runs all of
this on every push:
Contract (cargo test -p stream-core from contracts/) — 66 tests:
- Vesting math — property tests (
proptest) randomize the time-weighted accrual formula across thousands of cases (vested_amount_is_bounded,_is_monotonic_in_time,_is_exact_floor_division,_endpoints_are_exact). - Integration — create → partial/full withdraw → cancel round trips with
exact pro-rata payouts; the settlement-invariant test proves sender refund +
receiver payouts always sum to exactly
total_amount(no value created or destroyed) across every scenario. - Authorization tree — unauthorized callers are rejected at every entrypoint
(
withdraw_rejects_non_receiver,cancel_rejects_uninvolved_party,pause_requires_admin_auth). - Edge cases — zero amount/duration, sender == receiver, double-cancel, nothing-to-withdraw, fee-on-transfer tokens, TTL re-arming, pause gating, and split-stream bounds.
- Committed SDK snapshots (
test_snapshots/) pin exact storage/event behavior.
Backend (cd backend && npm test) — 23 tests (node:test, zero new deps):
- TTL keeper —
bump_manybatching with per-stream fallback, dry-run, per-item error isolation, archived-entry skipping. - Monitoring — health/status endpoints and Prometheus metrics accumulation.
- Event indexer — fold/seed/backfill/poll semantics and enrich math.
Frontend (cd frontend && npx tsc -b) — typechecks clean, and the error
decoder smoke tests cover every contract code, host failure, Freighter
rejection, and network error shape.
Defaults target Testnet. For Mainnet, switch RPC_URL, HORIZON_URL,
VITE_RPC_URL, VITE_HORIZON_URL (pre-flight balance/trustline checks must
point at the same network as VITE_RPC_URL), and VITE_NETWORK_PASSPHRASE
(Public Global Stellar Network ; September 2015) and deploy with
--network mainnet. VITE_MIN_GAS_XLM is optional (defaults to 1 XLM).
- Core linearly-vesting stream contract (
create/withdraw/cancel+ views + events) - Unit, integration, and authorization-tree tests with committed snapshots
- Property-based invariant tests (proptest) + settlement-invariant suite
-
stream-corev1 extracted tocontracts/core/as a frozen, composable workspace member - Express indexer / API (Soroban RPC + Horizon)
- React + Freighter dashboard (connect, create, withdraw, cancel)
- Split streams — fan one stream out to multiple receivers (
create_split_stream/_bps/_pct,cancel_split,get_split) - Dependency streams — payer → sub-payee chains (multi-tier payroll)
- Event-based indexing — backend folds
created/withdrawn/cancelled(+ split) events into an in-memory index (/api/streams,/api/stream/:address) instead of the per-request id-scan - TTL keeper relayer — permissionless
bump/bump_many/bump_instanceon a schedule, with pass status + Prometheus metrics - Token metadata display (symbol + decimals) in the dashboard
- Relayer / gasless withdrawals for receivers
- Mainnet deployment + third-party audit
- CONTRIBUTING.md — development workflow, testing, and commit/PR conventions.
- CODE_OF_CONDUCT.md — community guidelines.
- docs/architecture.md — system architecture (C4-style diagrams).
- docs/architecture-refactor.md — modular
/sdkdecoupling blueprint. - CHANGELOG.md — version history.
MIT © StellarStream-Pay contributors.