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.
All timestamps are Unix seconds. The start_time, end_time, and cliff_time parameters are u64 Unix timestamps in seconds, matching the Soroban ledger clock (env.ledger().timestamp()). A caller using milliseconds (such as JavaScript's Date.now()) would create a stream that appears to never start, since a timestamp like 1735689600000 (January 1, 2025 in milliseconds) is interpreted as a date billions of years in the future when read as seconds. The contract does not validate timestamp magnitude or convert units; the caller must ensure all times are in seconds.
Concrete example: To create a one-month stream starting on January 1, 2025 at 00:00:00 UTC and ending on February 1, 2025 at 00:00:00 UTC, convert both dates to Unix seconds:
- January 1, 2025 00:00:00 UTC =
1735689600seconds since the Unix epoch (not1735689600000milliseconds). - February 1, 2025 00:00:00 UTC =
1738368000seconds.
Call create_stream(sender, recipient, token, total_amount, 1735689600, 1738368000, 1735689600) where cliff_time == start_time represents the no-cliff case. The ledger clock increments in seconds, so vesting progresses one second at a time from start_time toward end_time.
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. This is the usual default when a stream should begin vesting immediately fromstart_timerather than waiting for an explicit cliff. 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.
withdraw_amount is easy to confuse with withdraw: both pay out vested
tokens, but withdraw always sweeps the full available balance while
withdraw_amount lets the recipient take a smaller, named amount and leave
the rest streaming.
Using the same no-cliff reference stream from Example schedule
-
1000 units,
start_time = 100,end_time = 1100- atnow = 600the midpoint has been reached, so 500 units have vested and none have been withdrawn yet:withdrawable(id) == 500
The recipient draws only 200 of it:
withdraw_amount(id, 200) -> 200
This transfers exactly 200 units and leaves the remaining 300 of the vested 500 in the stream, still claimable and still separate from whatever vests next:
withdrawable(id) == 300
Requesting more than that remaining balance fails outright - nothing is transferred and nothing is recorded as withdrawn:
withdraw_amount(id, 400) -> Err(InsufficientBalance)
The call only checks the current withdrawable balance (300), not the stream's total or its still-locked portion (500), so lowering the request to 300 or less succeeds; asking for 301 or more repeats the same failure until more of the stream vests.
Using the same no-cliff reference stream — 1000 units, start_time = 100,
end_time = 1100 — cancelled at now = 600 (the midpoint):
- 500 units have vested. The recipient's accrued share is frozen and stays
claimable via
withdraworwithdraw_amountat any time after cancellation. - 500 units have not vested. This unvested remainder is refunded to the
sender immediately by the
cancelcall itself — no separate step required. - No further vesting occurs. The stream is frozen at
total_amount = 500andend_time = 600; the vesting window is closed, so the vested amount cannot grow beyond what had accrued at the cancellation instant.
cancel(id) -> 500 // 500 refunded to sender; 500 stays claimable by recipient
If the recipient had already withdrawn 200 of the 500 vested units before cancellation, the split is the same — the sender still gets only the 500 unvested units back, not the 200 the recipient already took. The recipient can then claim the remaining 300 of their vested share:
// at now = 600, after recipient withdrew 200 earlier:
cancel(id) -> 500 // sender refund (unvested only)
withdraw(id) -> 300 // recipient claims their remaining vested balance
When a stream has a cliff, the vested amount before the cliff is zero,
even if time has passed since start_time. Cancelling before the cliff
refunds the entire total to the sender and leaves the recipient with
nothing claimable.
Using a reference stream with a cliff — 1000 units, start_time = 100,
end_time = 1100, cliff_time = 600, cancelled at now = 300
(before the cliff):
- 0 units have vested. The cliff blocks all accrual until
now >= 600. - 1000 units are refunded to the sender. The entire total is unvested.
- The recipient claimable balance is 0. Nothing has vested, so nothing can be withdrawn.
Cancelling at the cliff (or any point after) behaves like the no-cliff
example: whatever has accrued is split between the two parties. At
now = 600 (the cliff instant), 500 units have vested:
In all cases, cancellation permanently freezes the stream. No further vesting occurs after the call.
Anyone can confirm that a live contract was built from this source by comparing the on-chain bytecode hash with the hash produced by a local build.
The WASM artifact must be built with the same toolchain version that the
deployed binary used. The pinned toolchain in rust-toolchain.toml ensures
this, but only if you have not overridden it:
cargo build --release --target wasm32v1-noneThe optimised artifact is written to
target/wasm32v1-none/release/tricklepay_stream.wasm.
Why wasm32v1-none? On Rust 1.82 and later, the familiar
wasm32-unknown-unknown target enables WASM reference-types and
multi-value features by default. The Soroban host rejects these
features, so builds targeting wasm32-unknown-unknown produce a WASM
module the network cannot execute. wasm32v1-none (Rust 1.84+) is the
supported target that avoids those extensions and produces a module the
Soroban environment accepts. If you build with the wrong target, Soroban
fails with a WasmVm error about unsupported reference-types or
multi-value features.
Compute its SHA-256 hash:
sha256sum target/wasm32v1-none/release/tricklepay_stream.wasmTo run one test while iterating on a focused change, pass the test name after
cargo test:
cargo test create_stream_locks_funds_and_assigns_idThe command is run from the workspace root and matches the test by name across
the workspace. To see output printed by a passing test, pass --nocapture to
the Rust test harness after --:
cargo test create_stream_locks_funds_and_assigns_id -- --nocaptureThe 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.
Every contract uploaded to a Stellar network is stored as a Wasm entry keyed
by the SHA-256 hash of the bytecode. That hash is also recorded in the
contract's instance ledger entry as the executable field. Retrieve it with
the Stellar CLI:
# Replace <CONTRACT_ID> with the deployed bech32 contract address and
# <NETWORK> with "testnet", "mainnet", or a custom RPC URL.
stellar contract inspect --id <CONTRACT_ID> --network <NETWORK>The output includes a wasm_hash field. This is the SHA-256 hash of the
bytecode the network is executing.
Alternatively, query the RPC directly:
stellar contract fetch --id <CONTRACT_ID> --network <NETWORK> \
--out-file fetched.wasm
sha256sum fetched.wasmIf the hash from step 1 matches the wasm_hash from step 2, the live
contract was compiled from this exact source tree with the pinned toolchain.
Caveat — reproducibility: Rust WASM builds are not guaranteed to be
bit-for-bit reproducible across different host platforms, OS versions, or LLVM
releases, even when the same toolchain version is used. In practice, the pinned
toolchain in rust-toolchain.toml makes builds reproducible across Linux
hosts; macOS or Windows hosts may produce a hash that differs from the deployed
one even though the source is identical. If hashes do not match, try building
on a Linux host (or a Docker image with the pinned Rust toolchain) before
concluding that the deployment differs from the source.
A cancel call is rejected with StreamAlreadyCompleted if now >= end_time
— once the stream has fully vested there is nothing unvested to refund. A
stream that has already been cancelled cannot be cancelled again
(AlreadyCancelled).
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
- Ongoing improvements and fixes as part of active development.
- See commit history and open issues for detailed change tracking.
- Ongoing improvements and fixes as part of active development.
- See commit history and open issues for detailed change tracking.