Onboarding and integration reference for developers and auditors. Describes stream lifecycle, accrual formula, cliff/end_time behavior, access control, events, and error codes.
Source of truth: contracts/stream/src/lib.rs, contracts/stream/src/accrual.rs
Alignment verification: See protocol-narrative-code-alignment.md for complete mapping between this documentation and implementation.
When changing the contract:
- Update this doc if you change lifecycle, access control, events, or error semantics
- Update
protocol-narrative-code-alignment.mdto reflect changes - Run
cargo test -p fluxora_streambefore committing - Update snapshot tests if externally visible behavior changes
- No behavior change required for doc-only updates
Entrypoint index (validator): accept_recipient_update, batch_withdraw_to, bulk_cancel_streams, bulk_resume_streams_as_admin, cancel_recipient_update, close_cancelled_stream, close_completed_stream, compute_keeper_fee_split, delete_stream_template, get_auto_renew, get_global_emergency_paused, get_keeper_fee_split, get_paused_stream_count, get_pending_recipient_update, get_protocol_fees_accrued, get_recipient_stream_count, get_stream_health, get_stream_memo, get_stream_template, get_total_liabilities, global_resume, keeper_cancel, migration_v5_to_v6, renew_stream, set_auto_renew, set_contract_paused, set_global_emergency_paused, set_max_rate_per_second, version.
This document provides crisp success and failure semantics for all protocol operations. Treasury operators, recipient applications, and auditors can reason about contract behavior using only:
- On-chain observables: Persistent storage fields, emitted events, token transfers
- Published documentation: This file and referenced specifications
- Error classifications: Structured
ContractErrorvariants
No hidden rules or implementation details are required to understand protocol behavior.
From CONTRACT_VERSION 4, every stream may carry an optional bounded key-value map
(metadata: Option<Map<Bytes, Bytes>>) for rich integration data such as invoice IDs,
project codes, and external reference URIs.
| Entrypoint | Description |
|---|---|
create_stream(…, metadata) |
Pass Some(map) to attach metadata at creation, or None to omit. |
get_stream_metadata(stream_id) |
Returns Option<Map<Bytes, Bytes>>. Permissionless read. |
Metadata is also propagated through create_streams, create_streams_relative,
create_streams_partial, and create_stream_from_template via the metadata field on
CreateStreamParams / CreateStreamRelativeParams.
| Bound | Constant | Value |
|---|---|---|
| Maximum key-value pair count | MAX_METADATA_KEYS |
8 |
| Maximum aggregate (all keys + values) bytes | MAX_METADATA_BYTES |
512 |
| Maximum single key length | MAX_METADATA_KEY_BYTES |
32 |
| Maximum single value length | MAX_METADATA_VALUE_BYTES |
128 |
- Validated entirely at creation time; immutable post-creation.
- Stream ID is not allocated if metadata validation fails (no partial state written).
- No token movement occurs if metadata validation fails.
StreamCreatedevent includes themetadatafield for indexer consumption.
let mut meta = Map::new(&env);
meta.set(Bytes::from_slice(&env, b"invoice_id"), Bytes::from_slice(&env, b"INV-2026-001"));
meta.set(Bytes::from_slice(&env, b"project"), Bytes::from_slice(&env, b"PROJ-42"));
let stream_id = client.create_stream(
&sender, &recipient,
&deposit, &rate,
&start, &cliff, &end,
&0_i128, // dust threshold
&None, // memo
&Some(meta),
);
// Later — permissionless read
let stored_meta = client.get_stream_metadata(&stream_id);From CONTRACT_VERSION 3, integrators can register relative schedule skeletons (register_stream_template) and create streams from them (create_stream_from_template). This standardizes recurring payroll windows and trims repeated calldata versus always passing start_delay / cliff_delay / duration through the client for identical shapes.
- Auth: registering and deleting templates requires the template
ownersigner. Creating a stream from a template requires the fundingsenderto authorize (same ascreate_stream_relative). - Caps: per-owner and global template counts are bounded; see
MAX_TEMPLATES_PER_OWNERandMAX_GLOBAL_TEMPLATESincontracts/stream/src/lib.rs. - Errors:
TemplateNotFound,TemplateLimitExceeded,TemplateUnauthorized.
From CONTRACT_VERSION 4, the contract supports distinct streaming styles, governed by the StreamKind field on the stream configuration:
- Linear (Default/Legacy): Accrues tokens continuously and linearly over time at
rate_per_secondonce the stream has started, subject to a standard cliff window (during which nothing can be withdrawn). - CliffOnly: A one-shot, instant unlock stream variant. Tokens do not accrue continuously over time. Instead:
- Before the
cliff_time,0tokens are accrued/withdrawable (all funds are locked). - At or after the
cliff_time, the totaldeposit_amountis immediately and fully unlocked and made claimable by the recipient. - To enforce the single-unlock model,
rate_per_secondis forced to0during creation and all subsequent mutation/adjustment requests are rejected.
- Before the
- CliffSlope: A post-cliff linear accrual variant. Tokens accrue linearly only after the cliff:
- Before the
cliff_time,0tokens are accrued/withdrawable (all funds are locked). - At or after the
cliff_time, accrual begins from0and grows atrate_per_seconduntil theend_time(or untildeposit_amountis reached). - Rate changes and schedule mutations are rejected, similar to
CliffOnly.
- Before the
Off-chain orchestrators and indexers that build payment batches often need to know stream IDs before submitting create_stream transactions, to pre-populate database records or cross-reference external invoice systems.
reserve_stream_ids(caller, count) atomically advances the global ID counter by count and returns the reserved range as a Vec<u64>. Subsequent create_stream calls by the same caller consume IDs from the reservation in order; once exhausted (or if no reservation exists) the live counter is used.
| Constant | Value | Purpose |
|---|---|---|
MAX_ID_RESERVATION |
100 | Cap per call — prevents counter-inflation attacks |
RESERVATION_TTL_LEDGERS |
17 280 (~1 day) | Reservation expiry — abandoned ranges do not block the counter forever |
Security notes:
count = 0->ReservationCountZero(17).count > 100->ReservationLimitExceeded(18).- A new reservation for the same caller overwrites the previous one; the old IDs remain as a gap in the counter (same as any abandoned reservation).
- The TTL ensures persistent storage entries are cleaned up automatically.
Usage pattern:
1. Call reserve_stream_ids(caller, N) → get [id_0, id_1, …, id_{N-1}]
2. Pre-populate off-chain DB with those IDs
3. Submit N create_stream transactions — each consumes the next reserved ID in order
| Phase | Action | Notes |
|---|---|---|
| Creation | create_stream / create_streams_partial |
Sender deposits tokens; stream starts as Active |
| Clone | clone_stream |
Copies rate, cliff offset, threshold, and memo from a source stream; accepts new recipient and timing |
| Top-up | top_up_stream |
Extra deposit locked (sender or admin only); schedule unchanged |
| Pause | pause_stream / pause_stream_as_admin |
Stops withdrawals; accrual continues by time |
| Resume | resume_stream / resume_stream_as_admin / bulk_resume_streams_as_admin |
Restores withdrawals; blocked if past end_time (Terminal); batch is atomic |
| Cancellation | cancel_stream / cancel_stream_as_admin / bulk_cancel_streams |
Refunds unstreamed amount; frozen accrued stays for recipient |
| Withdrawal | withdraw / withdraw_to / batch_withdraw |
Recipient pulls accrued tokens; allowed on Paused if past end_time |
| Completion | Automatic | When withdrawn_amount == deposit_amount, status becomes Completed |
| Auto-renewal | set_auto_renew / renew_stream |
Sender opts in; anyone can trigger the next identical schedule from the sender's allowance |
| Rotation | update_recipient / accept_recipient_update / cancel_recipient_update |
Sender proposes a new recipient; the current recipient must accept. Pending rotations are queryable via get_pending_recipient_update. Acceptance updates both the stream record and recipient indexes atomically. |
| Transfer | transfer_claim_ownership |
Claim owner (or recipient if not set) transfers the sole withdrawal rights to a new owner immediately. |
| Auto-claim | set_auto_claim / revoke_auto_claim / trigger_auto_claim |
Recipient opts in to permissionless final claim at end_time to a chosen destination |
| Delegation | delegate_recipient_share |
Recipient delegates a portion of their future stream accrual (in basis points) to a new recipient. Creates a child stream and reduces parent rate. Bounded to a maximum depth of 3 to prevent unbounded chains. Cyclical delegation is prevented. |
- Active ↔ Paused (via pause/resume)
- Active or Paused → Cancelled (terminal)
- Active or Paused → Completed (when recipient withdraws full deposit; terminal)
Terminal states: Completed, Cancelled. Both may be closed via close_completed_stream to reclaim storage and index space. A stream is also considered technically terminal if ledger.timestamp() >= end_time.
In this "time-terminal" state, pause/resume is blocked, but withdrawal is always allowed regardless of previous pause status.
Cancelled stream closure rule: A Cancelled stream may only be closed after the recipient has fully withdrawn the frozen accrued amount. Attempting to close a Cancelled stream with remaining claimable balance returns ContractError::InvalidState. This prevents storage cleanup from destroying recipient funds.
Auto-renewal supports recurring payroll and subscription payments without granting a relayer authority to redirect funds.
- The original sender calls
set_auto_renew(stream_id, sender, true). Only that sender may enable or disable the setting. Cancelled streams cannot be enabled. - After the recipient has fully withdrawn the stream and its status is
Completed, any caller may callrenew_stream(stream_id). - Renewal pulls exactly the old stream's
deposit_amountfrom the original sender to the contract using the sender's pre-approved token allowance. The recipient is copied from the completed stream; the caller supplies no source or destination address. - The new stream starts at the current ledger timestamp and preserves the original duration, rate, cliff offset, stream kind, memo, and withdrawal dust threshold. The new stream is itself auto-renew-enabled.
The consumed old opt-in is disabled before the token interaction. Successful renewal
therefore cannot be replayed against the same completed stream. If the sender's token
balance or allowance is insufficient, the call returns the dedicated
ContractError::AutoRenewFundingUnavailable error and creates no new stream or event.
Token transfer failures are atomic as well: state, liabilities, and the opt-in revert
together with the failed transaction.
This section is the protocol-level contract for cancel_stream and cancel_stream_as_admin.
Success semantics (observable):
- Preconditions: stream status is
ActiveorPaused. cancelled_atis set to current ledger timestamp.- Accrued amount is frozen at
cancelled_at(no post-cancel time growth). - Refund is
deposit_amount - accrued_at_cancelled_at. - Stream transitions to terminal
Cancelledstate. StreamCancelledevent is emitted with topic("cancelled", stream_id).
Failure semantics (observable):
- Missing stream:
ContractError::StreamNotFound. - Non-cancellable status (
Completedor alreadyCancelled):ContractError::InvalidState. - Modification in terminal state (past
end_timefor pause/resume):ContractError::StreamTerminalState. - Unauthorized caller on sender path:
ContractError::Unauthorized. - Unauthorized caller on admin path:
ContractError::Unauthorized. - Redundant state change (pause already paused):
ContractError::StreamAlreadyPaused. - Redundant state change (resume already active):
ContractError::StreamNotPaused. - Any failure is atomic: no refund transfer, no state mutation, no cancel event.
Role boundaries:
cancel_stream: only the streamsendercan authorize.cancel_stream_as_admin: only contractadmincan authorize.- Recipient and third parties cannot cancel through either path unless they hold required credentials.
Invariants after successful cancellation:
status == Cancelledandcancelled_at.is_some().calculate_accrued(stream_id)always returns accrued atcancelled_at.refund + frozen_accrued == deposit_amount.- Recipient may withdraw only frozen accrued remainder (
frozen_accrued - withdrawn_amount).
Scope boundary and exclusions:
- In scope: refund math,
cancelled_atpersistence/freeze semantics, cancel auth paths, cancel event consistency. - Out of scope: token-level trust assumptions beyond documented model, off-chain indexer liveness, and economic policy choices (for example who should bear operational costs).
- Residual risk: if a non-standard token violates SEP-41 expectations, transfer behavior may diverge; CEI ordering reduces but cannot fully eliminate external token risk.
The keeper_cancel entrypoint allows any third-party keeper to cancel an expired, unwithdrawn stream after the grace period has elapsed.
Fee Accounting Note:
- The keeper fee (50 BPS) is deducted solely from the unstreamed refund bound for the sender.
- The contract does not retain a protocol split of this fee. The entire fee is transferred directly to the keeper.
- The view function
get_protocol_fees_accrued(added in #623) tracks the cumulative total of keeper fees paid out of the contract, rather than an internal sweepable balance. - Accounting Invariant: The contract's token balance must securely cover all remaining liabilities. Since the keeper fee is transferred entirely to the keeper and leaves the contract, the tracked total in
get_protocol_fees_accruedis strictly monotone and safely independent of the contract's real-time asset/liability ratio.
This section defines the success and failure behavior of clone_stream.
Success semantics (observable):
- Preconditions: Source stream must be in
ActiveorPausedstatus. - The contract creates a new stream inheriting the rate, cliff offset, dust threshold, and memo from the source stream.
- The new stream is initialized in the
Activestatus. - Tokens are pulled from the source stream's sender for the new deposit.
Failure semantics (observable):
- Terminal source state: If the source stream status is
CompletedorCancelled, the operation is rejected withContractError::StreamTerminalState. - Unauthorized: If the caller is not the sender of the source stream, the operation is rejected.
This section is the protocol-level contract for the global pause state managed via pause_protocol and resume_protocol.
Entrypoints:
| Function | Description |
|---|---|
pause_protocol(admin, reason) |
Globally pause new stream creation with audit trail (reason, timestamp, admin) |
resume_protocol(admin) |
Globally resume new stream creation, clearing audit trail |
is_paused() |
Query if protocol is currently paused (permissionless) |
get_pause_info() |
Query detailed pause info including audit trail (permissionless) |
set_max_rate_per_second(max_rate) |
Admin-only governance entrypoint that sets the maximum allowed stream rate for future rate updates |
migration_v5_to_v6(admin) |
Admin-only deployment checkpoint that emits a migrated audit event; no storage transformation is required |
Pause reason length: The reason string passed to pause_protocol is bounded by MAX_PAUSE_REASON_BYTES = 256. Strings longer than 256 bytes are rejected with ContractError::InvalidParams. This prevents unbounded ledger-entry growth (Issue #513).
Success semantics (observable):
- Preconditions: Caller must be the authorized contract
admin. - Storage: The
CreationPauseddata key is set totrueorfalsein instance storage. - Event:
ContractPaused(bool)is emitted with topic("paused_ctl",). - Effect on creation: When paused,
create_streamandcreate_streamsreturnContractError::ContractPausedand all new stream creation is blocked. - Effect on existing streams: Active streams are intentionally unaffected. Withdrawals, top-ups, pause/resume/cancel operations on individual streams continue to function normally.
Failure semantics (observable):
- Unauthorized caller on admin path:
ContractError::Unauthorized. - Any failure is atomic: no storage mutation, no event emitted.
Role boundaries:
pause_protocol/resume_protocol: only the contractadmincan authorize.- Senders and recipients cannot pause the global contract. Senders manage individual streams via
pause_stream.
Invariants when globally paused:
- No new streams can be persisted (no
createdevents, no deposit tokens pulled). - Existing streams do not change status due to a global pause.
- Audit trail (reason, timestamp, admin) is queryable via
get_pause_info().
Scope boundary: The global pause is strictly an administrative circuit breaker for new liabilities. It does not freeze funds of existing users or prevent recipients from withdrawing their vested entitlement.
Note on Stream Creation:
Stream creation is blocked while the protocol is globally paused. The create_stream function returns ContractError::ContractPaused if is_paused() is true. This applies to both single-stream and batch (create_streams) creation.
stateDiagram-v2
direction LR
[*] --> Active : create_stream
Active --> Paused : pause_stream
Paused --> Active : resume_stream
Active --> Cancelled : cancel_stream
Paused --> Cancelled : cancel_stream
Active --> Completed : withdraw full amount
Cancelled --> [*]
Completed --> [*]
The create_stream function and its variants authenticate the sender uniformly via sender.require_auth(). This pattern seamlessly supports both externally-owned Stellar accounts and smart contract addresses (such as treasury vaults or multisig contracts) without any special-cased code paths.
When a contract creates a stream:
- Authorization: The calling contract naturally authorizes the action via the standard Soroban authentication framework.
- Funding: Tokens are debited from the contract's token balance (the contract must have sufficient funds).
- Management: The contract acts as the stream's sender for all lifecycle operations, meaning only the contract can call
top_up_stream,cancel_stream,decrease_rate_per_second, etc. - Refunds: If a stream is cancelled or shortened, the unstreamed tokens are refunded directly to the sender's contract address.
Caveat: Ensure that sender == recipient validation (if enforced off-chain or via UI) and refund logic correctly account for contract addresses exactly as they would for standard accounts. The streaming protocol treats them identically.
The following diagram shows the full create → withdraw flow, including optional pause/resume and cancel paths.
sequenceDiagram
participant Sender
participant Contract as FluxoraStream
participant Token as USDC Token
participant Recipient
Note over Sender, Recipient: 1. Stream Creation
Sender ->> Contract: create_stream(sender, recipient, deposit_amount, rate_per_second, start_time, cliff_time, end_time)
Contract ->> Contract: require_auth(sender)<br/>validate params
Contract ->> Token: transfer(sender → contract, deposit_amount)
Token -->> Contract: OK
Contract -->> Sender: stream_id
Note right of Contract: Event: ("created", stream_id) → StreamCreated
Note over Sender, Recipient: 2. Cliff Period (no withdrawals)
Recipient ->> Contract: withdraw(stream_id)
Contract -->> Recipient: 0
Note right of Contract: No state change, no transfer, no withdraw/completed events
Note over Sender, Recipient: 3. After Cliff — Partial Withdrawal
Recipient ->> Contract: withdraw(stream_id)
Contract ->> Contract: require_auth(recipient)<br/>calculate_accrued() − withdrawn_amount
Contract ->> Token: transfer(contract → recipient, withdrawable)
Token -->> Contract: OK
Contract -->> Recipient: withdrawable
Note right of Contract: Event: ("withdrew", stream_id) → Withdrawal { stream_id, recipient, amount }
Note over Sender, Recipient: 4. Optional — Pause / Resume
Sender ->> Contract: pause_stream(stream_id)
Contract ->> Contract: require_auth(sender)<br/>status = Paused
Contract -->> Sender: OK
Note right of Contract: Event: ("paused", stream_id)
Recipient ->> Contract: withdraw(stream_id)
Contract --x Recipient: Error: InvalidState (if before end_time)
Note over Sender, Recipient: 4b. Terminal Liquidity (Paused past end_time)
Note right of Contract: Time >= end_time
Recipient ->> Contract: withdraw(stream_id)
Contract ->> Contract: status = Completed
Contract ->> Token: transfer(contract → recipient, total)
Contract -->> Recipient: OK
Note right of Contract: Event: ("completed", stream_id)
Sender ->> Contract: resume_stream(stream_id)
Contract ->> Contract: require_auth(sender)<br/>status = Active
Contract -->> Sender: OK
Note right of Contract: Event: ("resumed", stream_id)
Note over Sender, Recipient: 5a. Happy Path — Complete Withdrawal
Recipient ->> Contract: withdraw(stream_id)
Contract ->> Contract: require_auth(recipient)<br/>withdrawable = deposit_amount − withdrawn_amount
Contract ->> Token: transfer(contract → recipient, withdrawable)
Token -->> Contract: OK
Contract ->> Contract: status = Completed
Contract -->> Recipient: withdrawable
Note right of Contract: Event: ("withdrew", stream_id) → Withdrawal { stream_id, recipient, amount }
Note right of Contract: Event: ("completed", stream_id)
Note over Sender, Recipient: 5b. Alternative — Cancellation
Sender ->> Contract: cancel_stream(stream_id)
Contract ->> Contract: require_auth(sender)<br/>calculate unstreamed refund
Contract ->> Contract: status = Cancelled
Contract ->> Token: transfer(contract → sender, unstreamed)
Token -->> Contract: OK
Contract -->> Sender: OK
Note right of Contract: Event: ("cancelled", stream_id)
Note over Recipient: Recipient can still withdraw<br/>accrued amount before cancellation
Location: contracts/stream/src/accrual.rs
The mathematical accrual behavior branches on the stream's StreamKind:
if current_time < cliff_time → return 0
if checkpointed_at >= end_time or rate < 0 → return checkpointed_amount or 0
elapsed_now = min(current_time, end_time)
elapsed_seconds = elapsed_now - checkpointed_at // 0 if underflow
added = elapsed_seconds * rate_per_second // on overflow → deposit_amount
return min(checkpointed_amount + added, deposit_amount).max(0)
- Time limits: All time evaluations (like
elapsed_seconds) are computed in whole seconds. - Rate and Amount:
rate_per_secondis expressed in base token units per second (integer), and amounts are in base token units. - Rounding Direction: The contract uses exact integer math. There are no fractional seconds or fractional tokens. Any division resulting in precision loss must occur prior to contract interactions (e.g., frontend converting a monthly rate to integer tokens-per-second, essentially flooring it). Internally, exact multiplication provides an integer step-function corresponding to second boundaries.
if current_time < cliff_time → return 0
else → return deposit_amount
- Before cliff: Returns 0 (no withdrawals allowed)
- After cliff: Accrual computed from
start_time, not from cliff - No cliff: Set
cliff_time = start_timefor immediate vesting - After end_time: Elapsed time is capped at
end_time(no post-end accrual) - Overflow: Multiplication overflow yields
deposit_amount(safe upper bound) - Active streams: Accrual computed using current ledger timestamp
- Paused streams: Accrual computed using current ledger timestamp (same as Active; pause only blocks withdrawals, not accrual)
- Completed:
calculate_accruedreturnsdeposit_amount(deterministic final value, timestamp-independent) - Cancelled:
calculate_accruedis frozen atcancelled_at(no post-cancel growth)
Ledger-backed accrual paths cache the last observed accrual timestamp in instance storage and compare each later ledger timestamp against it before evaluating withdrawable math. The guard is intentionally global and short-lived: it catches non-monotonic test harness setup, migration mistakes, or future environment changes without adding per-stream storage.
accrual.rs also contains a debug_assert!(current_ts >= prev_ts, "retrograde ledger timestamp"). In test/debug builds, the same condition returns ContractError::ClockRegression instead of allowing a retrograde timestamp to reduce computed accrual. Production Stellar ledgers are still assumed to be monotonically non-decreasing by protocol.
get_claimable_at(stream_id, timestamp) is exempt because the timestamp is caller-supplied simulation input rather than ledger().timestamp().
Guarantee: Stream accrual is defined entirely in terms of env.ledger().timestamp() (wall-clock seconds). The ledger sequence number (block height) has no influence on the amount accrued or the amount withdrawable.
Why this matters: On the Stellar network the ledger sequence number and the UNIX timestamp advance independently. A burst of rapid ledger closes can push the sequence far ahead while the timestamp barely moves (e.g., 10 000 ledger closes in 400 seconds). Conversely, a slow-close period may hold the sequence near-constant while wall-clock time advances normally. Any accidental dependency on env.ledger().sequence() inside the accrual path would make recipient payout amounts sensitive to network block-production rate rather than actual elapsed time — a fund-accuracy issue.
Where sequence numbers are used (intentionally):
| Usage | Location | Purpose |
|---|---|---|
MIN_PAUSE_INTERVAL_LEDGERS |
pause_stream / resume_stream |
DoS cooldown: prevents rapid pause/resume toggling (17 ledgers) |
MIN_WITHDRAW_INTERVAL_LEDGERS |
withdraw / batch_withdraw / delegated_withdraw |
DoS guard: prevents excessive ledger I/O from high-frequency polling (1 ledger) |
last_withdraw_ledger |
Per-stream storage | Tracks last successful withdrawal for the frequency guard above |
last_pause_toggle_ledger |
Per-stream storage | Tracks last pause/resume toggle for the cooldown guard above |
These are all operational rate-limiting mechanisms. None of them affect the mathematical accrual formula in accrual.rs.
Verified by tests (contracts/stream/tests/clock_monotonicity.rs):
sequence_advances_fast_timestamp_static_accrual_is_timestamp_only— advances sequence to 10 000 while holding timestamp at 400 s. Asserts thatcalculate_accruedandwithdrawboth return 400, not 10 000. Any accidental sequence-to-accrual coupling would cause this test to fail.timestamp_advances_sequence_static_normal_accrual_works— holds sequence at 1 (the minimum needed to pass the withdrawal-frequency DoS gate) while advancing timestamp to 700 s. Asserts that accrual equals 700 and withdrawal succeeds, confirming that low-sequence environments do not suppress accrual.
No accidental dependency found: A review of contracts/stream/src/accrual.rs and contracts/stream/src/lib.rs confirmed that every call to calculate_accrued_amount_checkpointed passes env.ledger().timestamp() as the now argument. There is no code path that passes env.ledger().sequence() (or any function of it) into the accrual formula. The sequence-number usages listed above are in separate, clearly labelled guard blocks.
| Status | Time Source | Expected Behavior |
|---|---|---|
| Active | env.ledger().timestamp | Accrual grows with wall-clock time |
| Paused | env.ledger().timestamp | Same as Active (accrual continues) |
| Completed | N/A (ignored) | Returns deposit_amount (deterministic) |
| Cancelled | cancelled_at | Frozen at cancellation time |
withdrawable = accrued - withdrawn_amount
From CONTRACT_VERSION 5, senders can optionally set a withdraw_dust_threshold per stream to reduce fee and event spam from tiny micro-withdrawals.
- Enforcement: If
withdrawable < withdraw_dust_threshold, the withdrawal returns0(no transfer, no event). - Exceptions (Threshold Ignored):
- Terminal State: Once the stream reaches
end_timeor isCancelled, the threshold is ignored to ensure the recipient can pull all remaining funds. - Final Drain: If the withdrawal would result in
withdrawn_amount == deposit_amount(completing the stream), it is allowed even if the amount is below the threshold.
- Terminal State: Once the stream reaches
- Default: The threshold defaults to
0if not specified at creation.
See also: dust-threshold.md — formula for choosing a safe threshold value, worked USDC examples, a validation table, and guidance for template authors.
From CONTRACT_VERSION 6, all withdrawal operations enforce a minimum ledger interval between consecutive withdrawals on the same stream to prevent excessive ledger entry generation and I/O costs from high-frequency polling.
- Constant:
MIN_WITHDRAW_INTERVAL_LEDGERS = 17(approximately 1 minute at ~5 seconds per ledger close, subject to network conditions) - Enforcement:
withdraw,delegated_withdraw, andbatch_withdrawall enforcecurrent_ledger - last_withdraw_ledger >= MIN_WITHDRAW_INTERVAL_LEDGERS - Error: Returns
ContractError::WithdrawalTooFrequent(error code 17) if the interval check fails - Atomicity: For
batch_withdraw, if any stream in the batch violates the rate limit, the entire batch reverts - Per-Stream: Each stream tracks its own
last_withdraw_ledgerindependently - First Withdrawal: Always succeeds (
last_withdraw_ledgeris initialized to 0 at stream creation) - State Update:
last_withdraw_ledgeris updated toenv.ledger().sequence()only after a successful withdrawal (withdrawable > 0) - Zero Withdrawable: If a withdrawal returns 0 (before cliff, dust threshold, etc.),
last_withdraw_ledgeris not updated
Invariant: current_ledger >= last_withdraw_ledger at all times (guaranteed by monotonic ledger progression).
Example: If a withdrawal succeeds at ledger 100, the next withdrawal can occur at ledger 117 or later (100 + 17 = 117).
From CONTRACT_VERSION 7 (or with issue #1018), both update_rate_per_second and decrease_rate_per_second enforce a minimum ledger interval to prevent spam and rapid rate oscillation within a single ledger window.
- Constant:
MIN_RATE_INTERVAL_LEDGERS = 17(approximately 1.5 minutes) - Enforcement: Checks
current_ledger - last_rate_change_ledger >= MIN_RATE_INTERVAL_LEDGERS. - Error: Returns
ContractError::RateCooldownActive(error code 36) if the throttle is violated. - First Change Exempt: The throttle does not block the very first rate change on a freshly created stream (
last_rate_change_ledgeris initialized to 0 at stream creation). - State Update:
last_rate_change_ledgeris updated toenv.ledger().sequence()only after a successful rate adjustment.
get_claimable_at(stream_id, timestamp) is a read-only view that returns the amount that would be claimable (withdrawable) at an arbitrary timestamp. Use it for:
- Planning: "How much will be claimable at time T?" without sending a transaction.
- Simulation: Pass a future timestamp to show projected claimable amount.
- Consistency: For the current ledger time, result matches
get_withdrawable(stream_id).
Behaviour: Active/Paused streams use the given timestamp (clamped to schedule); Cancelled streams use min(timestamp, cancelled_at) so accrual is frozen at cancellation. Completed streams return 0.
get_stream_health(stream_id) returns a structured health summary for a stream.
- is_underfunded:
trueif the currentdeposit_amountis insufficient to cover the total tokens that will accrue byend_timeat the currentrate_per_second. - is_expired:
trueifledger.timestamp() >= end_timeand the stream is not yetCompletedorCancelled. - accrued_to_date: Real-time total tokens accrued since
start_time. - remaining_deposit:
deposit_amount - withdrawn_amount. For cancelled streams, this reflects the unwithdrawn portion of the original deposit, even though the unstreamed portion has been refunded. - seconds_until_depletion: Estimated seconds until the stream's deposit is fully exhausted by accrual. Capped at
end_time. For cancelled streams, this continues to reflect the hypothetical depletion time based on the original rate.
Use this to show real-time health indicators in UIs, alert senders of underfunding, or notify recipients of expired streams ready for final withdrawal.
- Must be in
[start_time, end_time](enforced at creation) - Before
cliff_time: accrued = 0, no withdrawals - At or after
cliff_time: accrual uses elapsed time fromstart_time, not cliff
- Must satisfy
start_time < end_time - Accrual uses
min(current_time, end_time)as the upper bound - After
end_time, accrued stays atmin((end_time - start_time) * rate_per_second, deposit_amount) - No extra accrual beyond
end_time
At creation:
deposit_amount >= rate_per_second * (end_time - start_time)
The same sufficiency check is enforced when extending a stream's end_time:
deposit_amount >= rate_per_second * (new_end_time - start_time)
If the existing deposit does not cover the extended duration, extend_stream_end_time returns ContractError::InsufficientDeposit and no state changes occur. Use top_up_stream first to increase the deposit, then extend.
To preserve the absolute one-shot unlock nature of a [CliffOnly](#cliff-only-streams) stream variant and guarantee its immutability post-creation, all mutating endpoints are strictly blocked. Attempting to call any of the following functions on a [CliffOnly](#cliff-only-streams) stream will return [ContractError::UnsupportedStreamKind](./error.md#unsupportedstreamkind-17) and revert all state changes:
top_up_streamupdate_rate_per_seconddecrease_rate_per_secondshorten_stream_end_timeextend_stream_end_time
Any such attempt is atomic: no balances are transferred, no state is updated, and no events are emitted.
shorten_stream_end_time(stream_id, new_end_time) is sender-only and only valid for Active/Paused streams.
Validation boundaries:
new_end_time > nownew_end_time > start_timenew_end_time >= cliff_timenew_end_time < old_end_time
On success:
new_deposit_amount = rate_per_second * (new_end_time - start_time)refund_amount = old_deposit_amount - new_deposit_amount- Contract persists
end_timeanddeposit_amount, then transfersrefund_amountto sender, then emitsend_shrt.
On failure (InvalidParams or InvalidState):
- No state change
- No token transfer
- No
end_shrtevent
start_timemust be >= current ledger timestamp at creation time.start_time == nowis valid ("start now").start_time < nowis rejected withContractError::StartTimeInPast.- Failure is atomic: no stream is persisted, no tokens move, and no
createdevent is emitted.
Limits Policy (Defense in Depth):
- No arbitrary hard-coded caps (e.g. "max 1M tokens").
- The technical upper bound is
i128::MAXor the underlying token's total supply. - Rationale: Accrual math (in
accrual.rs) is already overflow-safe viachecked_muland clamping. - Application-specific limits should be handled in the frontend or factory contracts.
The contract provides two entrypoints for creating multiple streams in a single transaction. Both accept a vector of CreateStreamParams and require a single authorization from the sender. For both functions, providing an empty vector safely returns an empty result (Ok(Vec::new())) with no side effects and no token transfers.
pub fn create_streams(
env: Env,
sender: Address,
streams: Vec<CreateStreamParams>,
) -> Result<Vec<u64>, ContractError>Semantics: All-or-nothing.
- The contract first validates all entries.
- If any single entry fails validation (e.g.,
StartTimeInPast,InvalidParams), the entire transaction reverts. - A single bulk token transfer is made for the sum of all
deposit_amounts. If the sender lacks sufficient balance for the aggregate total, the transaction reverts. - Returns a
Vec<u64>containing the new stream IDs in the exact order of the input.
pub fn create_streams_partial(
env: Env,
sender: Address,
streams: Vec<CreateStreamParams>,
) -> Result<Vec<CreateStreamResult>, ContractError>
pub struct CreateStreamResult {
pub success: bool,
pub stream_id: Option<u64>,
pub error: Option<u32>,
}Semantics: Failure isolation per entry.
- The contract attempts to create each stream independently.
- Token Transfer Handling: Tokens are pulled from the sender per entry. If an entry fails validation, it is skipped entirely (no tokens are pulled). If the per-entry token transfer fails, it is recorded as
InsufficientBalance(error code 9). - Subsequent entries continue processing normally regardless of prior failures.
- Return Value: Callers receive a
Vec<CreateStreamResult>matching the input order. To learn which elements succeeded, callers iterate the result vector and checkresult.success. Successful entries includeSome(stream_id), while failed entries includeSome(error_code).
Example:
let results = contract.create_streams_partial(&sender, ¶ms)?;
for (i, res) in results.iter().enumerate() {
if res.success {
println!("Stream {} created with ID {}", i, res.stream_id.unwrap());
} else {
println!("Stream {} failed with error code {}", i, res.error.unwrap());
}
}The contract provides convenience entry points that compute stream times relative to the current ledger timestamp, eliminating off-chain calculation errors that lead to StartTimeInPast failures.
Off-chain applications often construct stream parameters ahead of time, e.g., "start 1 day from now". If there is clock drift between the application server and the Soroban ledger, the calculated start_time may already be in the past when the transaction is executed, causing StartTimeInPast rejection.
Relative-time helpers avoid this by deferring timestamp computation to the contract itself, which always has the authoritative ledger timestamp.
Signature:
pub fn create_stream_relative(
env: Env,
sender: Address,
recipient: Address,
deposit_amount: i128,
rate_per_second: i128,
start_delay: u64, // Seconds to add to current timestamp
cliff_delay: u64, // Seconds to add to current timestamp
duration: u64, // Total seconds from start_time to end_time
) -> Result<u64, ContractError>Computation:
current_time = env.ledger().timestamp()
start_time = current_time + start_delay
cliff_time = current_time + cliff_delay
end_time = start_time + duration
Validation:
- Checks for overflow/underflow in all additions
- Delegates to
create_streamwith computed absolute times - Inherits all validation rules: deposit sufficiency, cliff bounds, etc.
- Never produces
StartTimeInPasterror (computed times are always >= current_time)
Example:
// Create a stream starting in 1 day, cliff in 3 days, running for 30 days
contract.create_stream_relative(
&sender,
&recipient,
&100_000_000, // 100M tokens
&1_157_407, // ~1% per day
&86400, // start_delay: 1 day
&259200, // cliff_delay: 3 days
&2_592_000, // duration: 30 days
)?;
Signature:
pub fn create_streams_relative(
env: Env,
sender: Address,
streams_relative: Vec<CreateStreamRelativeParams>,
) -> Result<Vec<u64>, ContractError>Parameters (per entry):
pub struct CreateStreamRelativeParams {
pub recipient: Address,
pub deposit_amount: i128,
pub rate_per_second: i128,
pub start_delay: u64,
pub cliff_delay: u64,
pub duration: u64,
}Batch semantics:
- Empty batch returns
Ok(Vec::new())with no side effects - All entries are converted to absolute times (overflow checks per entry)
- Delegates to
create_streamswith converted parameters - Atomic: all or nothing (any validation failure aborts entire batch)
- Single authorization and token transfer for all streams (gas efficient)
Example:
let params = vec![
CreateStreamRelativeParams {
recipient: alice,
deposit_amount: 1000,
rate_per_second: 1,
start_delay: 0, // Immediate
cliff_delay: 0, // Immediate
duration: 86400, // 1 day
},
CreateStreamRelativeParams {
recipient: bob,
deposit_amount: 2000,
rate_per_second: 2,
start_delay: 86400, // 1 day delay
cliff_delay: 172800, // 2 day cliff
duration: 2592000, // 30 days
},
];
contract.create_streams_relative(&sender, ¶ms)?;
Error handling:
InvalidParams: If any time offset causes u64 overflow, or if other validation fails (rate, deposit, cliff bounds, etc.)ContractPaused: If creation is globally paused- All other errors: Same as
create_stream/create_streams
| Function | Authorized Caller | Auth Check |
|---|---|---|
init |
Bootstrap admin signer (once) | admin.require_auth() |
create_stream |
Sender | sender.require_auth() |
clone_stream |
Source stream's sender | source.sender.require_auth() |
create_streams |
Sender | sender.require_auth() (once per batch) |
create_stream_relative |
Sender | sender.require_auth() |
create_streams_relative |
Sender | sender.require_auth() (once per batch) |
pause_stream |
Sender | sender.require_auth() |
resume_stream |
Sender | sender.require_auth() |
cancel_stream |
Sender | sender.require_auth() |
withdraw |
Recipient | recipient.require_auth() |
withdraw_to |
Recipient | recipient.require_auth() |
batch_withdraw |
Recipient | recipient.require_auth() (once per batch) |
calculate_accrued |
Anyone | None (view) |
get_withdrawable |
Anyone | None (view) |
get_claimable_at |
Anyone | None (view) |
get_config |
Anyone | None (view) |
get_stream_state |
Anyone | None (view) |
get_stream_health |
Anyone | None (view) |
get_streams_by_id_range |
Anyone | None (view, paginated) |
get_recipient_streams_paginated |
Anyone | None (view, paginated) |
pause_stream_as_admin |
Admin | admin.require_auth() |
resume_stream_as_admin |
Admin | admin.require_auth() |
bulk_resume_streams_as_admin |
Admin | admin.require_auth() (once per batch; atomic all-or-nothing) |
cancel_stream_as_admin |
Admin | admin.require_auth() |
close_completed_stream |
Anyone | None (permissionless terminal cleanup) |
top_up_stream |
Funder address | funder.require_auth() |
set_auto_renew |
Original stream sender | sender.require_auth() |
renew_stream |
Anyone | None (permissionless; funds fixed to original sender) |
get_auto_renew |
Anyone | None (view) |
update_rate_per_second |
Sender | sender.require_auth() |
update_recipient |
Recipient | recipient.require_auth() |
decrease_rate_per_second |
Sender | sender.require_auth() |
shorten_stream_end_time |
Sender | sender.require_auth() |
extend_stream_end_time |
Sender | sender.require_auth() |
transfer_sender |
Current stream sender | sender.require_auth() |
set_auto_claim |
Recipient | recipient.require_auth() |
revoke_auto_claim |
Recipient | recipient.require_auth() |
trigger_auto_claim |
Anyone | None (permissionless; destination fixed by recipient) |
get_auto_claim_destination |
Anyone | None (view) |
delegated_withdraw |
Relayer (ed25519 sig from recipient) | relayer.require_auth() + ed25519 sig |
get_delegated_nonce |
Anyone | None (view) |
release_id_reservation |
Reservation holder | holder.require_auth() |
reclaim_expired_id_reservation |
Anyone | None (permissionless cleanup) |
get_total_liabilities |
Anyone | None (view) |
Note: Sender-managed functions (pause_stream, resume_stream, cancel_stream) require sender auth. Admin uses separate _as_admin entry points.
Two bounded view entrypoints support off-chain export and migration without unbounded loops:
Returns streams within an ID range with a strict result limit (capped at MAX_PAGE_SIZE = 100).
Parameters:
start_id: u64— First stream ID (inclusive)end_id: u64— Last stream ID (inclusive). Useu64::MAXfor open-ended.limit: u64— Max results (enforced ≤ 100)
Semantics:
- Returns streams in ascending ID order
- Skips closed/archived streams silently
- Empty range (
start_id > end_id) returns empty vector - Zero limit returns empty vector
DoS Protection:
- Hard limit of 100 streams per call regardless of requested
limit - Gas cost is O(result_count), not O(range_size)
Migration Pattern:
let total = client.get_stream_count();
let mut start = 0u64;
while start < total {
let page = client.get_streams_by_id_range(&start, &(start + 99), &100);
// Export page...
start += page.len() as u64; // Handle closed streams
}Cursor-based pagination for recipient stream export (capped at MAX_PAGE_SIZE = 100).
Parameters:
recipient: Address— Address to querycursor: u64— 0-based starting indexlimit: u64— Max results (enforced ≤ 100)
Semantics:
- Cursor is index into sorted recipient stream list
- Returns stream IDs in ascending order
- Empty result indicates end of data or cursor beyond bounds
Pagination Pattern:
let mut cursor = 0u64;
loop {
let page = client.get_recipient_streams_paginated(&recipient, &cursor, &50);
if page.is_empty() { break; }
// Export page...
cursor += page.len() as u64;
}Comparison with Unbounded Views:
| Function | Use Case | Limit | Risk |
|---|---|---|---|
get_recipient_streams |
Small portfolios (<100) | None | Memory exhaustion |
get_recipient_streams_paginated |
Large portfolios | 100/page | Bounded, safe |
get_streams_by_id_range |
Full contract export | 100/page | Bounded, safe |
top_up_stream(stream_id, funder, amount) is a deposit-only mutation for existing streams.
- Auth boundary: only
fundermust authorize. The contract does not requirefunderto be the stream sender or the contract admin. - Allowed states:
ActiveandPausedonly.CompletedandCancelledreturnContractError::InvalidState. - Amount validation:
amount <= 0returnsContractError::InvalidParams. - State transition on success: only
deposit_amountchanges, and it increases by exactlyamount. - Preserved fields on success:
sender,recipient,start_time,cliff_time,end_time,rate_per_second,withdrawn_amount, andstatus. - Atomic failure semantics: failed auth, failed token pull, or arithmetic overflow revert the whole transaction, leaving balances, stored deposit, and emitted contract events unchanged.
- Event semantics: a successful top-up emits exactly one contract event with topics
("top_up", stream_id)and payloadStreamToppedUp { stream_id, top_up_amount: amount, new_deposit_amount }.
Treasury policy note: if an application wants to restrict who may fund streams, that policy must be enforced off-chain or in a wrapper contract. The base stream contract intentionally accepts any self-authorizing funder.
decrease_rate_per_second(stream_id, new_rate_per_second) allows the stream sender to safely reduce the streaming rate.
A naive decrease would retroactively lower the recipient's accrued tokens. To prevent this, the contract checkpoints the stream: it locks in the mathematical accrual up to the current timestamp under the old rate, and applies the new rate only moving forward.
- Check-Effects-Interactions (CEI): Computes accrual, reduces deposit amount, persists stream state, and finally refunds the difference to the sender.
- Rate Validation:
0 < new_rate_per_second < current rate_per_second. - Refund: The sender receives a refund of
old_deposit - new_deposit, wherenew_deposit = checkpointed_amount + new_rate * remaining_seconds. - Refund-non-negativity invariant: The refund maths uses
checked_sub(old_deposit - new_deposit), notsaturating_sub. Any rate change whosenew_depositwould exceedold_deposit— i.e. the new schedule streamable amount is larger than what has already been deposited — is rejected withContractError::ArithmeticOverflow. The contract never silently grows a stream's deposit ceiling viadecrease_rate_per_second; onlytop_up_streamadds new deposit, andupdate_rate_per_secondre-prices under the existing ceiling. Seecontracts/stream/tests/rate_decrease_after_withdraw.rsfor the regression coverage of this invariant.
- Unauthorized: Caller is not the original sender.
- InvalidState: Stream is already expired (
now >= end_time). - StreamTerminalState: Stream is Cancelled or Completed.
- InvalidParams:
new_rate_per_second <= 0ornew_rate_per_second >= old_rate. - ArithmeticOverflow:
old_deposit < new_depositper the refund-non-negativity invariant above. The contract refuses to grow the stream's deposit ceiling via a rate decrease.
update_rate_per_second(stream_id, new_rate_per_second) allows the stream sender to increase the streaming rate for an existing stream.
- Authorization: Only the stream
sendercan authorize the call. - State Requirements: Stream must be in
ActiveorPausedstatus (notCompletedorCancelled). - Rate Validation:
new_rate_per_second > 0andnew_rate_per_second > current rate_per_second(forward-only increases). - Deposit Coverage:
deposit_amount >= new_rate_per_second * (end_time - start_time)must hold. - Accrual Impact: The accrual calculation uses the new rate retroactively for the entire elapsed time since
start_time, ensuring accrued amounts are monotonically non-decreasing. - Partial Withdrawal Interaction:
withdrawn_amountremains unchanged. Withdrawable amount becomesaccrued (with new rate) - withdrawn_amount. - Event: Emits
("rate_upd", stream_id)withRateUpdatedpayload including old/new rates andeffective_time. - No State Changes:
status,deposit_amount,withdrawn_amount, schedule fields (start_time,cliff_time,end_time) are preserved.
- StreamNotFound: Invalid
stream_id. - Unauthorized: Caller is not the stream sender.
- InvalidState: Stream is
CompletedorCancelled. - InvalidParams:
new_rate_per_second <= 0ornew_rate_per_second <= old_rate. - InsufficientDeposit:
deposit_amount < new_rate_per_second * (end_time - start_time). - Atomicity: Any failure reverts the entire transaction with no state changes or events.
- Accrued amounts never decrease due to rate updates.
- Recipient entitlement is preserved or increased.
- Deposit coverage ensures the stream remains fully fundable at the new rate.
transfer_sender(stream_id, new_sender) allows the current stream sender to rotate the treasury key for an existing stream.
- Authorization: Only the current stream
sendercan authorize the call. - State Requirements: Stream must be in
ActiveorPausedstatus (notCompletedorCancelled). - Parameter Validation:
new_sender != current_senderandnew_sender != recipient. - State Change:
stream.senderis updated tonew_sender. All other fields are unchanged. - Immediate Effect:
new_sendergains all sender-role privileges (pause, resume, cancel, rate updates, schedule changes) immediately.old_senderloses them immediately. - Recipient Entitlement: Unchanged. Accrued amounts,
withdrawn_amount, and schedule are unaffected. - Event: Emits
("sndr_xfr", stream_id)withSenderTransferred { stream_id, old_sender, new_sender }.
- StreamNotFound: Invalid
stream_id. - Unauthorized: Caller is not the current stream sender.
- InvalidState: Stream is
CompletedorCancelled. - InvalidParams:
new_sender == current_senderornew_sender == recipient. - Atomicity: Any failure reverts the entire transaction with no state changes or events.
Treasury key rotation: when a treasury wallet is being rotated, the operator calls transfer_sender to hand over stream management rights to the new key without disrupting the recipient's accrual or requiring stream recreation.
batch_withdraw processes each stream ID in order. A stream with status Completed does not error — it contributes a zero-amount result (BatchWithdrawResult { stream_id, amount: 0 }) and is skipped silently. No token transfer and no event are emitted for that entry. This allows callers to pass a mixed list of active and already-completed streams without pre-filtering.
A Paused stream does return ContractError::InvalidState and reverts the entire batch.
init(token, admin) has explicit externally observable bootstrap semantics:
- One-shot: first successful call writes
Config { token, admin }andNextStreamId = 0. - Auth boundary: the supplied
adminaddress must authorize the call. - Re-init failure: any second call returns
ContractError::AlreadyInitialised. - Failure atomicity: failed auth or re-init leaves bootstrap storage unchanged.
- Immutability boundary:
tokenis immutable after init;admincan rotate only viaset_adminwith current-admin auth.
Residual assumption: deployment flow must ensure the intended bootstrap admin signs the first init transaction.
create_streams(sender, streams) is the batch creation entrypoint for treasury operators and indexers.
- Single auth: only
sendermust authorize, and it is checked once for the entire batch. - Batch validation: every entry is validated before token transfer or persistence.
- Atomic transfer: the contract pulls exactly
sum(deposit_amount)once. - Atomic persistence: if any entry fails validation (or total-deposit sum overflows), no stream is created.
- Event behavior: on success, one
createdevent is emitted per created stream; on failure, nocreatedevents are emitted. - Ordering guarantee: returned stream IDs are contiguous and in the same order as input entries.
When streams is an empty vector:
Success Behavior (Observable):
- Returns
Ok(Vec::new())(empty result vector) - No tokens are transferred (total_deposit = 0, no
pull_tokencall) - No streams are persisted (stream count unchanged)
- No
StreamCreatedevents are emitted - Stream ID counter is not advanced
- Contract state remains unchanged
- Authorization is still required:
sender.require_auth()is called and must succeed - No errors are raised (empty batch is valid and succeeds)
Failure Behavior (Observable):
- If
senderis not authorized: authorization failure before any state changes - If contract is globally paused:
ContractError::ContractPausedreturned, no state changes - Any failure is atomic: no state mutation, no token transfer, no events
Invariants After Empty Batch:
- Returned vector has length 0
- Stream count unchanged
- Token balances unchanged
- No new events in event log
- Recipient stream indices unchanged
- Multiple empty batches have identical observable effects (idempotent)
Rationale:
- Empty batch is a valid no-op: allows callers to submit conditional batches without special-casing
- Authorization is still required: maintains consistent auth semantics across all entry points
- No state advance: ensures stream IDs remain contiguous and predictable
- Idempotent: enables safe retry logic in integrators
These guarantees are limited to create_streams creation semantics. They do not change withdrawal, pause/resume, cancellation, or cleanup rules.
batch_withdraw(recipient, stream_ids) enforces recipient-only authorization and deterministic completion semantics:
batch_withdraw_to(recipient, withdrawals) extends the same recipient-only authorization model to per-stream destinations. The recipient must authorize the batch, every stream must belong to that recipient, and the batch reverts atomically if any entry is unauthorized, invalid, or otherwise rejected. Destination addresses may be any non-contract address; routing to the contract address is rejected with ContractError::InvalidParams.
- Auth boundary: only the stream
recipientcan authorizebatch_withdraw. - Non-recipient calls fail before transfer/state/event side effects.
- Uniqueness check:
stream_idsmust not contain duplicates; duplicates returnContractError::DuplicateStreamIdand revert the entire batch. - Completed streams: contribute a zero-amount result and are skipped silently (no error, no event).
- Active/Paused streams: processed normally;
Pausedstreams returnContractError::InvalidStateand revert the entire batch. - Event ordering on active final drain:
withdrewis emitted beforecompleted.
When stream_ids is an empty vector:
Success Behavior (Observable):
- Returns
Ok(Vec::new())(empty result vector) - No streams are processed
- No tokens are transferred
- No events are emitted
- Contract state remains unchanged
- Authorization is still required:
recipient.require_auth()is called and must succeed - No errors are raised (empty batch is valid and succeeds)
Failure Behavior (Observable):
- If
recipientis not authorized: authorization failure before any state changes - If contract is globally paused:
ContractError::ContractPausedreturned, no state changes - Any failure is atomic: no state mutation, no token transfer, no events
Invariants After Empty Batch:
- Returned vector has length 0
- No stream state changed
- Token balances unchanged
- No new events in event log
- Multiple empty batches have identical observable effects (idempotent)
Rationale:
- Empty batch is a valid no-op: allows callers to submit conditional batches without special-casing
- Authorization is still required: maintains consistent auth semantics across all entry points
- Idempotent: enables safe retry logic in integrators
set_auto_claim, revoke_auto_claim, and trigger_auto_claim implement a recipient-controlled permissionless claim mechanism.
Recipients may opt in to have their final withdrawal triggered by any third party (keeper, bot, or user) once the stream reaches end_time. The destination address is chosen and stored on-chain by the recipient — no caller can redirect funds.
- Auth:
recipient.require_auth()— only the stream recipient may set or change the destination. - Constraints: stream must exist and not be
CompletedorCancelled;destinationmust not be the contract address. - Validation: destination is validated to ensure it's not the zero address and not the contract itself.
- Idempotent: calling again with a new address overwrites the previous destination.
- Event:
("ac_set", stream_id)→AutoClaimSet { stream_id, destination }. - Storage: destination is stored in persistent storage under
DataKey::AutoClaimDestination(stream_id).
Pre-flight check query that returns the auto-claim configuration status and claimable amount. This allows callers to validate before executing trigger_auto_claim, reducing failed transactions and wasted gas on invalid destinations.
- Auth: None required (read-only view function).
- Returns:
AutoClaimStatusenum with three variants:NotSet: No auto-claim destination has been configured for this stream.ValidDestination { destination, claimable }: Destination is set and valid, with the current claimable amount.InvalidDestination { destination }: Destination is set but invalid (zero address or contract itself).
- Claimable calculation: Computed as
accrued_amount - withdrawn_amountat current timestamp, capped at 0. - Validation checks:
- Destination is not the zero address
- Destination is not the contract address itself
- Usage pattern:
let status = client.get_auto_claim_status(&stream_id); match status { AutoClaimStatus::ValidDestination { destination, claimable } => { if claimable > 0 { client.trigger_auto_claim(&stream_id); } } AutoClaimStatus::NotSet => { // No auto-claim configured } AutoClaimStatus::InvalidDestination { destination } => { // Destination is invalid, cannot trigger } }
- Benefits:
- Prevents wasted gas on invalid destinations
- Allows off-chain systems to batch valid claims
- Provides transparency for keepers and bots
- No state changes or side effects
Simple query that returns the stored auto-claim destination address, or None if not set.
- Auth: None required (read-only view function).
- Returns:
Option<Address>— the destination address if set, otherwiseNone. - Note: Does not validate the destination. Use
get_auto_claim_statusfor validation.
- Auth:
recipient.require_auth(). - Idempotent: safe to call even if no destination is set (no error, no event side-effects beyond the revoke event).
- Event:
("ac_revoke", stream_id)→AutoClaimRevoked { stream_id }.
- Auth: none — permissionless. Any account may call this.
- Preconditions (all must hold):
- Stream exists.
- Stream is not
CompletedorCancelled. ledger.timestamp() >= stream.end_time(time-terminal).- Auto-claim destination is set (
AutoClaimNotSetotherwise). - Contract is not globally paused.
- Accounting: identical to
withdraw_to— computesaccrued - withdrawn_amount, caps by contract balance, updateswithdrawn_amount, may transition toCompleted. - Destination immutability: tokens are sent to the address stored by the recipient. The caller cannot influence the destination.
- Events:
("withdrew", stream_id)→Withdrawal { stream_id, recipient, amount }(indexer compatibility).("ac_trig", stream_id)→AutoClaimTriggered { stream_id, destination, amount }.("completed", stream_id)→StreamEvent::StreamCompleted(stream_id)if stream transitions toCompleted.
If a stream is cancelled after opt-in, trigger_auto_claim returns InvalidState. The auto-claim destination entry remains in storage but is inert. Recipients may call revoke_auto_claim to clean up storage.
- Post-Revoke Trigger Prevention: Once a recipient calls
revoke_auto_claim, the auto-claim configuration is immediately deleted. Any subsequenttrigger_auto_claimcalls fail withContractError::InvalidParamswithout transferring any tokens. - Early Trigger Restriction: Permissionless triggering is strictly disallowed before the stream's
end_timeis reached. Early calls returnContractError::InvalidStateand transfer zero funds. - Destination Update Immendiate Effect: If the destination is updated, the change is immediately effective. Triggering auto-claim afterwards sends funds ONLY to the recipient's currently selected destination.
- Only the recipient can set or change the destination (
require_authenforced). - The caller of
trigger_auto_claimhas zero influence over where tokens go. - CEI ordering is preserved: stream state is saved before the token transfer.
- Global emergency pause blocks
trigger_auto_claim(same aswithdraw).
delegated_withdraw allows a relayer (keeper, bot, or any third party) to submit a withdrawal on behalf of a recipient without requiring the recipient to sign a Soroban transaction themselves. The recipient instead signs an off-chain ed25519 message committing to the exact parameters of the withdrawal.
message = stream_id (u64, 8 bytes, big-endian)
| nonce (u64, 8 bytes, big-endian)
| deadline (u64, 8 bytes, big-endian)
| expected_minimum_amount (i128, 16 bytes, big-endian)
Total: 40 bytes.
Without this field, a relayer could delay the transaction until the accrued amount is much smaller than the recipient expected (e.g. after a rate decrease or near stream end), constituting a griefing vector. By committing to a minimum, the call reverts with BelowMinimumAmount (16) if withdrawable < expected_minimum_amount. Pass 0 to accept any positive amount.
Each recipient has a per-address nonce stored in DataKey::DelegatedWithdrawNonce(recipient). The nonce starts at 0 and is incremented on every successful delegated_withdraw. Replaying a used signature returns InvalidSignature (15). Query the current nonce via get_delegated_nonce(recipient).
| Condition | Error |
|---|---|
ledger.timestamp() > deadline |
InvalidSignature (15) |
nonce != stored_nonce |
InvalidSignature (15) |
| ed25519 signature invalid | host trap (panic) |
withdrawable < expected_minimum_amount |
BelowMinimumAmount (16) |
| Stream paused (non-terminal) | InvalidState (2) |
| Stream completed | InvalidState (2) |
Emitted when a new stream is created via create_stream or create_streams.
Topic: ("created", stream_id)
Payload: StreamCreated struct containing:
stream_id(u64): Unique identifier for the streamsender(Address): Address that created and funded the streamrecipient(Address): Address that receives the streamed tokensdeposit_amount(i128): Total tokens depositedrate_per_second(i128): Streaming rate in tokens per secondstart_time(u64): When streaming begins (ledger timestamp)cliff_time(u64): When tokens first become available (vesting cliff)end_time(u64): When streaming completes (ledger timestamp)
Emitted when a recipient successfully withdraws tokens via withdraw.
Topic: ("withdrew", stream_id)
Payload: Withdrawal struct containing:
stream_id(u64): Unique identifier for the streamrecipient(Address): Address that received the tokensamount(i128): Amount of tokens withdrawn
Emitted when a sender successfully updates the streaming rate via update_rate_per_second.
Topic: ("rate_upd", stream_id)
Payload: RateUpdated struct containing:
stream_id(u64): Unique identifier of the streamold_rate_per_second(i128): The previous streaming ratenew_rate_per_second(i128): The new streaming rateeffective_time(u64): Ledger timestamp when the rate update became effective
| Topic | Payload | When Emitted |
|---|---|---|
("created", stream_id) |
StreamCreated (struct payload) |
create_stream / create_streams |
("cloned", stream_id) |
StreamCloned (struct payload) |
clone_stream — carries source_stream_id for indexer correlation |
("paused", stream_id) |
StreamEvent::Paused(stream_id) |
pause_stream / pause_stream_as_admin |
("resumed", stream_id) |
StreamEvent::Resumed(stream_id) |
resume_stream / resume_stream_as_admin / bulk_resume_streams_as_admin |
("cancelled", stream_id) |
StreamEvent::StreamCancelled(stream_id) |
cancel_stream / cancel_stream_as_admin |
("withdrew", stream_id) |
Withdrawal { stream_id, recipient, amount } |
withdraw |
("completed", stream_id) |
StreamEvent::StreamCompleted(stream_id) |
withdraw / batch_withdraw (active final drain) |
("rate_upd", stream_id) |
RateUpdated (struct payload) |
update_rate_per_second |
("closed", stream_id) |
StreamEvent::StreamClosed(stream_id) |
close_completed_stream |
("top_up", stream_id) |
StreamToppedUp (struct payload) |
top_up_stream |
("renewed", old_stream_id, new_stream_id) |
StreamRenewed { old_stream_id, new_stream_id } |
renew_stream |
withdraw_to(stream_id, destination) lets the recipient redirect accrued tokens to any address except the contract itself.
| Destination | Allowed | Error on rejection |
|---|---|---|
Contract address (env.current_contract_address()) |
❌ No | ContractError::InvalidParams |
| Recipient address (self-redirect) | ✅ Yes | — |
| Sender address | ✅ Yes | — |
| Any other third-party address | ✅ Yes | — |
Atomicity guarantee: If the destination check fails, the call returns InvalidParams with no side effects — withdrawn_amount is not incremented, no token transfer occurs, and no event is emitted. The stream state is identical to its state before the call.
Auth: recipient.require_auth() is always enforced before the destination check.
Errors are surfaced either as ContractError variants or as panic/assert messages.
Integrators should treat ContractError as stable error codes, and panic strings
as best-effort diagnostics. The table below focuses on creation and lifecycle
errors relevant to stream creation and timing.
| Message | Function | Trigger |
|---|---|---|
"already initialised" |
init |
Re-init attempt |
| authorization failure | init |
caller did not satisfy admin.require_auth() |
"deposit_amount must be positive" |
create_stream / create_streams |
deposit_amount <= 0 |
"rate_per_second must be positive" |
create_stream / create_streams |
rate_per_second <= 0 |
"sender and recipient must be different" |
create_stream / create_streams |
sender == recipient |
"start_time must be before end_time" |
create_stream / create_streams |
start_time >= end_time |
"cliff_time must be within [start_time, end_time]" |
create_stream / create_streams |
cliff out of range |
"deposit_amount must cover total streamable amount (rate * duration)" |
create_stream / create_streams |
underfunded |
"overflow calculating total streamable amount" |
create_stream / create_streams |
overflow in rate * duration |
"overflow calculating total batch deposit" |
create_streams |
overflow in sum of deposits |
ContractError::StartTimeInPast |
create_stream / create_streams |
start_time < ledger timestamp |
ContractError::ClockRegression (17) |
Ledger-backed accrual paths | ledger timestamp < previous accrual timestamp |
ContractError::StreamAlreadyPaused (10) |
pause_stream |
Double pause |
ContractError::StreamNotPaused (11) |
resume_stream |
Resume active stream |
ContractError::StreamTerminalState (12) |
pause_stream / resume_stream |
Modification past end_time |
ContractError::StreamNotFound (1) |
Various | Invalid stream_id |
[ContractError::UnsupportedStreamKind](./error.md#unsupportedstreamkind-17) (17) |
Mutating functions | Attempting to mutate a CliffOnly stream |
ContractError::Unauthorized (6) |
Various | Auth check failed |
ContractError::InvalidState (2) |
withdraw |
Withdraw from non-terminal paused |
ContractError::InvalidState (2) |
cancel_stream |
Cancel completed/cancelled |
"invalid state for stream closure" |
close_completed_stream |
Close non-terminal (Active/Paused) stream |
ContractError::InvalidState (2) |
close_completed_stream |
Close Cancelled stream with remaining claimable balance |
ContractError::AutoRenewFundingUnavailable (36) |
renew_stream |
Original sender balance or allowance is below deposit amount |
ContractError::InvalidState (2) |
renew_stream |
Source is not Completed or auto-renew is disabled |
"contract not initialised: missing config" |
Functions requiring config | Config missing |
The protocol supports two distinct pausing modes managed by the contract admin. These modes allow for graduated intervention depending on the situation (e.g., routine maintenance vs. emergency exploit investigation).
| Mode | Flag | Blocked Operations | Allowed Operations |
|---|---|---|---|
| Creation Only | CreationPaused |
create_stream, create_streams |
withdraw, cancel_stream, top_up_stream, update_rate_per_second, extend_stream_end_time, shorten_stream_end_time |
| Global Emergency | GlobalEmergencyPaused |
ALL mutation operations (Create, Withdraw, Cancel, Update, etc.) | get_stream_state, calculate_accrued, close_completed_stream (read-only and cleanup) |
- Creation Functions: Blocked if either
GlobalEmergencyPausedorCreationPausedis set. - Mutation Functions: Blocked ONLY if
GlobalEmergencyPausedis set. - Read-Only Functions: Never blocked; users can always calculate their accrued balance even during a total emergency pause.
- Admin Functions: Never blocked; admins can always pause/resume the protocol or rotate the admin address.
When an operation is blocked by a protocol-level pause, it returns ContractError::ContractPaused (4). No state changes occur, and no tokens are transferred.
For a full list of contract errors, see error.md.
- Protocol Narrative vs Code Alignment - Complete verification that this documentation matches implementation
- Audit Documentation - Entrypoints and invariants for auditors
- Error Reference - Complete error code catalog
- Security Guidelines - Security considerations and best practices
- Storage Layout - Contract storage architecture
- Deployment Guide - Step-by-step deployment checklist
- Treasury Operators: See §1 (Lifecycle), §4 (Access Control), §5 (Events)
- Recipient Applications: See §2 (Accrual Formula), §4 (Withdrawal), §5 (Events)
- Indexers: See §5 (Events), §6 (Error Behavior)
- Auditors: See protocol-narrative-code-alignment.md for complete verification
This documentation is verified against implementation in protocol-narrative-code-alignment.md:
- ✅ All 20 operations have explicit authorization rules
- ✅ All 6 valid state transitions documented
- ✅ All 6 invalid state transitions documented
- ✅ Accrual formula matches implementation line-by-line
- ✅ All 7 event types verified
- ✅ All 8 error codes mapped
- ✅ Zero contradictions found
Last verified: 2026-03-27
The sweep_excess function allows the contract admin to recover trapped tokens that exceed the sum of all outstanding stream liabilities. This addresses scenarios where tokens become stranded in the contract due to:
- Stream cancellations where refunds fail or sender addresses are lost
- Rate decreases via
decrease_rate_per_secondwhere excess deposits are refunded but the refund fails - Rounding errors that accumulate over many stream operations
- Failed refund transfers during any operation that returns tokens to senders
pub fn sweep_excess(env: Env, recipient: Address) -> Result<i128, ContractError>recipient: Address to receive the excess tokens
- Required: Contract admin must authorize the call via
admin.require_auth() - Unauthorized callers: Returns
ContractError::Unauthorized
excess = contract_token_balance - total_liabilities
Where:
contract_token_balance: Current token balance of the contract (queried from token contract)total_liabilities: Sum of all outstanding stream deposits tracked inDataKey::TotalLiabilities
- Preconditions: Caller must be the authorized contract admin
- Calculation: Computes
excess = balance - liabilities - Early return: If
excess <= 0, returnsOk(0)with no transfer or event - Event: Emits
ExcessSwept { to, amount }with topic("ex_swept", recipient) - Transfer: Transfers
excesstokens from contract torecipient - Return: Returns the amount swept (
excess)
- Unauthorized: If caller is not admin →
ContractError::Unauthorized - Invalid state: If contract not initialized →
ContractError::InvalidState - Transfer failure: If token transfer fails → propagates token contract error
- Reentrancy: If reentrancy lock is held →
ContractError::InvalidState
- Recipient protection: Never sweeps tokens that are owed to stream recipients
- Liability tracking: Uses
TotalLiabilitiescounter to ensure all active stream deposits are protected - CEI pattern: Emits event before token transfer to reduce reentrancy risk
- Reentrancy guard: Acquires lock before transfer, releases after
- Idempotent: Safe to call multiple times; returns 0 when no excess exists
- After successful sweep:
contract_balance == total_liabilities - Active stream deposits are never affected
- Recipient entitlements remain unchanged
- No state mutation if
excess <= 0
- Permissionless query: Anyone can calculate potential excess by comparing
token.balance(contract)with the sum of all active streamdeposit_amount - withdrawn_amountvalues - Operational hygiene: Should be called periodically by operators to maintain clean accounting
- No impact on streams: Does not affect any stream state, accrual, or withdrawal operations
- Multiple calls: Can be called multiple times as excess accumulates
1. Stream created: 1000 tokens deposited
2. Stream cancelled at 50% completion
3. 500 tokens should be refunded to sender
4. Sender address is compromised/lost
5. 500 tokens remain trapped in contract
6. Admin calls sweep_excess to recover the 500 tokens
1. Stream created: 1000 tokens, 10 tokens/sec, 100 seconds
2. Rate decreased at t=50 to 5 tokens/sec
3. New total needed: 500 (accrued) + 250 (remaining) = 750
4. 250 tokens refunded to sender
5. If refund fails, 250 tokens trapped
6. Admin calls sweep_excess to recover the 250 tokens
1. 100 streams created and cancelled over time
2. Small rounding errors accumulate (1-2 tokens per stream)
3. Total excess: ~150 tokens
4. Admin calls sweep_excess to recover accumulated excess
| Function | Authorized Caller | Auth Check |
|---|---|---|
sweep_excess |
Admin | admin.require_auth() |
get_total_liabilities |
Anyone | None (view) |
get_total_liabilities is a read-only view that returns the sum of all outstanding stream
deposits tracked in DataKey::TotalLiabilities. It is used to verify that the contract's
token balance always covers what it owes across all active streams, and to compute the
excess amount available to sweep_excess.
#[contracttype]
#[derive(Clone, Debug)]
pub struct ExcessSwept {
pub to: Address,
pub amount: i128,
}Topic: ("ex_swept", recipient)
ContractError::Unauthorized(7): Caller is not the adminContractError::InvalidState(2): Contract not initialized or reentrancy detected- Token transfer errors: Propagated from token contract
- Admin trust: This function requires trusting the admin not to abuse it. The admin could theoretically call it with their own address to extract excess funds.
- Liability tracking accuracy: The safety of this function depends on accurate
TotalLiabilitiestracking. Any bug in liability accounting could allow sweeping of recipient funds. - Audit trail: All sweeps are logged via
ExcessSweptevents for transparency and auditing. - No emergency pause bypass: This function does not bypass global emergency pause (if implemented in future versions).
Comprehensive test coverage includes:
- ✅ Returns 0 when no excess exists
- ✅ Sweeps correct amount after stream cancellation
- ✅ Sweeps correct amount after rate decrease
- ✅ Requires admin authorization
- ✅ Emits
ExcessSweptevent - ✅ Protects recipient funds (never sweeps liabilities)
- ✅ Can be called multiple times
- ✅ Works correctly with multiple streams
- ✅ Handles edge cases (completed streams, paused streams, etc.)
See contracts/stream/tests/integration_suite.rs for full test suite.
After a stream's end_time passes and a configurable grace period elapses, any address may
call keeper_cancel to close the stream and collect a small incentive fee. This prevents
unclaimed deposits from remaining locked in contract storage indefinitely.
| Constant | Value | Notes |
|---|---|---|
KEEPER_GRACE_PERIOD_SECONDS |
604 800 s (7 days) | Seconds after end_time before eligibility |
KEEPER_FEE_BPS |
50 bps (0.5 %) | Fee as a fraction of the unstreamed sender refund |
recipient_amount = accrued − withdrawn_amount→ transferred to recipient.sender_refund_gross = deposit_amount − accrued(unstreamed portion).keeper_fee = sender_refund_gross × KEEPER_FEE_BPS / 10 000→ transferred to keeper.sender_refund = sender_refund_gross − keeper_fee→ transferred to sender.
When sender_refund_gross == 0 (stream fully accrued), the keeper receives no fee.
Authorization: keeper.require_auth() — prevents fee redirection by a third party.
Errors:
| Error | Condition |
|---|---|
StreamNotFound |
stream_id does not exist |
InvalidState |
Stream is already Cancelled or Completed |
KeeperGracePeriodNotElapsed |
now < end_time + KEEPER_GRACE_PERIOD_SECONDS |
Purpose: Preview the (keeper_fee, sender_refund) split that keeper_cancel would pay,
without moving any funds or changing any state. Keepers should call this before paying gas
to confirm the fee is worthwhile.
Entry-point:
pub fn get_keeper_fee_split(env: Env, stream_id: u64) -> Result<(i128, i128), ContractError>Authorization: None (public view).
Returns:
| Condition | Return |
|---|---|
| Grace period not yet elapsed | Ok((0, 0)) — not yet eligible, no error |
| Stream is eligible | Ok((keeper_fee, sender_refund)) matching keeper_cancel payouts |
Stream is Cancelled or Completed |
Err(InvalidState) |
| Stream does not exist | Err(StreamNotFound) |
Invariants:
keeper_fee + sender_refund == deposit_amount − accrued(gross unstreamed) when eligible.- Output is identical to the amounts computed inside
keeper_cancelfor the same ledger timestamp. - No state writes, no TTL changes, no token operations — cannot be abused for griefing.
Example (Rust client):
let (fee, refund) = client.get_keeper_fee_split(&stream_id)?;
if fee > gas_cost_estimate {
client.keeper_cancel(&stream_id, &keeper_address);
}Test coverage: See contracts/stream/tests/keeper_cancel.rs.
- ✅ View/cancel parity (preview matches actual keeper and sender payouts)
- ✅ Not-yet-eligible stream returns
(0, 0) - ✅ Active stream before
end_timereturns(0, 0) - ✅ Fully-accrued stream returns
(0, 0)(no gross, no fee) - ✅
Cancelledstream returnsInvalidState - ✅
Completedstream returnsInvalidState - ✅ Non-existent stream returns
StreamNotFound - ✅ Paused eligible stream returns correct split
- ✅
fee + refund == grossinvariant - ✅ Idempotency (two calls at same timestamp return same result)
Purpose: Pre-allocate a contiguous range of stream IDs before creating streams, enabling off-chain orchestrators to pre-populate database records or reference external invoice systems with deterministic IDs.
Entry-point:
pub fn reserve_stream_ids(
env: Env,
caller: Address,
count: u32,
) -> Result<Vec<u64>, ContractError>Authorization: Requires caller signature.
Parameters:
caller: Address making the reservationcount: Number of IDs to reserve (1 –MAX_ID_RESERVATION= 100)
Returns: Vec<u64> containing the reserved IDs in ascending order.
Behavior:
- Atomically advances the global
NextStreamIdcounter bycount - Stores an
IdReservation { start_id, count, consumed: 0 }keyed bycaller - Returns
[start_id, start_id+1, ..., start_id+count-1] - Subsequent
create_streamcalls fromcallerconsume IDs from the reservation in order - When fully consumed, the reservation is automatically deleted
- A second
reserve_stream_idscall before the first is exhausted replaces the old reservation (unconsumed IDs become permanent gaps; the counter is never rewound)
Security:
countcapped atMAX_ID_RESERVATION = 100to prevent counter-inflation attacks- Authorization required to prevent third parties from consuming a victim's counter space
- Gaps from unconsumed reservations are permanent but bounded (max 100 per call)
Errors:
ReservationCountZero(17):countis 0ReservationLimitExceeded(18):count > MAX_ID_RESERVATION
Example:
// Off-chain orchestrator reserves 5 IDs
let ids = client.reserve_stream_ids(&orchestrator, &5); // [0, 1, 2, 3, 4]
// Pre-populate database with these IDs
database.insert_pending_streams(ids);
// Later: create streams — they'll get the reserved IDs
let id0 = client.create_stream(&orchestrator, ...); // Uses ID 0
let id1 = client.create_stream(&orchestrator, ...); // Uses ID 1Purpose: View the active ID reservation for a caller (if any).
Entry-point:
pub fn get_id_reservation(env: Env, caller: Address) -> Option<IdReservation>Authorization: None (view function).
Returns:
Some(IdReservation { start_id, count, consumed })if caller has an active reservationNoneif no reservation exists
Example:
let res = client.get_id_reservation(&caller);
match res {
Some(r) => println!("Reserved {}-{}, consumed {}", r.start_id, r.start_id + r.count - 1, r.consumed),
None => println!("No active reservation"),
}Test coverage: See contracts/stream/tests/id_reservation.rs for comprehensive tests covering:
- ✅ Basic reservation (single, max, sequential)
- ✅ Error cases (zero count, over-limit)
- ✅
get_id_reservationview (before/after reserve) - ✅
create_streamconsuming reservations - ✅ Counter-gap semantics (overwrites, exhaustion)
- ✅ Multi-caller isolation
Purpose: Replace the deployed contract WASM with a new version. This is the highest-privilege operation in the protocol and should only be used after the new WASM has been audited and verified storage-compatible.
Entry-point:
pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Result<(), ContractError>Authorization: Admin only (admin.require_auth()).
Behavior:
- Calls
env.deployer().update_current_contract_wasm(new_wasm_hash), which is atomic — if the new WASM is invalid, the call reverts and no state changes. - Bumps instance TTL after the upgrade so the contract does not expire.
- Emits
ContractUpgraded(topicupgraded) with the new hash, version, and caller, plus a legacyupgradetopic event for backward-compatible indexers. Seedocs/events.mdfor the exact event shapes.
Every existing creation entry point (create_stream, create_streams, etc.)
requires only the sender's authorization — a stream can be force-created
onto any recipient address without their consent. The offer-then-accept flow
adds a two-phase alternative where the recipient must explicitly accept
before accrual begins.
- Recipients may not want unexpected streams added to their index (spam, tax implications, compliance).
- Senders can propose terms off-chain first, then commit the deposit on-chain.
- Offers that are never accepted are automatically refundable by the sender.
Sender calls create_stream_offer
↓ deposit escrowed, PendingStreamOffer stored, RecipientStreams NOT updated
│
├─► Recipient calls accept_stream_offer
│ → offer removed, Active Stream created, RecipientStreams updated
│ start_time re-anchored to max(offer.start_time, now)
│
├─► Recipient calls reject_stream_offer
│ → offer removed, deposit refunded to sender
│
├─► Sender calls cancel_stream_offer (any time, including after expiry)
│ → offer removed, deposit refunded to sender
│
└─► expiry_time elapsed
→ accept_stream_offer returns OfferExpired (36)
sender can still cancel; recipient can still reject
pub fn create_stream_offer(
env: Env,
sender: Address,
recipient: Address,
deposit_amount: i128,
rate_per_second: i128,
start_time: u64,
cliff_time: u64,
end_time: u64,
withdraw_dust_threshold: i128,
memo: Option<Bytes>,
kind: StreamKind,
metadata: Option<Map<Bytes, Bytes>>,
expiry_time: Option<u64>,
) -> Result<u64, ContractError>Authorization: sender.require_auth()
Behavior:
- Validates all parameters using the same rules as
create_stream. - If
expiry_timeisSome(t)andt <= now, returnsInvalidParams. - Allocates an
offer_idfrom the global stream ID counter. - Stores a
StreamOfferinDataKey::PendingStreamOffer(offer_id). - Adds
offer_idtoDataKey::RecipientPendingOffers(recipient). - Pulls
deposit_amounttokens fromsenderinto escrow (CEI: state saved first). - Emits
StreamOfferCreated(topicoffr_crt). - Returns the
offer_id.
Does NOT: start accrual, add to RecipientStreams, track liabilities.
pub fn accept_stream_offer(
env: Env,
recipient: Address,
offer_id: u64,
) -> Result<u64, ContractError>Authorization: recipient.require_auth()
Behavior:
- Returns
OfferNotFoundif no pending offer exists. - Returns
OfferWrongRecipientifrecipient != offer.recipient. - Returns
OfferExpiredifnow > offer.expiry_time. - Re-anchors timing:
effective_start = max(offer.start_time, now). Cliff offset and stream duration are preserved relative toeffective_start. - Removes the offer from storage and the recipient pending-offers index.
- Creates an
Activestream using the sameoffer_idas stream ID. - Adds stream to
RecipientStreamsindex and tracks liability. - Emits
StreamCreated(topiccreated) andStreamOfferAccepted(topicoffr_acc). - No token transfer — deposit was already escrowed at offer creation.
pub fn reject_stream_offer(
env: Env,
recipient: Address,
offer_id: u64,
) -> Result<(), ContractError>Authorization: recipient.require_auth()
Removes the offer and pushes the escrowed deposit back to offer.sender.
Emits StreamOfferCancelled (topic offr_cxl).
pub fn cancel_stream_offer(
env: Env,
sender: Address,
offer_id: u64,
) -> Result<(), ContractError>Authorization: sender.require_auth()
Returns OfferWrongSender if sender != offer.sender. Otherwise removes the
offer and refunds the deposit. Can be called even after expiry_time has elapsed.
Emits StreamOfferCancelled (topic offr_cxl).
pub fn get_stream_offer(env: Env, offer_id: u64) -> Result<StreamOffer, ContractError>Returns the full StreamOffer struct. Returns OfferNotFound once the offer
has been accepted, rejected, or cancelled.
pub fn get_recipient_pending_offers(env: Env, recipient: Address) -> Vec<u64>Returns the sorted list of pending offer IDs for recipient. Empty if none.
When a recipient accepts an offer, the contract re-anchors timing to prevent a stream from starting in the past:
effective_start = max(offer.start_time, ledger.timestamp())
cliff_offset = offer.cliff_time - offer.start_time
effective_cliff = effective_start + cliff_offset
duration = offer.end_time - offer.start_time
effective_end = effective_start + duration
The cliff offset and duration are always preserved regardless of how much time has elapsed. This means a 12-month stream with a 6-month cliff will still run for exactly 12 months with a 6-month cliff from the acceptance timestamp.
| Code | Name | Meaning |
|---|---|---|
| 36 | OfferNotFound |
No pending offer with this ID |
| 37 | OfferExpired |
now > offer.expiry_time |
| 38 | OfferWrongRecipient |
Caller is not the intended recipient |
| 39 | OfferWrongSender |
Caller is not the original sender |
| Topic | Payload struct | Emitted by |
|---|---|---|
offr_crt |
StreamOfferCreated |
create_stream_offer |
offr_acc |
StreamOfferAccepted |
accept_stream_offer |
offr_cxl |
StreamOfferCancelled |
reject_stream_offer, cancel_stream_offer |
- CEI ordering is strictly maintained: all state changes occur before token transfers in every entry point.
- The offer is removed from storage before the stream is created in
accept_stream_offer, preventing double-acceptance if a malicious token re-enters the contract. - Offer IDs share the global stream ID counter, guaranteeing globally unique identifiers with no collision risk between offers and active streams.
- Unaccepted offers do not appear in
RecipientStreamsand do not contribute toTotalLiabilities, so they cannot inflate recipient-facing views or the contract's liability accounting.
Fluxora supports multi-recipient pooled streams where multiple beneficiaries receive pro-rata shares of a single deposited amount.
Creates a pooled stream. The recipients list takes pairs of (Address, u32) defining the recipient and their share weight. The maximum number of recipients is MAX_POOL_RECIPIENTS (100). The stream operates similarly to a single-recipient stream, but its is_pooled flag is set to true.
Withdrawals from a pooled stream are independent. When a recipient calls withdraw_from_pool(stream_id, caller), the contract calculates the total accrued tokens and multiplies by the caller's proportional share (caller_share / total_shares). The contract independently tracks withdrawn amounts for each pool member using DataKey::PooledStreamWithdrawn.
Rounding: The calculation uses strict integer math (checked_mul followed by checked_div), rounding down on remainders to avoid over-withdrawing the pool's deposit.