Reference
ABI and accrual semantics
The function surface below is parsed from the deployed contract's own interface spec, so it cannot drift from what is on chain. The semantics sections are written by hand from the accrual module documentation.
- Contract
- CBCGTSCJ…EYGUTHXW
- Interface spec sha256
- acdfd259c7f9a854d42c5da4cda43138fb71b757b604a21c6dac8a8a5a3a86d1
- Authoritative source
- docs/ABI.md
Function surface
Generated from the deployed spec
Lifecycle
State-changing. Each requires the signature named.
create_stream(sender: Address, recipient: Address, token: Address, deposit: i128, start_time: u64, end_time: u64, cliff_time: u64, cancellable: bool, pausable: bool, transferable: bool) -> Result<u64, Error>top_up(stream_id: u64, amount: i128) -> Result<(), Error>withdraw(stream_id: u64, amount: Option<i128>) -> Result<i128, Error>batch_withdraw(recipient: Address, stream_ids: Vec<u64>) -> Result<i128, Error>cancel(stream_id: u64) -> Result<(), Error>pause(stream_id: u64) -> Result<(), Error>resume(stream_id: u64) -> Result<(), Error>transfer_recipient(stream_id: u64, new_recipient: Address) -> Result<(), Error>
Views
Read-only. No authorization, and no TTL side effects.
get_stream(stream_id: u64) -> Result<Stream, Error>vested_of(stream_id: u64) -> Result<i128, Error>withdrawable_of(stream_id: u64) -> Result<i128, Error>refundable_of(stream_id: u64) -> Result<i128, Error>stream_count() -> u64stream_exists(stream_id: u64) -> bool
Maintenance
Permissionless. Anyone may pay to keep a stream readable; the caller can only spend their own funds on rent.
extend_stream_ttl(stream_id: u64) -> Result<u32, Error>batch_extend_ttl(stream_ids: Vec<u64>) -> Result<u32, Error>
Amounts are i128 and cross JSON-RPC as strings. A client that parses them as numbers loses precision above 253, which is well inside the range of a 7-decimal USDC stream. StreamStatus crosses as its discriminant, not its name: 0 active, 1 paused, 2 cancelled, 3 depleted.
The Stream struct
Returned whole by get_stream
| cancellable | bool |
|---|---|
| cliff_time | u64 |
| deposited | i128 |
| end_time | u64 |
| pausable | bool |
| paused_at | Option<u64> |
| paused_total | u64 |
| recipient | Address |
| sender | Address |
| start_time | u64 |
| status | StreamStatus |
| token | Address |
| transferable | bool |
| withdrawn | i128 |
Derive vested, withdrawable and refundable from this one response rather than making three calls. A public RPC URL is an endpoint, not a node, and separate calls can be served by backends at different ledger heights — two view calls combined into one figure can disagree.
Invariants
Hold for every stream at every instant
- I1
0 ≤ withdrawn ≤ vested(t) ≤ deposited - Bounds. A recipient can never hold more than they have earned, and no stream can vest more than was put into it.
- I2
t₁ ≤ t₂ ⟹ vested(t₁) ≤ vested(t₂) - Monotonic in time. For fixed stream state, vesting never runs backwards as the clock advances.
- I3
S → S′ ⟹ vested(S′, t) ≥ vested(S, t) - Monotonic across calls. For a fixed instant t, no entry point may reduce what is already vested. This is the one that is easy to break — see below.
- I4
vested(t) + refundable(t) = deposited - Conservation, exactly, with no dust term at any instant.
- I5
paused_at.is_some() ⟺ status = Paused - Pause coherence. A stream is frozen if and only if it says it is, and while frozen the clock does not advance.
Accrual semantics
The part integrators get wrong
The stream clock
Everything is expressed against a clock that belongs to the stream and stops while the stream is paused:
stream_time = (paused_at ?? now) - paused_total
elapsed = clamp(stream_time, start_time, end_time) - start_time
duration = end_time - start_time
vested = 0 if stream_time < cliff_time
= deposited × elapsed / duration otherwise, rounding downReading paused_at is what makes the freeze actually freeze. The obvious alternative — treating the effective end asend_time + paused_total and comparing againstnow — is correct only after a resume. While a pause is still open, the current interval has not yet been added to paused_total, so that expression keeps advancing and the stream keeps accruing while supposedly frozen.
Pause stretches the schedule; it does not shorten it
Pausing freezes accrual and pushes the effective end forward by the paused duration. Total value delivered is unchanged — the schedule simply takes longer in wall-clock terms. The recipient can still withdraw while paused: pausing stops accrual, not access to what was already earned.
Worked example
| Stream | 1,000 tokens over 100 days, no cliff — 10/day |
|---|---|
| Day 30 | Sender pauses. vested = 300, and it stays 300 however long the pause lasts. |
| Day 80 | Sender resumes after 50 days. paused_total = 50 days. vested is still 300 — no jump on resume. |
| Day 100 | Originally the end date. vested = 500, not 1,000 — 50 days of the schedule are still owed. |
| Day 150 | The stretched end. vested = 1,000, exactly, and clamped there forever after. |
A pause spanning the cliff defers the cliff too
This is the subtlest interaction in the contract and the easiest thing to get wrong in a client. The cliff is gated onstream time, not wall-clock time. A stream that is frozen when its cliff date passes does not silently unlock.
Worked example — cliff at day 50, paused day 30
| Day 30 | Paused. Stream time stops at 30. |
|---|---|
| Day 55 | The wall clock has passed the cliff, but stream time is still 30. vested = 0, andwithdraw returnsNothingToWithdraw. A client that compares cliff_time against the wall clock will show this stream as unlocked. It is not. |
| Day 55, resumed | Stream time restarts at 30. Stillvested = 0. |
| Day 75 | Twenty more days of real accrual reach 50 of stream time. The cliff opens and releases everything accrued since start_time at once — vested = 500, not the 0 that accrued since the cliff. |
That last point is the general rule: the cliff gates the payout, it does not delay accrual. Value accrues from start_time throughout; the cliff only decides when it becomes withdrawable. Passcliff_time == start_time for no cliff, orcliff_time == end_time for a single lump sum at maturity.
Cancellation settles exactly, and stays distinguishable
Cancelling computes vested at that instant, refundsdeposited − vested to the sender, then rewrites the schedule so the stream looks like one that matured:deposited drops to the vested amount andend_time is pulled back to the current point on the stream clock. Every later call clamps to the reduced deposit, sowithdraw needs no special case.
Cancelling a paused stream settles against the frozen clock, not the wall clock. Cancelling before the cliff refunds everything, because pre-cliff the entitlement is zero by definition.
Cancelled is sticky. A cancelled stream drained to zero stays cancelled; it never becomesdepleted. The two terminal states answer different questions — depleted means it ran to term and the recipient took everything, cancelled means the sender clawed back the remainder — so do not collapse them in a projection.
Top-up extends the duration and holds the rate
The per-second rate agreed at creation never changes;end_time moves forward byamount / rate instead. Holding the rate fixed is what makes a top-up safe to accept from an untrusted sender: it can neither accelerate nor dilute an existing schedule.
The extension rounds down, which is load-bearing rather than cosmetic. Rounding up would make the new duration slightly longer than exact, lowering the rate and thereforeretroactively reducing the amount already vested — breaking I3, letting withdrawn exceedvested, and letting a later cancel refund the sender tokens the recipient already held. A top-up too small to buy one second of schedule is rejected withTopUpTooSmall rather than absorbed by raising the rate.
Archived streams
Persistent entries expire. If a stream's entry is archived the tokens are not lost — they remain in the pooled balance — but the accounting entry is unreadable until restored.stream_exists(id) == false whileid < stream_count() is the signal: that stream has been archived, not that it never existed. Surface a restore action rather than an error. Every mutating call re-extends its entry's rent, and extend_stream_ttl is permissionless, so an actively used or swept stream will not reach this state.
Errors
Discriminants are ABI — never renumbered, only appended
The CLI and RPC render these as Error(Contract, #N). Every failure mode is typed; no numeric edge case panics.
- #1StreamNotFound
- #2InvalidTimeRange
- #3InvalidCliff
- #4InvalidDeposit
- #5DepositRateTooLow
- #6SelfStream
- #7Unauthorized
- #8NotCancellable
- #9NotPausable
- #10NotTransferable
- #11StreamNotActive
- #12StreamNotPaused
- #13StreamAlreadyPaused
- #14StreamTerminated
- #15StreamMatured
- #16InsufficientWithdrawable
- #17NothingToWithdraw
- #18InvalidAmount
- #19BatchTooLarge
- #20EmptyBatch
- #21DuplicateStreamId
- #22Overflow
- #23TopUpTooSmall