How StreamPay works internally: the storage model, the accrual math, auth and reentrancy, TTL strategy, and the design decisions behind the v1 API. Read the README first for the overview.
┌────────────────────────────────────────────────────────────────────┐
│ StreamingPayments │
│ │
│ lib.rs entrypoints: create_stream / withdraw / withdraw_max / │
│ cancel_stream / top_up / get_stream / available / │
│ recipient_streams / sender_streams │
│ stream.rs Stream type, storage, checked accrual math │
│ errors.rs StreamError — stable numeric codes, append-only │
│ events.rs StreamCreated / Withdrawn / StreamCancelled / │
│ StreamToppedUp (stream_id as topic) │
└──────────────────────────┬─────────────────────────────────────────┘
│ token interface (SAC-compatible)
▼
Stellar Asset Contract / custom token
| Key | Value | Purpose |
|---|---|---|
DataKey::NextStreamId |
u64 |
monotonic id counter (ids start at 1) |
DataKey::Stream(id) |
Stream |
full per-stream state |
DataKey::RecipientStreams(addr) |
Vec<u64> |
append-only ids where addr receives |
DataKey::SenderStreams(addr) |
Vec<u64> |
append-only ids created by addr |
Streams live in persistent storage. Every mutating entrypoint that touches
a stream extends its TTL (TTL_THRESHOLD_LEDGERS = 200_000, extend to
500_000 ≈ 29 days), so a stream that is being used never goes cold, while
abandoned streams naturally archive to off-chain recovery — the Soroban-native
way to bound state bloat without penalizing active users. Index entries get
the same TTL treatment on write and on read, so an actively-queried address
stays hot.
There is still no global registry of all streams (no contract-wide
Vec<u64>): that would make create_stream cost grow with total stream count.
Instead, discovery is per-address: create_stream appends the new id to
the sender's and the recipient's own index lists — each bounded by that one
user's activity, written only at creation, and paginated on read
(recipient_streams / sender_streams, limit clamped to
MAX_PAGE_SIZE = 50). The indexes are advisory: never authorize against
them; the Stream entry is the source of truth, and StreamCreated events
remain the complete audit trail (an external indexer can still rebuild or
cross-check the lists).
Everything reduces to one pure function (stream.rs):
streamed_at(deposit: i128, duration: u64, elapsed: u64) -> Result<i128, StreamError>elapsed == 0(beforestart_time) →0elapsed >= duration(at/afterend_time) →deposit- otherwise →
deposit * elapsed / duration, floored
Safety properties:
- Capacity pre-check at creation/top-up.
validate_capacityrequiresdeposit ≤ u128::MAX / duration, so the worst-case product can never overflow later — turning per-call overflow handling into a one-time admission check. - Widened products. The product is computed in
u128; a result is capped atdepositbefore narrowing back toi128(safe:deposit ≤ i128::MAXwas checked on admission). - Checked everywhere. All
+/-on user-relevant amounts usechecked_add/checked_suband map failure toStreamError::MathOverflow. No arithmetic operator in the contract can panic. - Rounding never benefits the sender. Floor division means a partial
second is never counted as streamed; over the full window
streamed(duration) = depositexactly, so the recipient is always made whole by the end. The floor dust accumulates to the recipient, not the sender — verified by the property test (partial withdrawals ≤ full-window accrual).
The window is half-open [start_time, end_time): at now == start_time
nothing has accrued, at now ≥ end_time the full duration has elapsed.
| Action | who may call | auth consumed |
|---|---|---|
create_stream |
anyone; sender must authorize |
sender |
withdraw |
recipient only | recipient |
cancel_stream |
sender always; recipient iff cancelable |
caller |
top_up |
sender only | sender |
The cancelable flag is chosen per stream at creation: payroll senders often
want sender-only cancellation, while vesting recipients may want the right to
walk away with what they've earned.
require_auth() is called before any state reads that depend on the caller's
identity, and errors use StreamError codes rather than SDK auth panics where
the condition is a business rule (e.g. NotAuthorized for a stranger calling
cancel_stream).
Every entrypoint follows CEI ordering — all storage writes and event emissions happen before the token transfers:
withdraw: markwithdrawn += amount, setDepletedif fully drained, emitWithdrawn, thentoken.transfer.cancel_stream: setCancelled, record the payout as withdrawn, emitStreamCancelled, then the two transfers.create_stream/top_up: pull tokens first, persist after — if a reentrant callback abused the transfer-in, the persisted state still reconciles with the balance (invariant below).
Additionally, withdraw and cancel_stream re-read status after auth: a
malicious token contract that reenters withdraw during its own transfer
callback finds withdrawn already updated, and available_amount reverts to
the post-write (smaller or zero) value. The suite contains a
malicious-token test that attempts exactly this reentry.
Balance invariant: the contract's token balance is always
≥ Σ (deposit − withdrawn) across live streams. Funds enter only via
create_stream/top_up and leave only via withdraw/cancel_stream, and
every exit is bounded by the invariant 0 ≤ withdrawn ≤ streamed ≤ deposit.
create_stream
│
▼
Active ──── cancel_stream ────▶ Cancelled (terminal)
│ (pro-rata split:
│ recipient ← available,
│ sender ← deposit − streamed)
│
├─ withdrawn == deposit ──▶ Depleted (terminal)
│
└─ top_up allowed while Active (even after end_time:
funds become fully accrued → recipient withdraws)
- Cancelling a
Depletedstream reverts (StreamNotActive) — there is nothing left to split. - Cancelling a not-yet-started stream is a 100 % refund to the sender.
top_upon a cancelled/depleted stream reverts.
StreamError variants carry explicit numeric codes (contracterror), part of
the public interface:
| Code | Variant | Raised when |
|---|---|---|
| 1 | StreamNotFound |
no stream with this id |
| 2–3 | NotStreamSender / NotStreamRecipient |
reserved role checks |
| 4 | StreamNotCancellable |
recipient cancels a non-cancelable stream |
| 5 | StreamNotActive |
cancelled/depleted stream mutated |
| 6 | StartTimeInPast |
start_time before current ledger timestamp |
| 7 | InvalidTimeRange |
end_time ≤ start_time (zero duration) |
| 8 | DurationTooLong |
window exceeds MAX_DURATION_SECONDS (100 y) |
| 9 | ZeroAmount |
deposit / withdrawal / top-up ≤ 0 |
| 10 | AmountExceedsAvailable |
withdrawal above accrued balance |
| 11 | MathOverflow |
a checked computation would overflow |
| 12 | NotAuthorized |
caller is neither sender nor (cancelable) recipient |
| 13 | NothingToWithdraw |
withdraw_max with an empty accrued balance |
Codes are append-only: never renumber or reuse a variant.
Wall-clock accrual (timestamp), not per-ledger counters. Streams are
expressed in seconds so a stream behaves identically on testnet, mainnet, and
in tests. On mainnet ledgers are ~5 s, so granularity is bounded by the
ledger cadence; the floor division in streamed_at makes under-granular
ledger boundaries always conservative.
Sender pulls via transfer_from (approval), not first-class
TokenInterface::approve+escrow flows. One approval + one create tx; the
contract is the escrow. This matches SAC semantics (transfer_from consumes a
real allowance) and keeps create_stream atomic.
Top-up does not extend end_time. Extending would re-baseline accrual and
can retroactively reduce the recipient's available balance (the extra funds
spread over a longer window). v1 keeps top-up strictly additive — accrued
amounts only grow. If demand exists, a create_stream from the remainder of a
fully-streamed stream is the clean composition (roadmap).
No global stream registry; per-address append-only indexes instead. See
§2. A contract-wide id vector couples create_stream cost to total adoption
and re-reads everything on every query; per-address lists keep writes O(1)
for the one user involved and reads paginated. Discovery remains safe because
the indexes are advisory only — auth and payouts always read the Stream
entry itself.
Terminal states are terminal. No reactivation, no partial cancellation. Simplicity is a security feature; richer flows belong in composing contracts or off-chain UX.
- Unit tests (
test.rs) cover every spec edge case: withdraw before start / at / after end, sequential partial withdrawals, cancel before start / fully-withdrawn / not-cancelable, top-up after end, zero-duration and zero-amount rejection,i128::MAX-scale checked math, event emission (typed, per-contract-filtered), and a malicious-token reentrancy attempt. - Property test: random
(deposit, duration)— for random sample pointst ≤ duration, the sum of payouts up totnever exceedsstreamed_at(deposit, duration, t), andstreamed_at(..., duration) == depositexactly. - Rate math is pure (
streamed_attakes noEnv), so thecargo-fuzztarget infuzz/hammers it without a Soroban host: bounds, exact floor-ratio reference comparison, monotonicity, and "errors are only ever genuineMathOverflow". CI runs a scheduled session (fuzz.yml, non-blocking); any crash becomes a regression unit test. withdraw_maxshares thewithdrawpayout tail (pay_out) so both paths inherit the same CEI ordering and reentrancy proof; index pagination and TTL behavior have dedicated tests intest.rs.
pause_stream(sender-initiated temporary hold) — needs a fourth status and carefulcancel_amountstreatment; drafted as a good-first-issue.- Multi-token batch creation — one tx, N streams; watch the instruction
budget, likely
try_create_batchwith per-item results. - Gas optimization pass on storage reads —
cancel_streamdoes one read-modify-write; aStreamsplit into hot/cold fields could shave bytes. - Event indexer service — TypeScript worker polling
getEventsper topicstream_id, materializing stream history for the UI.