Skip to content

feat(contracts): add shared storage TTL policy and renewal across core crates - #570

Merged
PeterOche merged 1 commit into
degenspot:mainfrom
Cybermaxi7:feat/storage-ttl-policy-568
Aug 20, 2026
Merged

feat(contracts): add shared storage TTL policy and renewal across core crates#570
PeterOche merged 1 commit into
degenspot:mainfrom
Cybermaxi7:feat/storage-ttl-policy-568

Conversation

@Cybermaxi7

Copy link
Copy Markdown
Contributor

Closes #568

The problem, measured

Retention was decided per crate, with no shared policy and no stated reasoning:

Crate Persistent extension Threshold
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 none none
prediction_market_futures none none

A 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:

Class Threshold Target Applies to
Config ~60 d ~120 d admin, oracle set, fees, windows, counters
Active ~45 d ~90 d open markets, unresolved outcomes, orders, futures positions
Claimable ~60 d ~150 d unclaimed payouts, refunds, recovery designations, call records
Historical never renewed settled outcomes and closed calls kept only for history

Values 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

  1. Renew on lifecycle writes, not reads. A read-renewal lets any caller refresh any record — 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 one entry a caller cannot multiply. (criteria 4, 9)
  2. Renew below a threshold, not on every touch. Topping up an entry that already holds ample TTL pays rent for time it already owns.
  3. Renewal emits nothing. It is storage maintenance and must not be mistakable for a state change by an indexer. (criterion 7)
  4. Never extend a missing entry — that traps and would fail an otherwise valid transaction. (criterion 6)

Applied

  • outcome_manager (renewed nothing): recovery addresses Claimable, votes Active, pending oracle changes Config
  • prediction_market_futures (renewed nothing): positions Active, instance on config writes
  • call_registry: migrated onto shared constants; calls classify Claimable, raising the target ~120 → ~150 days, so nothing that survives today expires sooner
  • prediction_market: migrated; orders classify Active, ~7 → ~90 days

⚠️ Two behaviour changes for your review

Per criterion 11, neither is silent — both are documented in TTL_POLICY.md:

1. call_registry::get_call no 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_market order 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_manager keeps 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:

  1. Per-class retention is not expressible for that state. Claimable payouts held there cannot be retained or expired independently of configuration — they live and die with the whole instance. Criterion 5 is therefore satisfied for those records only incidentally.
  2. It grows without bound. Instance storage is size-limited and every access loads the whole entry.

The codebase already documents the correct rule, on PersistentKey::RecoveryAddress:

Per-user, so this lives in persistent (not instance) storage — see PersistentKey::Votes for the established pattern

Claimed and ClaimableBalanceId are 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 in TTL_POLICY.md rather 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

# Criterion Status
1 Inventory storage across the four crates TTL_POLICY.md
2 Classify by required retention window ✅ four classes
3 Shared thresholds, documented, no magic numbers shared/src/ttl.rs
4 Renew on lifecycle ops without unbounded growth ✅ writes only
5 Claimable state live through its claim window ✅ tested past the 30 d grace period; see the instance-storage caveat
6 Defined behaviour for expired/archived history Historical never renewed; missing entries never extended
7 No misleading business event from renewal ✅ renewal emits nothing
8 Tests advance ledger near and beyond thresholds ✅ 8 tests
9 One user cannot renew another's records ✅ tested
10 Document rent implications and rationale TTL_POLICY.md
11 Backward-compatible unless documented ✅ two changes documented above
12 No frontend or backend changes ✅ contracts only

…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
PeterOche merged commit 8cb272a into degenspot:main Aug 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement Soroban Persistent Storage TTL Renewal and Expiry Tests

2 participants