feat(contracts): add shared storage TTL policy and renewal across core crates - #570
Merged
Merged
Conversation
…e crates Soroban charges rent for state and evicts entries whose TTL lapses. Retention was previously decided per crate, with no shared policy and no stated reasoning: call_registry 2_073_600 (~120 d) / 1_036_800 (~60 d) prediction_market 120_960 (~7 d) / 60_480 (~3.5 d) outcome_manager no renewal at all prediction_market_futures no renewal at all A 17x spread between two crates holding comparable records is not a policy, and two crates renewing nothing means their state simply expires. Adds `backit_shared::ttl` with four retention classes, each carrying its reasoning: Config, Active, Claimable, and Historical (never renewed). Values are derived through a `const fn days()` rather than written as literals, clamped to the 180 day network maximum, and paired threshold/target so an entry is only topped up once it actually needs it. Renewal rules the policy enforces: - Renew on lifecycle writes, not on reads. A read-renewal lets any caller refresh any record, so one address polling in a loop keeps unrelated users' state alive and shifts the rent onto the contract. Only Config renews on access, and only because instance storage is a single entry a caller cannot multiply. - Renew below a threshold, not on every touch. Topping up an entry that already holds ample TTL pays rent for time it already owns. - Never emit an event for a renewal. It is storage maintenance, not a state change, and must not be mistakable for one by an indexer. - Never extend a missing entry; doing so traps and would fail an otherwise valid transaction. Applied: - outcome_manager, which renewed nothing: recovery addresses as Claimable, votes as Active, pending oracle changes as Config. - prediction_market_futures, which renewed nothing: positions as Active, instance on configuration writes. - call_registry migrated onto the shared constants. Call records classify as Claimable, raising their target from ~120 to ~150 days, so nothing that survives today expires sooner. - prediction_market migrated. Orders classify as Active, ~7 days to ~90. Two behaviour changes flagged for maintainer review, both documented in TTL_POLICY.md: - `call_registry::get_call` no longer renews on read. A call that is only ever read, never written, now expires on its own schedule. - prediction_market order retention lengthens substantially, which costs more rent but removes a real failure mode: an order outliving its storage entry disappears while still notionally open. TTL_POLICY.md records the ledger/time assumptions, the rent implications, a storage inventory for all four crates, and one finding left unfixed: outcome_manager holds per-call and per-user records — Claimed, ClaimableBalanceId, FinalOutcome, SettledAt — in instance storage. Instance is a single entry with a single TTL, so per-class retention is not expressible for that state and it grows without bound. The codebase documents the correct rule on PersistentKey::RecoveryAddress and these keys do not follow it. Moving them is a storage migration affecting deployed state, which this issue places behind maintainer approval, so it is recorded rather than attempted. Tests: 17 new (9 policy, 8 TTL behaviour). The TTL tests advance ledger sequence across thresholds and cover renewal, non-renewal above the threshold, the claim window past the 30 day recovery grace period, read-does-not-renew, and that one user's write cannot renew another user's record. One of those tests failed first and corrected the implementation's semantics: a rewrite does not renew while TTL remains above the threshold, by design. Both sides of that boundary are now covered. Verified: 247 tests pass across the five crates, up from 230, no regressions. Clippy clean on the changed files.
PeterOche
approved these changes
Aug 20, 2026
12 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #568
The problem, measured
Retention was decided per crate, with no shared policy and no stated reasoning:
call_registryprediction_marketoutcome_managerprediction_market_futuresA 17× spread between two crates holding comparable records is not a policy, and two crates renewing nothing means their state simply expires.
What this adds
backit_shared::ttl— four retention classes, each carrying its reasoning in the source:ConfigActiveClaimableHistoricalValues derive from a
const fn days()rather than being written as literals, are clamped to the 180-day network maximum, and pair a threshold with a target so an entry is topped up only once it needs it.Ledger assumption, stated and qualified: 5 s close → 17,280 ledgers/day. Close time is a target; under load ledgers close slower, which makes a ledger-denominated window shorter in wall-clock terms than nominal, never longer. Every window is sized with headroom rather than to the exact requirement.
Renewal rules the policy enforces
Configrenews on access, and only because instance storage is one entry a caller cannot multiply. (criteria 4, 9)Applied
outcome_manager(renewed nothing): recovery addressesClaimable, votesActive, pending oracle changesConfigprediction_market_futures(renewed nothing): positionsActive, instance on config writescall_registry: migrated onto shared constants; calls classifyClaimable, raising the target ~120 → ~150 days, so nothing that survives today expires soonerprediction_market: migrated; orders classifyActive, ~7 → ~90 daysPer criterion 11, neither is silent — both are documented in
TTL_POLICY.md:1.
call_registry::get_callno longer renews on read. It previously extended TTL on every access, which is precisely the unbounded-growth pattern criterion 9 prohibits. Consequence: a call that is only ever read, never written, now expires on its own schedule instead of being kept alive by readers.2.
prediction_marketorder retention lengthens substantially (~7 → ~90 days). This costs more rent, and removes a real failure mode: an order outliving its storage entry disappears while still notionally open. The figure is open to your judgement.Finding left unfixed, deliberately
outcome_managerkeeps per-call and per-user records in instance storage:Claimed(u64, Address),ClaimableBalanceId(u64, Address),FinalOutcome(u64),SettledAt(u64),PendingOutcome(u64),DisputeWindowStart(u64).Instance storage is a single entry with a single TTL. Two consequences:
The codebase already documents the correct rule, on
PersistentKey::RecoveryAddress:ClaimedandClaimableBalanceIdare per-user and do not follow it. Moving them is a storage migration affecting deployed state, which #568 places behind maintainer approval — so it is recorded inTTL_POLICY.mdrather than attempted here.Tests
17 new; 247 passing across the five crates, up from 230, no regressions. Clippy clean on the changed files.
The TTL tests advance ledger sequence across thresholds and cover: renewal, non-renewal above the threshold, the claim window past the 30-day recovery grace period, read-does-not-renew, rewrite-renews-below-threshold, and one user's write not renewing another user's record (criterion 9).
Worth flagging: one test failed first and corrected the implementation's semantics. I had asserted that rewriting a record renews it — it does not, while TTL remains above the threshold, because topping up an entry with 130 days left pays rent twice. Both sides of that boundary are now covered, and the test names say which is which.
Criteria
TTL_POLICY.mdshared/src/ttl.rsHistoricalnever renewed; missing entries never extendedTTL_POLICY.md