Contract storage architecture, key design, TTL policies, and DataKey evolution rules for the Fluxora stream contract.
Source of truth: contracts/stream/src/lib.rs (DataKey enum, TTL constants, storage helpers)
Canonical discriminant reference: For the frozen discriminant table (variants 0–14) and the full ABI stability contract, see ABI_STABILITY.md § 2.4. The table below tracks all variants including post-freeze additions; always cross-check against both this file and
ABI_STABILITY.mdwhen adding new variants.
All storage keys are defined in the DataKey enum:
#[contracttype]
pub enum DataKey {
Config, // Instance storage for global settings (admin/token).
NextStreamId, // Instance storage for the auto-incrementing ID counter.
Stream(u64), // Persistent storage for individual stream data (O(1) lookup).
RecipientStreams(Address), // Persistent storage for recipient stream index (sorted by stream_id).
GlobalEmergencyPaused,
CreationPaused,
GlobalPauseReason,
GlobalPauseTimestamp,
GlobalPauseAdmin,
AutoClaimDestination(u64),
NextTemplateId,
ActiveTemplateCount,
StreamTemplate(u64),
OwnerTemplateIds(Address),
TotalLiabilities,
WithdrawNonce(Address),
PauseState,
ReentrancyLock,
RecipientStreamPage(Address, u32),
RecipientStreamPageCount(Address),
PendingRecipientUpdate(u64),
IdReservation(Address),
MaxRatePerSecond,
DelegatedWithdrawNonce(Address),
LastPauseRecord(PauseKind),
RotationHistory(u64),
LastAccrualLedgerTimestamp,
PausedStreamCount,
TotalKeeperFeesPaid,
SenderStreams(Address),
AutoRenewEnabled(u64),
PendingStreamOffer(u64),
RecipientPendingOffers(Address),
}| Discriminant | Variant | Storage type | Value type | Set by | Mutated by |
|---|---|---|---|---|---|
| 0 | Config |
Instance | Config { token, admin } |
init (one-shot) |
set_admin |
| 1 | NextStreamId |
Instance | u64 (monotonic counter) |
init (→ 0) |
create_stream, create_streams |
| 2 | Stream(u64) |
Persistent | Stream struct |
create_stream, create_streams |
pause_stream, resume_stream, cancel_stream, withdraw, withdraw_to, batch_withdraw, top_up_stream, update_rate_per_second, shorten_stream_end_time, extend_stream_end_time |
| 3 | RecipientStreams(Address) |
Persistent | Vec<u64> (sorted) |
create_stream, create_streams |
close_completed_stream, close_cancelled_stream (removes entry) |
| 4 | GlobalEmergencyPaused |
Instance | bool |
set_global_emergency_paused |
(DEPRECATED) |
| 5 | CreationPaused |
Instance | bool |
set_contract_paused |
(DEPRECATED) |
| 6 | GlobalPauseReason |
Instance | String |
pause_protocol |
resume_protocol (removes) |
| 7 | GlobalPauseTimestamp |
Instance | u64 |
pause_protocol |
resume_protocol (removes) |
| 8 | GlobalPauseAdmin |
Instance | Address |
pause_protocol |
resume_protocol (removes) |
| 9 | AutoClaimDestination(u64) |
Persistent | Address |
auto-claim opt-in | auto-claim revoke |
| 10 | NextTemplateId |
Instance | u64 |
init |
create_stream_template |
| 11 | ActiveTemplateCount |
Instance | u64 |
init |
create_stream_template, delete_stream_template |
| 12 | StreamTemplate(u64) |
Persistent | StreamScheduleTemplate |
create_stream_template |
delete_stream_template (removes) |
| 13 | OwnerTemplateIds(Address) |
Persistent | Vec<u64> |
create_stream_template |
delete_stream_template (removes) |
| 14 | TotalLiabilities |
Instance | i128 |
init |
create_stream, withdraw, cancel_stream |
| 15 | WithdrawNonce(Address) |
Persistent | u64 |
delegated_withdraw (first) |
delegated_withdraw (increments) |
| 16 | PauseState |
Instance | PauseState enum |
set_global_emergency_paused, set_contract_paused, pause_protocol |
resume_protocol (Active) |
| 17 | ReentrancyLock |
Instance | bool |
acquire_reentrancy_lock |
release_reentrancy_lock |
| 18 | RecipientStreamPage(Address, u32) |
Persistent | Vec<u64> |
create_stream |
close_completed_stream |
| 19 | RecipientStreamPageCount(Address) |
Persistent | u32 |
create_stream |
close_completed_stream |
| 20 | PendingRecipientUpdate(u64) |
Persistent | Address |
propose_recipient_update |
accept_recipient_update (removes) |
| 21 | IdReservation(Address) |
Persistent | IdReservation |
reserve_stream_ids |
create_stream, create_streams (removes when exhausted) |
| 22 | MaxRatePerSecond |
Instance | i128 |
set_max_rate_per_second |
set_max_rate_per_second |
| 23 | DelegatedWithdrawNonce(Address) |
Persistent | u64 |
delegated_withdraw |
delegated_withdraw (increments) |
| 24 | LastPauseRecord(PauseKind) |
Persistent | PauseRecord |
pause_stream, pause_protocol |
resume_stream, resume_protocol |
| 25 | RotationHistory(u64) |
Persistent | Vec<RotationEntry> |
accept_recipient_update, transfer_sender |
(append-only) |
| 26 | LastAccrualLedgerTimestamp |
Instance | u64 |
current_accrual_timestamp |
current_accrual_timestamp |
| 27 | PausedStreamCount |
Instance | u64 |
pause_stream, pause_stream_as_admin |
resume_stream, cancel_stream, close_completed_stream |
| 28 | TotalKeeperFeesPaid |
Instance | i128 |
init |
keeper_cancel |
| 29 | SenderStreams(Address) |
Persistent | Vec<u64> (sorted) |
create_stream, create_streams |
close_completed_stream, close_cancelled_stream (removes entry) |
| 30 | AutoRenewEnabled(u64) |
Persistent | bool |
sender opt-in | sender revoke |
| 31 | PendingStreamOffer(u64) |
Persistent | StreamOffer |
create_stream_offer |
accept/reject/cancel (removes) |
| 32 | RecipientPendingOffers(Address) |
Persistent | Vec<u64> |
create_stream_offer |
accept/reject/cancel (removes) |
DataKey is a #[contracttype] enum. Soroban serialises enum variants by their discriminant index (0-based, declaration order). Changing the order of existing variants, or inserting a new variant anywhere other than the end, silently shifts all subsequent discriminants and makes every existing persistent storage entry unreadable on any live instance.
Persistent storage is used for individual stream records and per-recipient nonces:
| Key Pattern | Type | Description | Set By | Modified By |
|---|---|---|---|---|
Stream(stream_id) |
Stream struct |
Complete stream state including participants, amounts, timing, and status | create_stream() |
pause_stream(), resume_stream(), cancel_stream(), withdraw() |
RecipientStreams(address) |
Vec<u64> |
Sorted list of stream IDs for a recipient | create_stream() |
close_completed_stream() |
WithdrawNonce(address) |
u64 |
Monotonically increasing nonce for delegated-withdraw replay protection | delegated_withdraw() (first call) |
delegated_withdraw() (incremented on each successful withdrawal that moves tokens) |
- Never reorder existing variants. The discriminant table above is immutable for the lifetime of any deployed instance.
- Never remove a variant that has ever been written to a live network. Mark it
#[deprecated]in a doc comment and stop writing to it; do not delete it. - Always append new variants at the end of the enum.
- Increment
CONTRACT_VERSIONwhenever a new variant is added or an existing variant's associated value type changes — both are breaking changes for off-chain tools that read storage directly. - Document the ledger at which each new variant is first deployed so that migration tooling can determine which entries exist on a given instance.
| Change | Breaking? | Action |
|---|---|---|
| Reorder existing variants | Yes — corrupts all existing entries | Never do this |
| Insert variant in the middle | Yes — shifts discriminants | Never do this |
| Remove an existing variant | Yes — existing entries become orphaned | Deprecate instead |
| Change the value type of an existing variant | Yes — existing entries become undecodable | Increment CONTRACT_VERSION |
| Append a new variant at the end | No — existing entries unaffected | Increment CONTRACT_VERSION (conservative) |
| Change TTL constants | No — no effect on stored data | No version bump required |
| Change internal helper logic with identical external behaviour | No | No version bump required |
- No on-chain enforcement. The rules above are enforced by code review and CI only. A developer who reorders variants will not get a compile error — the bug will only surface at runtime when existing entries are read back with the wrong type.
- Off-chain indexers. Any tool that reads Soroban storage entries directly (e.g., via RPC
getLedgerEntries) must be updated whenever a new variant is added, even if it is append-only. - Discriminant stability across forks. If a fork of this contract adds variants in a different order, its discriminant table will diverge. Always use the canonical table above as the reference.
Used for contract-wide configuration and counters. Shared across all operations, low cardinality (3 keys), TTL extended on every entry-point call.
| Key | Description |
|---|---|
Config |
Token address and admin address. Immutable after init except for admin rotation via set_admin. |
NextStreamId |
Monotonically increasing stream ID counter. Never decremented. |
GlobalEmergencyPaused |
Emergency pause flag. true blocks all operational entrypoints. |
CreationPaused |
Soft creation pause flag. true blocks create_stream and create_streams. |
Used for per-stream data and per-recipient indexes. Grows linearly with stream count.
| Key | Description |
|---|---|
Stream(stream_id) |
Complete stream state: participants, amounts, timing, status, cancelled_at. One entry per stream. |
RecipientStreams(address) |
Sorted Vec<u64> of stream IDs where address is the recipient. Maintained in ascending order. |
AutoClaimDestination(stream_id) |
Recipient-chosen destination Address for permissionless auto-claim. Absent when not opted in. Removed by revoke_auto_claim. |
const INSTANCE_LIFETIME_THRESHOLD: u32 = 17_280; // ~1 day at 5 s/ledger
const INSTANCE_BUMP_AMOUNT: u32 = 120_960; // ~7 days
const PERSISTENT_LIFETIME_THRESHOLD: u32 = 17_280;
const PERSISTENT_BUMP_AMOUNT: u32 = 120_960;Extended via bump_instance_ttl() on every entry-point that touches instance storage. This means any contract interaction — read or write — keeps Config, NextStreamId, GlobalEmergencyPaused, and CreationPaused alive.
Extended on every load_stream() (read) and save_stream() (write), and on every load_recipient_streams() / save_recipient_streams() call.
| Scenario | TTL refreshed? |
|---|---|
| Stream created | Yes (save_stream + save_recipient_streams) |
Stream read via get_stream_state |
Yes (load_stream) |
Stream read via calculate_accrued |
Yes (load_stream) |
| Stream mutated (pause/resume/cancel/withdraw) | Yes (load_stream + save_stream) |
Stream closed via close_completed_stream |
Entry removed (no TTL) |
Recipient index read via get_recipient_streams |
Yes (if non-empty) |
- Active streams: TTL refreshed on any interaction.
- Cancelled streams: Remain in persistent storage until the recipient withdraws the frozen accrued amount.
close_completed_streamandclose_cancelled_streamare blocked while any claimable balance remains; only once the recipient has fully withdrawn the frozen accrued can a permissionless cleanup remove the entry. Operators must ensure recipients are notified to withdraw before TTL expiry. - Inactive streams: May expire after ~7 days with zero interaction. Operators must ensure recipients are notified before TTL expiry.
- Expired entries: Cannot be recovered. Data is permanently lost.
- Contract liveness: Instance storage stays alive as long as any function is called at least once per 7 days.
| Function | Keys read | TTL bumped |
|---|---|---|
get_config |
Config |
Instance |
get_stream_count |
NextStreamId |
Instance |
get_stream_state |
Stream(id) |
Persistent |
calculate_accrued |
Stream(id) |
Persistent |
get_withdrawable |
Stream(id) |
Persistent |
get_claimable_at |
Stream(id) |
Persistent |
get_recipient_streams |
RecipientStreams(addr) |
Persistent (if non-empty) |
get_recipient_stream_count |
RecipientStreams(addr) |
Persistent (if non-empty) |
version |
None | Instance (via bump_instance_ttl) |
| Function | Keys written | Notes |
|---|---|---|
init |
Config, NextStreamId |
One-shot; fails if Config already exists |
create_stream |
NextStreamId, Stream(id), RecipientStreams(addr) |
Atomic |
create_streams |
NextStreamId, Stream(id)×N, RecipientStreams(addr)×N |
Atomic batch |
pause_stream / resume_stream |
Stream(id) |
Status field only |
cancel_stream |
Stream(id) |
Sets status=Cancelled, cancelled_at |
withdraw / withdraw_to |
Stream(id) |
Updates withdrawn_amount; may set status=Completed |
top_up_stream |
Stream(id) |
Updates deposit_amount |
update_rate_per_second |
Stream(id) |
Updates rate_per_second |
shorten_stream_end_time |
Stream(id) |
Updates end_time, deposit_amount |
extend_stream_end_time |
Stream(id) |
Updates end_time |
close_completed_stream |
Removes Stream(id), updates RecipientStreams(addr) |
Permissionless cleanup |
set_admin |
Config |
Admin key rotation |
set_global_emergency_paused |
GlobalEmergencyPaused |
Global emergency pause flag |
set_contract_paused |
CreationPaused |
Soft creation pause flag |
- Atomic operations: All state changes are transactional. No partial updates are possible.
- Key isolation: Each stream has independent storage. No cross-stream interference.
- CEI ordering: State is always persisted (
save_stream) before any external token transfer. Seedocs/security.md. - No stale reads: TTL bumps on reads mean monitoring queries keep data fresh.
- Admin rotation:
set_adminwrites a newConfigwith the updated admin address. The token address is immutable. - ID Reservation Overwrite: Currently, invoking
reserve_stream_idsunconditionally overwrites any existingDataKey::IdReservation(Address)entry for the caller. TheNextStreamIdglobal counter accurately tracks the sum of all reserved blocks, meaning the previously reserved but unconsumed IDs are permanently leaked rather than double-allocated. Integrators must avoid creating a new reservation before fully consuming or reclaiming an existing one.
For a full description of what changed between contract versions and how to migrate, see DEPLOYMENT.md — Version Migration.
This section documents the storage layout as it existed in CONTRACT_VERSION = 5, before the V6 additions. It is the authoritative reference for:
- Regression tests that seed V5-era ledger state and verify V6 read paths.
- Off-chain indexers that may encounter V5-encoded entries on instances that have not been migrated.
- Auditors verifying that no discriminant was shifted between V5 and V6.
| Discriminant | Variant | Storage | Value type |
|---|---|---|---|
| 0 | Config |
Instance | Config { token, admin } |
| 1 | NextStreamId |
Instance | u64 |
| 2 | Stream(u64) |
Persistent | Stream (V5, 14 fields) |
| 3 | RecipientStreams(Address) |
Persistent | Vec<u64> (sorted) |
| 4 | GlobalEmergencyPaused |
Instance | bool |
| 5 | CreationPaused |
Instance | bool |
| 6 | GlobalPauseReason |
Instance | String |
| 7 | GlobalPauseTimestamp |
Instance | u64 |
| 8 | GlobalPauseAdmin |
Instance | Address |
| 9 | AutoClaimDestination(u64) |
Persistent | Address |
| 10 | NextTemplateId |
Instance | u64 |
| 11 | ActiveTemplateCount |
Instance | u64 |
| 12 | StreamTemplate(u64) |
Persistent | StreamScheduleTemplate |
| 13 | OwnerTemplateIds(Address) |
Persistent | Vec<u64> |
| 14 | TotalLiabilities |
Instance | i128 |
Discriminants 0–14 are permanently frozen. No variant at these positions may ever be reordered, renamed, or removed on any instance that has processed at least one transaction.
| Position | Field | Type | Notes |
|---|---|---|---|
| 0 | stream_id |
u64 |
Monotonically increasing, set at creation |
| 1 | sender |
Address |
Stream creator and controller |
| 2 | recipient |
Address |
Token beneficiary |
| 3 | deposit_amount |
i128 |
Total escrowed tokens |
| 4 | rate_per_second |
i128 |
Streaming speed in raw token units/second |
| 5 | start_time |
u64 |
Ledger timestamp when accrual begins |
| 6 | cliff_time |
u64 |
Ledger timestamp when withdrawals unlock |
| 7 | end_time |
u64 |
Ledger timestamp when accrual stops |
| 8 | withdrawn_amount |
i128 |
Cumulative tokens already withdrawn |
| 9 | status |
StreamStatus |
Active, Paused, Completed, Cancelled |
| 10 | cancelled_at |
Option<u64> |
Set when status transitions to Cancelled |
| 11 | checkpointed_amount |
i128 |
Accrued tokens locked at last rate change |
| 12 | checkpointed_at |
u64 |
Timestamp of last rate change |
| 13 | withdraw_dust_threshold |
i128 |
Minimum withdrawal amount (0 = no filter) |
No memo field in V5. The V5 Stream struct has exactly 14 fields.
V6 appended six new DataKey variants (discriminants 15–20) and one new Stream field:
| Discriminant | Variant | Storage | Value type | Notes |
|---|---|---|---|---|
| 15 | WithdrawNonce(Address) |
Persistent | u64 |
Per-recipient nonce; absent until first delegated-withdraw |
| 16 | PauseState |
Instance | PauseState |
Unified pause state enum |
| 17 | ReentrancyLock |
Instance | bool |
Reentrancy guard; absent when not held |
| 18 | RecipientStreamPage(Address, u32) |
Persistent | Vec<u64> |
Paged recipient index (page → IDs) |
| 19 | RecipientStreamPageCount(Address) |
Persistent | u32 |
Number of pages in recipient's index |
| 20 | PendingRecipientUpdate(u64) |
Persistent | Address |
Pending recipient rotation proposal |
V6 Stream struct adds one field at the end:
| Position | Field | Type | Notes |
|---|---|---|---|
| 14 | memo |
Option<Bytes> |
Optional indexer correlation memo (max 64 bytes); None in V5 entries |
All V5 persistent Stream entries remain decodable on a V6 instance. Soroban XDR struct decoding is positional and forward-compatible: a V6 decoder reading a V5-encoded struct decodes the first 14 fields correctly and treats the absent 15th field as None (for Option<Bytes>).
This guarantee holds only because:
memoisOption-typed — an absent field decodes asNone, not a type error.memois appended as the last field — no positional shift occurs for fields 0–13.
A non-Option append or a mid-struct insertion would break V5 entries silently.
The file contracts/stream/tests/storage_key_compat.rs encodes these invariants as executable tests:
| Test | What it guards |
|---|---|
v5_stream_readable_by_v6_get_stream_state |
Discriminant 2 stability; memo == None on V5 entries |
v5_stream_calculate_accrued_correct |
Accrual math on V5 entries |
v5_stream_get_withdrawable_correct |
Withdrawable calculation on V5 entries |
v5_stream_get_claimable_at_correct |
Claimable-at simulation on V5 entries |
v5_multiple_streams_all_readable |
Stream(u64) key encoding for multiple IDs |
v5_cancelled_stream_readable_accrual_frozen |
cancelled_at field decoding; frozen accrual |
v5_stream_with_checkpoint_readable |
checkpointed_amount field decoding |
v5_config_key_readable_by_v6 |
Discriminant 0 stability |
v5_next_stream_id_readable_by_v6 |
Discriminant 1 stability |
v5_global_emergency_paused_readable_by_v6 |
Discriminant 4 stability |
v5_creation_paused_readable_by_v6 |
Discriminant 5 stability |
v5_total_liabilities_readable_by_v6 |
Discriminant 14 stability (last frozen key) |
v5_recipient_streams_readable_by_v6 |
Discriminant 3 stability |
v5_recipient_stream_count_correct |
RecipientStreams count on V5 index |
v5_absent_recipient_streams_returns_empty |
No panic on absent V5 index |
v6_withdraw_nonce_absent_on_v5_instance |
Discriminant 15 absent on V5 |
v6_pause_state_absent_on_v5_instance |
Discriminant 16 absent on V5 |
v6_reentrancy_lock_absent_on_v5_instance |
Discriminant 17 absent on V5 |
v6_recipient_stream_page_absent_on_v5_instance |
Discriminant 18 absent on V5 |
v6_recipient_stream_page_count_absent_on_v5_instance |
Discriminant 19 absent on V5 |
v6_pending_recipient_update_absent_on_v5_instance |
Discriminant 20 absent on V5 |
discriminant_0_config_round_trips |
Config key round-trip |
discriminant_1_next_stream_id_round_trips |
NextStreamId key round-trip |
discriminant_2_stream_round_trips |
Stream key round-trip |
discriminant_3_recipient_streams_round_trips |
RecipientStreams key round-trip |
discriminant_14_total_liabilities_round_trips |
TotalLiabilities key round-trip |
version_entry_point_works_on_v5_seeded_instance |
version() callable on V5 state |
Both reservation release entrypoints now share a unified reclamation helper (release_reservation) that reclaims tip-adjacent unused IDs:
- Action: Immediate, voluntary release of an active reservation by its owner.
- Counter Behavior: If the reservation is tip-adjacent (its allocated range ends exactly at the current
NextStreamId) and fully or partially unconsumed,NextStreamIdis rewound to the first unconsumed ID. If IDs beyond the reservation range were consumed (non-tip-adjacent), the reservation record is simply removed with no counter rewind.
- Action: Permissionless reclamation of a reservation that has passed its
expirytimestamp. - Counter Behavior: Same as
release_id_reservation— if the expired reservation is tip-adjacent and unconsumed,NextStreamIdis rewound to the first unconsumed ID.
- Pre-expiry rejection: Blocks denial-of-service (DoS) or front-running attacks where an attacker reclaims a user's reservation before they can publish their streams.
- At-expiry & post-expiry success: Ensures that if a holder abandons or loses access to their reservation, the counter space/storage is not permanently locked, maintaining contract liveness.
- Tip-adjacent guard: Counter rewind only occurs when
reservation_end == current_count, meaning no streams exist beyond the reserved range. This prevents unsafe rewinds that would create ID collisions with already-created streams. - Consistent event shape: Both paths emit the
res_relevent with(start_id, count, consumed, reclaimed), ensuring consistent indexer accounting regardless of which release path triggered the reclamation.