Soroban smart contracts for TricklePay, a token streaming protocol on Stellar.
A stream locks a sum of tokens from a sender and releases them to a recipient linearly over time. The recipient can withdraw whatever has vested at any moment; the sender can cancel and reclaim only the portion that has not yet vested. This is the on-chain primitive behind payroll, vesting, grants, and subscriptions, where value should move continuously rather than in lump sums.
This repository holds the stream contract and its test suite. The indexer and
web client that build on it live in separate repositories; see
Related repositories.
A stream is defined by a total amount and a window of time:
-
Start and end bound the linear release. At the start nothing has vested; at the end the full amount has vested; in between the vested amount grows in proportion to elapsed time. The
end_timemust be strictly in the future at the momentcreate_streamis called — a window whose end has already passed is rejected withStreamWindowInPast. A window whosestart_timeis in the past but whoseend_timeis still in the future is accepted: the elapsed portion vests immediately, making it useful for backdated payroll or grants that should have started earlier. -
Cliff (optional) is a point before which nothing can be withdrawn. When the cliff is reached, everything accrued since the start unlocks at once and vesting continues linearly from there.
cliff_timemust fall inside[start_time, end_time]; anything outside is rejected withInvalidCliff.A stream has no cliff when
cliff_time == start_time. There is no separate flag or null value to pass — the cliff is always a timestamp, and setting it to the start makes the gate vacuous.vested_amountwithholds everything whilenow < cliff_time || now < start_time, so when the two are equal that reduces tonow < start_time: exactly the start check every stream already applies. The no-cliff case is not special-cased anywhere in the vesting math, it simply falls out of the same expression, and fromstart_timeonward the amount is the plain lineartotal_amount * elapsed / duration. At the other end of the range,cliff_time == end_timeis equally valid and withholds everything until the window closes — a pure lockup that vests in one step.A no-cliff stream is what
create_stream(sender, recipient, token, 1000, 100, 1100, 100)opens, and it is the shape most of the contract tests use. Its schedule is tabulated under Example schedule below. -
Withdraw sends the recipient whatever has vested minus what they have already taken. A partial withdrawal (
withdraw_amount) names a figure instead and transfers exactly that, up to the same balance; whatever is left stays in the stream and keeps growing as more vests. The two can be mixed freely — draw a fixed sum each month, then sweep the remainder at the end. -
Cancel stops a stream early. The recipient keeps everything vested up to that moment; the unvested remainder is refunded to the sender. A cancelled stream's vested balance stays claimable.
A stream can also be read at any time without changing it. The vested and
locked amounts mirror each other and always sum to the total, while
progress reports the same ratio in basis points, from 0 to 10000, for
rendering a progress bar (for example, a value of 5000 means 50%). Cancelling
freezes the total at whatever had vested, so a cancelled stream reports nothing
locked and full progress (10000) even when it was stopped early. A stream with
a total_amount of zero also reports full progress (10000) at all times.
All amounts are in the token's smallest unit. All times are Unix timestamps in seconds, matching the ledger clock.
Both examples stream 1000 units from start_time = 100 to end_time = 1100
— the reference stream the vesting tests use. Every row below is asserted in
vesting.rs.
Without a cliff, cliff_time == start_time == 100 (no cliff):
| Time | Vested | Locked | Description |
|---|---|---|---|
| 50 | 0 | 1000 | before the start, nothing has vested; entire amount is locked |
| 350 | 250 | 750 | a quarter of the window has elapsed |
| 600 | 500 | 500 | the midpoint |
| 850 | 750 | 250 | three quarters |
| 1100 | 1000 | 0 | the end: fully vested; zero locked |
| 9999 | 1000 | 0 | past the end, still capped at the total |
With a cliff at the midpoint, cliff_time == 600:
| Time | Vested | Locked | Description |
|---|---|---|---|
| 300 | 0 | 1000 | past the start, but the cliff has not been reached; all 1000 remains locked |
| 600 | 500 | 500 | the cliff releases everything accrued since the start, unlocking 500 |
| 850 | 750 | 250 | vesting continues linearly from the cliff onward |
| 1100 | 1000 | 0 | the end: fully vested |
The two schedules agree everywhere from the cliff onward. A cliff does not change the rate or the total, it only withholds the earlier portion and then releases it in one step.
A few common edge cases are worth keeping explicit:
- An exact-end withdrawal is valid: once
now >= end_time, the stream is fully vested andwithdrawcan move the remaining balance out in one call. - A stream with
cliff_time == start_timeis a normal stream with no cliff; the vesting logic simply reduces to the standard start-time gate. - Cancellation is never retroactive. The recipient keeps all vested funds up to the cancellation instant, and the sender receives only the remaining unvested balance.
Vested amounts are computed as:
vested = total_amount * elapsed / duration
where elapsed = now - start_time and duration = end_time - start_time. Both
operands are cast to i128 before the multiplication so the product never
overflows for any amount at or below the MAX_AMOUNT cap (i64::MAX stroops).
Because this is integer (truncating) division, any fractional stroop is discarded toward zero. The recipient is never credited more than their exact linear share — the rounding always favours the contract.
No-cliff example: a stream of 1000 units over [100, 1100] with
cliff_time == start_time == 100 (no cliff):
| Time | elapsed |
Exact share | Vested (truncated) |
|---|---|---|---|
| 350 | 250 | 250.0 | 250 |
| 600 | 500 | 500.0 | 500 |
| 850 | 750 | 750.0 | 750 |
| 1100 | 1000 | 1000.0 | 1000 |
The schedule above divides evenly, so truncation has no visible effect. To see
it, consider **10 units over 3 seconds** queried at now == 1: 10 * 1 / 3 = 3 (not 4). This is explicitly tested in [vesting.rs](contracts/stream/src/vesting.rs) as integer_division_rounds_down`.
Limitation: a stream whose total_amount is not a multiple of duration
will silently lose at most duration - 1 stroops to rounding over the stream's
entire life. For example, 10 units over 3 seconds delivers only 9 (3 + 3 + 3)
rather than 10 — the last stroop never vests as a fractional unit and remains
in the contract after the window closes. Callers who require exact delivery
should size total_amount to be a multiple of duration, or accept the
rounding delta as a known, bounded cost.
Compatibility note: the formula and rounding behaviour are part of the public contract ABI. Any change to the rounding direction would constitute a breaking change to the on-chain interface.
| Function | Caller | Description |
|---|---|---|
create_stream(sender, recipient, token, total_amount, start_time, end_time, cliff_time) -> u64 |
sender | Locks total_amount and opens a stream, returning its id. |
withdraw(id) -> i128 |
recipient | Transfers the vested, unwithdrawn balance to the recipient. |
withdraw_amount(id, amount) -> i128 |
recipient | Transfers exactly amount; fails if it exceeds the withdrawable balance. |
cancel(id) -> i128 |
sender | Refunds the unvested remainder to the sender and freezes the stream. |
get_stream(id) -> Stream |
anyone | Returns the full stream record. |
withdrawable(id) -> i128 |
anyone | Amount the recipient can withdraw right now. |
vested(id) -> i128 |
anyone | Total vested so far, including what was withdrawn. |
locked(id) -> i128 |
anyone | Amount still unvested; zero once the stream completes or is cancelled. |
progress(id) -> u32 |
anyone | Vesting progress in basis points, from 0 to 10000. |
status(id) -> StreamStatus |
anyone | Pending, Streaming, Completed, or Cancelled. |
stream_count() -> u64 |
anyone | Number of streams created; ids run from 0 upward. |
The contract enforces Soroban authorization at the call site using
require_auth() on the participant whose action is being authorized:
create_stream(...)requiressender.require_auth().withdraw(...)andwithdraw_amount(...)requirerecipient.require_auth().cancel(...)requiressender.require_auth().
A missing or invalid signature is a host-auth failure, not a StreamError.
That is intentional: authorization errors are reported by Soroban before the
contract returns a user-facing enum value.
Concrete example: if Alice creates a stream to Bob using token T, the
wallet or client must sign the invocation with Alice's key. If the signer is
not Alice, the authorization step fails before the contract can check the
stream schedule or transfer funds.
Compatibility note: the contract does not accept a custom “approval token”
for these checks; the required signature mechanism is the standard Soroban
Address::require_auth() flow. Client code should therefore attach the exact
caller signature expected by the entry point rather than relying on a
non-standard allowance path.
Arguments are validated in a fixed order, and all of it runs before any tokens move or any storage is written — a rejected call leaves no transfer, no stream record, and no consumed id behind. When an argument list breaks more than one rule, the first group below decides the error, so integrators get the same answer every time rather than one that depends on check ordering:
| Group | Errors, in order | |
|---|---|---|
| 1 | Authorization | sender must authorize the call |
| 2 | Participants | InvalidParticipant |
| 3 | Amount | InvalidAmount, then AmountTooLarge |
| 4 | Schedule | InvalidTimeRange, then InvalidCliff, then StreamWindowInPast |
| 5 | Capacity | StreamCountExhausted |
Two participant rules are enforced in group 2. sender and recipient must
differ, and the token address must also be distinct from both of them. A stream
where token == sender or token == recipient is invalid because the token
contract cannot also act as a stream participant. The stream contract's own
address is also not valid in any role (sender, recipient, or token),
therefore each one triggers InvalidParticipant before any token transfer.
The first four calls move tokens and require authorization from the caller
named above. The rest are read-only views computed from the stream record and
the current ledger time; those that take an id return StreamNotFound when no
stream has it.
When calling create_stream, the full total_amount of tokens is pulled immediately from the sender into the stream contract address via TokenClient::transfer(&sender, &contract_address, &total_amount) (see contract.rs).
- Allowance Expectation: The contract expects the
senderto have a sufficient token balance and to have authorized the token transfer. On Soroban (SEP-41 / Stellar Asset Contract standard), callingcreate_streaminvokessender.require_auth(). In client integrations, thesendermust either include the token transfer in their invocation authorization or grant an allowance to the stream contract equal to or exceedingtotal_amount. - How it's checked: The allowance and balance check occurs in step 5 of
create_streamafter all validation checks (authorization, participants, amount, schedule, capacity) pass. Ifsenderlacks sufficient balance or token allowance/authorization, the token transfer panics before any stream state is created or stored. - Worked example:
- A sender holds 1,000 stroops of token
T. - The sender approves/authorizes the stream contract to transfer 1,000 stroops of token
T. - The sender invokes
create_stream(sender, recipient, token_T, 1000, 100, 1100, 100)(wherecliff_time == start_time == 100represents the no-cliff vesting case). - Step 5 executes
TokenClient::new(&env, &token_T).transfer(&sender, &contract_address, &1000). - The contract balance increases by 1,000 stroops, the sender balance decreases by 1,000 stroops, and stream ID
0is initialized with linear vesting mathvested = total_amount * elapsed / durationmatching the no-cliff example schedule investing.rs.
- A sender holds 1,000 stroops of token
Verification and test implementations can be reviewed in test.rs.
| Code | Variant | When returned |
|---|---|---|
| 1 | StreamNotFound |
No stream exists for the given id. |
| 3 | InvalidTimeRange |
start_time is not strictly before end_time. |
| 4 | InvalidAmount |
total_amount is zero or negative, or the withdrawal amount is non-positive. |
| 5 | InvalidCliff |
cliff_time falls outside [start_time, end_time]. |
| 6 | AlreadyCancelled |
Attempting to cancel a stream that was already cancelled. |
| 7 | NothingToWithdraw |
No vested balance is available to withdraw right now. |
| 8 | InsufficientBalance |
Requested withdrawal exceeds the available vested balance. |
| 9 | StreamAlreadyCompleted |
Attempting to cancel a stream that has fully vested (now >= end_time). |
| 10 | AmountTooLarge |
total_amount exceeds i64::MAX, the overflow-safety cap. |
| 11 | StreamWindowInPast |
end_time is at or before the current ledger timestamp. The stream would be 100 % vested on creation; use a direct token transfer instead. |
| 12 | StreamCountExhausted |
The id counter has reached u64::MAX. No further stream can be created; ids are never reused. |
| 13 | InvalidParticipant |
sender equals recipient, or sender/recipient/token is the stream contract's own address. |
Code 2 is permanently retired and will never be assigned to a new variant.
The contract publishes Created, Withdrawn, and Cancelled events, each
carrying the parties as topics so an indexer can filter streams by sender or
recipient. Created also carries the schedule, so a stream can be recorded
without a follow-up get_stream call, and withdraw and withdraw_amount
publish the same Withdrawn event.
On-chain enumeration of streams by address is deliberately not supported.
Streams are keyed by numeric id only. The contract does not maintain per-sender or per-recipient index lists for the following reasons:
- Soroban persistent storage is paid per entry and per ledger. Maintaining a dynamic list of ids under each address key would require unbounded storage growth and complex TTL management, imposing costs on every
create_streamcall that are proportional to how active the address is. - A contract-side list would need a maximum length cap or pagination scheme, adding surface area for bugs and gas exhaustion attacks.
How to enumerate streams for an address:
Use the Created event. Each Created event is published with sender and recipient as indexed topics, so any indexer (Horizon, RPC, or the tricklepay-backend) can filter events by topic to reconstruct the full set of stream ids for any address without a follow-up get_stream call. The event also carries the full schedule, so streams can be recorded on first observation.
For a contract-only consumer with no event access:
- Call
stream_count()to get the total number of streams. - Call
get_stream(id)for each id from0tostream_count() - 1and filter bysenderorrecipient.
This is O(n) over all streams and is only suitable for small deployments or one-off queries. Production consumers should use event indexing.
Soroban storage entries expire on a ledger clock and are archived once their time to live runs out. The contract keeps two kinds of entry alive on the same schedule:
| Entry | Storage type | Holds |
|---|---|---|
DataKey::Stream(id) |
persistent | one stream record |
DataKey::StreamCount |
instance | the id to assign to the next stream |
Both are granted ENTRY_TTL — 518,400 ledgers, roughly thirty days at the
standard five second close time — and both are extended back to that full
window whenever they are touched with fewer than BUMP_THRESHOLD (103,680
ledgers, roughly six days) remaining. Above that mark a touch is a deliberate
no-op, so an entry in frequent use does not pay to be re-extended on every
access.
The two are refreshed by different things:
- Stream entries are bumped as a side effect of being read or written, so
get_stream,withdrawable,vested,locked,progress,status,withdraw,withdraw_amount, andcancelall renew the stream they touch. A stream that is looked at even once every few weeks never expires, and a stream may run far longer than a singleENTRY_TTLwindow. - The instance is bumped only by
create_stream. Nothing renews it as a side effect of a read, so a contract that is queried but never written to will run its instance down.
That second point is why create_stream extends the instance explicitly. The
counter is the source of every id and never reuses one; if the instance were
archived, a fresh counter would restart at zero and the next stream would be
written over the record still sitting under Stream(0). Extending the instance
on the same schedule as stream entries keeps the counter alive for as long as
the streams it numbers.
Limitation: a stream left completely untouched for longer than ENTRY_TTL
is archived like any other Soroban entry. Recovering it requires a restore
operation submitted off-contract; the contract itself offers no way to revive
an archived stream. Callers holding long-dated streams should read them
periodically — any view call is enough.
The contract has no pause, freeze, or emergency-stop function. There is no admin or owner account. The deployed bytecode is immutable — there is no upgrade path. If a bug is discovered after deployment, in-flight streams cannot be halted or migrated; every token locked in a stream is exposed to any vulnerability in the deployed code for the full duration of that stream.
The only unilateral escape hatch available to either party is the sender's
cancel, which returns the unvested portion to the sender. It does not recover
tokens that have already vested.
This is an explicit design choice: adding a pause mechanism would introduce a privileged key whose compromise could freeze every stream on the contract simultaneously. The design removes that risk at the cost of operational flexibility.
Full details — including the rationale, consequences for lock-up decisions, and out-of-scope risks — are in THREAT_MODEL.md.
Minimum Supported Rust Version (MSRV): The MSRV is 1.84.0. This recent toolchain is required because soroban-sdk targets wasm32v1-none. The pinned versions are in rust-toolchain.toml.
Note that wasm32-unknown-unknown does not work: on Rust 1.82+ it enables wasm features the Soroban environment does
not support, and soroban-sdk fails the build rather than produce a bad artifact.
# Native build and the full test suite
cargo test
# Optimized WASM ready to deploy
cargo build --release --target wasm32v1-noneThe release artifact is written to
target/wasm32v1-none/release/tricklepay_stream.wasm.
Contract size affects deployment cost, since Soroban charges to store and load
bytecode. The [profile.release] settings in the root Cargo.toml are tuned
to minimize the size of that artifact, sometimes at the cost of build time or
raw runtime speed:
| Setting | Value | Effect |
|---|---|---|
opt-level |
"z" |
Optimizes for the smallest possible binary size, ahead of runtime speed. |
lto |
true |
Enables link-time optimization across the whole dependency graph, so the compiler can inline and eliminate dead code across crate boundaries — smaller (and often faster) output, at the cost of a slower build. |
codegen-units |
1 |
Compiles as a single codegen unit instead of splitting work in parallel, which allows more aggressive cross-function optimization at the cost of slower, non-parallel compilation. |
strip |
"symbols" |
Strips debug symbols and other metadata from the compiled artifact, shrinking it with no effect on behavior. |
debug |
0 |
Omits debug info from the build; it isn't used in a deployed WASM artifact and only adds size. |
debug-assertions |
false |
Disables debug_assert! checks in the compiled output, trimming both size and runtime overhead. |
overflow-checks |
true |
Kept enabled even in release mode, unlike the Rust default — a deliberate trade of a small amount of size and speed for safety, since a silent overflow in a token amount would be a serious bug. |
panic |
"abort" |
Aborts on panic instead of unwinding, removing the unwinding machinery from the binary for a smaller artifact. |
cargo test # unit and integration tests
cargo fmt --check # formatting
cargo clippy --all-targets # lints
cargo audit --deny warnings # uses .cargo/audit.toml ignoresThe audit ignores the unmaintained derivative and paste crates
(RUSTSEC-2024-0388 and RUSTSEC-2024-0436) and the yanked spin crate via
.cargo/audit.toml because they are transitive Soroban test-host dependencies
and are not used in the deployed WASM. Vulnerability advisories remain enabled;
see .cargo/audit.toml for the allowlist.
The suite covers the vesting math in isolation and the contract end to end:
stepwise withdrawal, partial withdrawal and its over-request and non-positive
guards, cliff gating, cancellation splits, the locked and progress views
across a stream's life, the cliff and no-cliff schedules documented above,
authorization requirements, invalid input, past and
boundary time-window rejection, backdated-start acceptance, multiple token
parallel streams, id-counter exhaustion at the u64::MAX boundary, rejection
of the contract's own address in each participant role, self-streams, the
documented precedence between validation groups, and double-withdraw and unknown-id guards.
It also covers the storage and event behaviour described above: the order in
which each entry point moves tokens and publishes its event, the indexed
event topics, the silence of a rejected call on the event stream, DataKey
encoding across the id range, and
the persistent-entry and instance time-to-live bumps on both sides of
BUMP_THRESHOLD.
scripts/deploy.sh wraps the Stellar CLI to build, install, and deploy the
contract. It expects a funded identity configured with stellar keys.
./scripts/deploy.sh <identity-name>If you see an error when building that mentions unsupported WebAssembly features or the wrong target:
error: compiling for `wasm32-unknown-unknown` is not supported
Fix: Soroban SDK requires the newer target on recent Rust versions. Always build with --target wasm32v1-none instead of wasm32-unknown-unknown.
If you see an error indicating that the standard library cannot be found:
error[E0463]: can't find crate for `core`
= note: the `wasm32v1-none` target may not be installed
Fix: Add the required WebAssembly target to your Rust toolchain by running:
rustup target add wasm32v1-none
When running the deployment script, if you encounter an error like:
error: account not found
or a transaction failure due to insufficient XLM on testnet.
Fix: Make sure the identity you are using is funded by running:
stellar keys fund <identity-name> --network testnet
1. Why are funds locked up front?
To guarantee that the recipient will actually receive the streamed tokens, the entire total_amount is pulled into the contract immediately upon creation. This prevents the sender from spending the funds elsewhere before they vest. See THREAT_MODEL.md for details on the security implications of this lock-up.
2. What happens if the project's servers disappear? The stream lives entirely on the Stellar ledger as a smart contract. You can interact with it using any Stellar Horizon or RPC node, even if our frontend or indexer goes down. The security model ensures that you do not depend on any off-chain infrastructure. See THREAT_MODEL.md.
3. Can I pause or freeze a stream?
No. There is no pause, freeze, or emergency-stop function. The only escape hatch is the sender's cancel function, which stops the stream and refunds only the unvested portion. See THREAT_MODEL.md.
4. Are there any admin keys that can steal or lock my funds? No, there is no admin or owner account. The deployed bytecode is immutable, meaning no privileged key can upgrade the contract, halt streams, or confiscate tokens. See THREAT_MODEL.md.
5. How do I enumerate my streams?
On-chain enumeration is not supported to save on storage and gas costs. You should use the Created event to index streams off-chain. See the Stream enumeration section above for more details.
contracts/stream/src/
lib.rs module wiring and public exports
contract.rs entry points: create, withdraw, cancel, views
vesting.rs pure linear-vesting calculations
types.rs Stream record and StreamStatus
storage.rs persistent storage keys and TTL handling
events.rs Created, Withdrawn, Cancelled events
error.rs contract error codes
test.rs integration tests and the shared test harness
Notable changes are recorded in CHANGELOG.md, including changes to the error codes, which are part of the public ABI.
The security properties and known limitations described above are documented in full in THREAT_MODEL.md.
- tricklepay-backend — indexes stream events and serves a read API.
- tricklepay-frontend — web client for creating and managing streams.
- tricklepay-docs — architecture, security model, and contributor guides.
MIT. See LICENSE.