Status: Accepted
Date: 2026-04-25
Refs: escrow/src/lib.rs — DataKey, SCHEMA_VERSION, migrate; docs/escrow-data-model.md
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.
Adding a new DataKey variant is safe when:
- The new key is read with
.get(...).unwrap_or(default)so deployments that predate the key behave as "unset / default" without panicking. - The XDR shape of every existing variant and stored struct is unchanged.
- 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.
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
DataKeyvariant 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.
SCHEMA_VERSION (currently 6) is incremented only when a migrate path is added. Additive-only
releases leave the version unchanged.
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.
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.
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.
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.
- Deploy version N; exercise
init,fund,settle,claim_investor_payout. - Deploy version N+1 with only new optional keys; repeat the same flows; assert old instance keys are still readable and return expected defaults.
- If
InvoiceEscrowor another existing struct changes, add a migration test that: a. Writes the old layout directly viaenv.storage().instance().set(...). b. Callsmigrate(N, N+1). c. Reads back the new layout and asserts correctness. - If no migration path is feasible, document mandatory redeploy in the release notes and bump
SCHEMA_VERSION.
- 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_VERSIONremains a reliable signal: a stored version lower thanSCHEMA_VERSIONmeansmigratemust 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).
- Always bump version on any key addition: creates unnecessary migration ceremony for purely
additive changes and makes
migratea no-op most of the time. - In-place
migrateto copy instance per-investor entries to persistent: rejected because investor addresses cannot be enumerated from storage; no safe on-chain migration path exists.