Skip to content

Latest commit

 

History

History
139 lines (101 loc) · 7.07 KB

File metadata and controls

139 lines (101 loc) · 7.07 KB

ADR-007: Storage Key Evolution and Additive-Key Policy

Status: Accepted
Date: 2026-04-25
Refs: escrow/src/lib.rsDataKey, SCHEMA_VERSION, migrate; docs/escrow-data-model.md


Context

The escrow contract stores all state in Soroban instance storage under a DataKey enum. As the protocol evolves, new features require new storage keys. Soroban does not provide automatic schema migration: the contract author must decide which changes are safe to deploy in-place and which require a coordinated migration or full redeploy.

The repository README documents a high-level policy ("Storage-only upgrade policy"). This ADR formalises that policy, defines the compatibility boundary, and records the test plan.

Decision

Rule 1 — New optional keys are always backward-compatible

Adding a new DataKey variant is safe when:

  1. The new key is read with .get(...).unwrap_or(default) so deployments that predate the key behave as "unset / default" without panicking.
  2. The XDR shape of every existing variant and stored struct is unchanged.
  3. The new key's absence does not alter the semantics of any existing entrypoint.

Such changes do not require a SCHEMA_VERSION bump or a migrate call.

Rule 2 — Changing existing stored types is breaking

The following changes require either a migrate implementation or a full redeploy:

  • Adding a non-optional field to an existing #[contracttype] struct (e.g. InvoiceEscrow).
  • Renaming a DataKey variant or changing its XDR discriminant.
  • Changing the stored Rust type of an existing key (e.g. LegalHold: bool → u32).

When a breaking change is needed, implement a migrate(from_version, to_version) path that reads the old layout, rewrites under the new layout, and bumps DataKey::Version.

Rule 3 — SCHEMA_VERSION tracks breaking changes only

SCHEMA_VERSION (currently 6) is incremented only when a migrate path is added. Additive-only releases leave the version unchanged.

Rule 4 — Per-address key growth must be re-evaluated

Any new per-address DataKey variant (e.g. DataKey::SomeFlag(Address)) multiplies storage consumption by the investor count. Before merging, verify the worst-case serialised size at MaxUniqueInvestorsCap stays within Soroban's per-entry limits. The existing storage-growth regression tests in escrow/src/tests/ serve as the baseline.

Rule 5 — Per-investor keys use persistent storage (schema version 6, issue #253)

The following keys are stored via env.storage().persistent() so each investor address has an independent TTL and the contract instance entry does not grow with investor cardinality:

  • DataKey::InvestorContribution(Address)
  • DataKey::InvestorEffectiveYield(Address)
  • DataKey::InvestorClaimNotBefore(Address)
  • DataKey::InvestorClaimed(Address)

Read/write semantics are unchanged: absent keys still default to 0, base yield_bps, 0, and false respectively. Per-investor persistent keys have their TTL extended at write time using PERSISTENT_TTL_MIN_EXTENSION_LEDGERS. See docs/escrow-gas-storage-notes.md for additional TTL extension via LiquifactEscrow::bump_ttl.

Migration: relocating storage type is not enumerable on-chain (no iteration over instance keys by address). migrate returns [EscrowError::NoMigrationPath]; operators must redeploy fresh contract instances at SCHEMA_VERSION = 6.

Rule 6 — DataKey XDR discriminant stability

The DataKey enum is encoded to XDR with each variant assigned a fixed integer discriminant equal to its 0-indexed position in the enum definition. This discriminant is stored on-chain as the storage key identifier.

Consequence: reordering existing DataKey variants changes their on-chain discriminant. A key that was previously reachable as discriminant N becomes unreachable; existing on-chain data keyed under the old discriminant is invisible to the new WASM and cannot be migrated in-place (addresses are not enumerable).

Rule: existing DataKey variants must never be renamed, removed, or reordered. Only append new variants at the end of the enum. This applies to both instance and persistent storage keys.

This rule is enforced by code review. PRs that touch DataKey must include a diff showing only appends. Any removal or reorder requires a documented redeploy.

upgrade() and migrate() division of labor

The upgrade(new_wasm_hash) entrypoint swaps the WASM bytecode for an existing contract instance without touching any storage. migrate(from_version) is the admin-gated entrypoint for applying storage rewrites after a schema-breaking upgrade. Their division of labor:

Change type Required action
New additive DataKey (read with defaults) upgrade() only — no migrate() needed
Bug fix / logic change (no storage shape change) upgrade() only
Breaking struct / key change (in-place feasible) upgrade() then migrate(stored_version)
Breaking struct / key change (not enumerable) Redeploy (no migration path possible)

In the current release (SCHEMA_VERSION = 6), migrate() fails on all paths with typed contract errors. See docs/OPERATOR_RUNBOOK.md §2.5 for step-by-step procedures and escrow/src/lib.rs upgrade() rustdoc for the complete additive-key safety contract.

Compatibility test plan

  1. Deploy version N; exercise init, fund, settle, claim_investor_payout.
  2. Deploy version N+1 with only new optional keys; repeat the same flows; assert old instance keys are still readable and return expected defaults.
  3. If InvoiceEscrow or another existing struct changes, add a migration test that: a. Writes the old layout directly via env.storage().instance().set(...). b. Calls migrate(N, N+1). c. Reads back the new layout and asserts correctness.
  4. If no migration path is feasible, document mandatory redeploy in the release notes and bump SCHEMA_VERSION.

Consequences

  • Reviewers can approve additive-key PRs without requiring a migration test.
  • Breaking changes are blocked from merging until a migration path or explicit redeploy note exists.
  • SCHEMA_VERSION remains a reliable signal: a stored version lower than SCHEMA_VERSION means migrate must be called before using new features that depend on the new layout.
  • Storage-growth tests act as regression guards; any PR that adds per-address keys must update or extend those tests.
  • Schema version 6 bounds instance footprint by moving the four per-investor keys above to persistent storage; TTL per address is isolated from the contract instance (Stellar docs: state archival).

Rejected alternatives

  • Always bump version on any key addition: creates unnecessary migration ceremony for purely additive changes and makes migrate a no-op most of the time.
  • In-place migrate to copy instance per-investor entries to persistent: rejected because investor addresses cannot be enumerated from storage; no safe on-chain migration path exists.