StreamPay is a real-time payment-streaming smart contract for the Stellar network, written in Rust with the Soroban SDK.
The contract pins Soroban SDK 22.0.11 to keep contract builds reproducible. Update both the runtime and development dependency declarations together when upgrading.
A sender escrows a fixed amount of a token and the contract releases it to a recipient linearly over a time window. The recipient can withdraw the vested portion at any time, and either party can cancel an active stream to split the funds fairly between what has and has not yet vested.
- Linear, time-based vesting of an escrowed token amount.
- On-demand withdrawals of the vested-but-unwithdrawn balance.
- Cancellation that refunds the unstreamed remainder to the sender and pays the streamed remainder to the recipient.
- Authorization enforced with
require_authon every state-changing call. - Checked arithmetic throughout to avoid silent overflow.
- Events emitted for stream creation, withdrawal, and cancellation.
- A one-day timelock protects changes to the contract administrator.
| Function | Description |
|---|---|
initialize(admin, token) |
One-time setup: records the admin and the streamed token (SAC). |
create_stream(sender, recipient, total_amount, start_time, end_time) -> u64 |
Escrows total_amount from sender and opens a stream; returns its id. |
create_stream_batch(sender, requests: Vec<StreamRequest>) -> Vec<u64> |
Atomically opens several streams from one sender; validates all requests and escrows their aggregate amount in one token transfer. |
top_up(id, sender, amount) -> i128 |
Sender escrows amount more into an active stream; returns the new total. |
extend_stream(id, sender, new_end) |
Sender pushes back an active stream's end time, slowing vesting. |
streamed_amount(id) -> i128 |
View: amount vested so far based on the ledger timestamp. |
withdrawable_amount(id) -> i128 |
View: vested-but-unwithdrawn balance available to the recipient right now. |
remaining_amount(id) -> i128 |
View: amount not yet vested (the sender's potential refund). |
progress_bps(id) -> u32 |
View: vesting progress in basis points (0..=10_000) by elapsed time. |
percent_withdrawn(id) -> u32 |
View: share of the total already withdrawn, in basis points. |
duration(id) -> u64 |
View: length of the vesting window in seconds. |
elapsed(id) -> u64 |
View: seconds of the window elapsed so far (clamped to the window). |
get_summary(id) -> StreamSummary |
View: total, vested, withdrawn, withdrawable, progress, and status in one call. |
get_status(id) -> Status |
View: the stream's lifecycle status. |
is_active(id) -> bool |
View: whether the stream is still active. |
withdraw(id, recipient) -> i128 |
Recipient pulls the vested-but-unwithdrawn balance; returns the amount paid. |
cancel(id, caller) |
Sender or recipient cancels; splits funds by vested/unvested. |
get_stream(id) -> Stream |
View: the full stream record. |
get_admin() -> Address |
View: the configured admin. |
schedule_admin_transfer(admin, new_admin) -> u64 |
Admin-only: queues an admin transfer and returns its execution timestamp. |
execute_admin_transfer() |
Permissionless after the timelock: promotes the queued admin. |
cancel_admin_transfer(admin) |
Admin-only: cancels the queued admin transfer. |
get_pending_admin() -> Option<Address> |
View: the queued replacement admin, if any. |
get_admin_action_execute_after() -> Option<u64> |
View: timestamp at which the queued transfer can execute. |
get_token() -> Address |
View: the streamed token address. |
stream_counter() -> u64 |
View: number of streams created so far. |
The vested amount at timestamp t is:
vested(t) = 0 if t <= start
vested(t) = total if t >= end
vested(t) = total * (t - start) / (end - start) otherwise
Integer division truncates, so dust may accrue at the end of the window; it is
always fully released once t >= end.
create_stream_batch accepts a single sender and a Vec<StreamRequest>, where
each request provides recipient, total_amount, start_time, and end_time.
The sender authorizes the invocation once. The contract validates every request
before it transfers the summed escrow amount; if any request is invalid, the
whole call fails and creates no streams. Successful requests receive consecutive
stream IDs and each produces the usual created event.
The contract publishes an event for every lifecycle change so off-chain indexers can follow streams without polling. Each event's topics carry the event name and the stream id.
| Topic | Data | Emitted by |
|---|---|---|
("created", id) |
(sender, recipient, total) |
create_stream |
("toppedup", id) |
(sender, amount, new_total) |
top_up |
("extended", id) |
(sender, old_end, new_end) |
extend_stream |
("withdrawn", id) |
(recipient, amount) |
withdraw |
("cancelled", id) |
(caller, sender_refund, recipient_paid) |
cancel |
("admin_scheduled",) |
(current_admin, pending_admin, execute_after) |
schedule_admin_transfer |
("admin_transfer",) |
(previous_admin, new_admin) |
execute_admin_transfer |
("admin_cancelled",) |
admin |
cancel_admin_transfer |
The administrator cannot be changed immediately. The current admin calls
schedule_admin_transfer(admin, new_admin), which records the replacement and
an execution time exactly 86,400 seconds (one day) in the future. Until that
time, execute_admin_transfer() returns TimelockNotExpired. Once the delay
has passed, any account may execute the transfer; this makes an approved change
reliable even if the old admin is unavailable. The current admin can replace or
cancel a pending transfer before execution.
Install the Rust wasm32-unknown-unknown target and the
Stellar CLI,
then:
make build # compile the optimized release wasm
make test # run the unit test suite
make fmt # format the source tree
make clippy # lint with warnings deniedThe release artifact is written to:
target/wasm32-unknown-unknown/release/streampay_contract.wasm
Every push and pull request runs three CI checks:
- Format:
cargo fmt --all -- --checkenforces consistent code style. - Clippy:
cargo clippy --all-targets -- -D warningsdenies all clippy warnings, including pedantic lints. Known-safe casts are allowed at the crate level. - Test:
cargo testruns the full unit test suite.
All three checks must pass before a PR can be merged. Run them locally with:
make fmt-check # or `make fmt` to auto-fix formatting
make clippy
make testOr run all three in one command:
make lint# Optimize the wasm (optional but recommended).
make optimize
# Deploy to a network. Override SOURCE and NETWORK as needed.
make deploy NETWORK=testnet SOURCE=aliceAfter deploying, initialize the contract once with an admin and the address of the Stellar Asset Contract (SAC) to stream:
stellar contract invoke \
--id <CONTRACT_ID> \
--source alice \
--network testnet \
-- initialize \
--admin <ADMIN_ADDRESS> \
--token <TOKEN_SAC_ADDRESS>A stream moves through three statuses:
Active ──fully withdrawn──▶ Completed
│
└──────cancel───────────▶ Cancelled
- Active — the default after
create_stream. Funds vest over time, the recipient maywithdraw, and the sender maytop_uporextend_stream. - Completed — set automatically once the recipient has withdrawn the entire
total. Further withdrawals return
AlreadyCompleted. - Cancelled — set by
cancel. The vested-but-unwithdrawn portion is paid to the recipient and the unvested remainder is refunded to the sender. Further withdrawals returnAlreadyCancelled.
Once a stream leaves the Active status it is terminal: top_up and
extend_stream return StreamNotActive.
- A stream's escrowed balance is always
total - withdrawnwhile active; the contract never holds less than the sum of its active streams' balances. streamed_amount + remaining_amount == totalat every timestamp.withdrawnonly ever increases and never exceedstotal.- All token math is checked, so an overflow returns
Overflowrather than wrapping. - Batch escrow totals and cancellation payout splits use saturating aggregate
helpers in
src/aggregate.rsthat returnOverflowinstead of silently clamping (see ADR 0026).
Soroban charges for every transaction based on three resource dimensions: instructions, read/write bytes, and storage rent. Understanding these dimensions helps callers set appropriate resource limits and fee budgets.
StreamPay uses two Soroban storage tiers:
| Tier | Keys | Expiry |
|---|---|---|
| Instance | Admin, Token, Counter |
Shared with the contract instance; bumped on every write. |
| Persistent | Stream(id) one entry per stream |
Extended on every write_stream call. |
Instance storage is cheap per byte because all keys share a single ledger entry. Persistent entries cost more to maintain but survive indefinitely as long as they are accessed regularly.
Soroban ledger entries have a TTL measured in ledgers. Once an entry's TTL reaches zero the network can evict it, making it unreadable until it is restored (at additional cost).
StreamPay proactively extends TTLs whenever it writes storage:
| Constant | Value | Approximate real time (at 5 s/ledger) |
|---|---|---|
BUMP_THRESHOLD |
100,000 ledgers | ~6 days |
BUMP_EXTEND |
518,400 ledgers | ~30 days |
An entry whose remaining TTL is still above BUMP_THRESHOLD is not touched,
so callers do not pay an extension fee on every read. Any entry accessed at
least once a month is guaranteed to stay alive. Abandoned streams will
eventually be evicted by the network and their rent reclaimed.
The table below gives rough instruction-budget and byte-footprint guidance. Exact figures vary by network configuration and SDK version; always simulate the transaction before submitting.
| Operation | Storage reads | Storage writes | Token transfers | Notes |
|---|---|---|---|---|
initialize |
0 | 3 (Admin, Token, Counter) | 0 | One-time cost. |
create_stream |
2 (Token, Counter) | 2 (Stream, Counter) + instance | 1 (sender → contract) | Heaviest write path. |
top_up |
2 (Token, Stream) | 1 (Stream) + instance | 1 (sender → contract) | |
extend_stream |
1 (Stream) | 1 (Stream) + instance | 0 | |
withdraw |
2 (Token, Stream) | 1 (Stream) | 1 (contract → recipient) | |
cancel |
2 (Token, Stream) | 1 (Stream) | 0–2 (depends on balances) | Up to two transfers. |
| View functions | 1 (Stream) | 0 | 0 | Read-only; lowest cost. |
get_summary combines several view calculations into a single read, making
it cheaper in aggregate than calling each getter individually.
Use the Stellar CLI's simulate flag to get a precise fee breakdown before submitting:
stellar contract invoke \
--id <CONTRACT_ID> \
--source alice \
--network testnet \
--simulate-only \
-- create_stream \
--sender <SENDER> \
--recipient <RECIPIENT> \
--total_amount 1000000 \
--start_time 1700000000 \
--end_time 1702592000See docs/gas-and-fees.md for deeper coverage of the Soroban fee model, docs/resource-limits.md for instruction-budget limits, and docs/ttl-and-rent.md for the full TTL and rent strategy.
Licensed under the MIT License.