Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions docs/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,104 @@ persistent TTL):
`get_schema_version()` returns the persisted storage-layout version
(defaults to `1`, advanced to `2` by `migrate_v1_to_v2`). The two are
independent — see the migration entrypoints in `src/lib.rs`.

## Namespaced key policy

Pair storage is accessed through the typed namespace in
[`src/pool_storage.rs`](../src/pool_storage.rs). A namespace is the ordered
pair `(source, destination)` and a slot is one of the following typed values:

| Slot | Existing `DataKey` mapping | Cleared on unregister |
|---|---|---|
| `Registration` | `Pair(source, destination)` | yes |
| `FeeBps` | `PairFeeBps(source, destination)` | yes |
| `MinAmount` | `PairMinAmount(source, destination)` | yes |
| `MaxAmount` | `PairMaxAmount(source, destination)` | yes |
| `Liquidity` | `PairLiquidity(source, destination)` | yes |
| `LastRouteAt` | `PairLastRouteAt(source, destination)` | no |
| `RouteCount` | `PairRouteCount(source, destination)` | no |
| `Volume` | `PairVolume(source, destination)` | no |
| `Cooldown` | `PairCooldown(source, destination)` | yes |

The mapping is intentionally one-to-one with the deployed enum variants. It
is a namespace and review boundary, not a migration to a new serialized key.
Changing a slot's mapping would change its XDR encoding and is therefore an
ABI/storage migration requiring a new schema version and an explicit repair
plan. Adding a new slot is append-only and must include a layout test and a
default value in this document.

## Access-time TTL bumping

`bump_pair_ttl` renews every existing key in a pair namespace using one policy:

- threshold: 518,400 ledgers (about 30 days at five seconds per ledger);
- extension target: 1,036,800 ledgers (about 60 days);
- storage tier: persistent only;
- missing entries: skipped, never created as a side effect of a read.

The registration read invokes the helper, so route checks, pair inspection,
and configuration validation refresh live pair slots. Pair writes also invoke
the helper after storing their value. This means a pair that is accessed but
not routed remains available for configuration and inspection, while an
unregistered pair still returns the documented default without leaving an
orphan slot.

TTL bumping is deliberately centralized. Callers must not use a different
threshold for one field, because that would make a single logical pair expire
partially and produce inconsistent reads. The helper checks `has` before
`extend_ttl` because Soroban does not treat an absent persistent key as a
renewable value. A transaction that writes a new key and then bumps it gets a
fresh TTL; a transaction that fails rolls back both the write and the bump.

## Layout compatibility tests

The namespace tests cover directionality, all nine slot-to-`DataKey` mappings,
stable diagnostic labels, config/history clearing rules, and TTL policy bounds.
Contract integration tests should additionally verify that:

1. `(USDC, EURC)` and `(EURC, USDC)` never share a slot;
2. a fee update does not alter liquidity or route metrics;
3. a read of a missing pair does not create storage;
4. a pair access extends TTL for an existing slot;
5. a pair write extends TTL for the newly created slot;
6. unregister clears configuration but preserves history;
7. purge removes only explicitly requested history;
8. a second schema migration is rejected;
9. a new release reads all pre-existing v1 keys unchanged.

These checks make the storage layout an explicit compatibility contract rather
than an accidental consequence of individual call sites.

## Operator runbook

When a pair is slow-moving, an operator can call a read-only pair getter to
renew the existing namespace entries. The getter does not register a pair and
does not write a missing key. If a pair has been archived already, use the
normal admin configuration or registration transaction to restore its state;
do not introduce a second spelling of the pair or a hand-built symbol key.

Before a release that changes storage code:

- compare the `DataKey` enum with the slot mapping table;
- run the layout completeness test and the pre-existing v1 test suite;
- confirm the threshold and extension target are unchanged;
- inspect the generated XDR for every existing `DataKey` variant;
- verify that reverse-direction pairs remain independent;
- exercise unregister and explicit metric purge separately;
- test an absent pair and confirm no persistent key is created;
- test a configured pair and confirm every present slot is bumped;
- record the schema version and migration decision in release notes.

The TTL helper is safe to call repeatedly. It extends only when the host says
the key is near its threshold, so regular reads do not keep increasing a key's
TTL without bound beyond the configured target. All slots in one namespace
share the same policy, which avoids partial archival where a dashboard sees a
fee from one epoch and metrics from another.

The typed `PoolSlot` ordinal is an audit convention, not a replacement for the
serialized `DataKey` discriminant. Keep the existing enum variant order and
field order stable. If a new pair slot is necessary, append its typed slot,
map it to a new `DataKey` variant, document its default and clear policy, add
one directionality test, and bump the storage schema only when a migration is
actually required. A refactor that continues to map to the old variant does
not need a migration.
78 changes: 53 additions & 25 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ use soroban_sdk::{
Bytes, BytesN, Env, Symbol, Vec,
};

mod error_taxonomy;
mod pool_storage;
use pool_storage::{bump_key_ttl, bump_pair_ttl};

/// Aggregated read of every pair-scoped storage slot (base fields).
#[contracttype]
Expand Down Expand Up @@ -448,10 +449,10 @@ impl StableRouteRouter {
/// unregistered pair. This is the single source of truth for the
/// [`DataKey::Pair`] sentinel value.
fn read_pair_registered(env: &Env, source: &Symbol, destination: &Symbol) -> bool {
env.storage()
.persistent()
.get(&DataKey::Pair(source.clone(), destination.clone()))
.unwrap_or(false)
let key = DataKey::Pair(source.clone(), destination.clone());
let value = env.storage().persistent().get(&key).unwrap_or(false);
bump_key_ttl(env, &key);
value
}

/// Read the per-pair fee in basis points from persistent storage.
Expand All @@ -460,10 +461,10 @@ impl StableRouteRouter {
/// default for an unconfigured, registered pair. This is the single
/// source of truth for the [`DataKey::PairFeeBps`] sentinel value.
fn read_pair_fee_bps(env: &Env, source: &Symbol, destination: &Symbol) -> u32 {
env.storage()
.persistent()
.get(&DataKey::PairFeeBps(source.clone(), destination.clone()))
.unwrap_or(0)
let key = DataKey::PairFeeBps(source.clone(), destination.clone());
let value = env.storage().persistent().get(&key).unwrap_or(0);
bump_key_ttl(env, &key);
value
}

/// Read the per-pair minimum routable amount from persistent storage.
Expand All @@ -472,10 +473,10 @@ impl StableRouteRouter {
/// default. This is the single source of truth for the
/// [`DataKey::PairMinAmount`] sentinel value.
fn read_pair_min(env: &Env, source: &Symbol, destination: &Symbol) -> i128 {
env.storage()
.persistent()
.get(&DataKey::PairMinAmount(source.clone(), destination.clone()))
.unwrap_or(0)
let key = DataKey::PairMinAmount(source.clone(), destination.clone());
let value = env.storage().persistent().get(&key).unwrap_or(0);
bump_key_ttl(env, &key);
value
}

/// Read the per-pair maximum routable amount from persistent storage.
Expand All @@ -484,10 +485,10 @@ impl StableRouteRouter {
/// absent. This is the single source of truth for the
/// [`DataKey::PairMaxAmount`] sentinel value.
fn read_pair_max(env: &Env, source: &Symbol, destination: &Symbol) -> i128 {
env.storage()
.persistent()
.get(&DataKey::PairMaxAmount(source.clone(), destination.clone()))
.unwrap_or(i128::MAX)
let key = DataKey::PairMaxAmount(source.clone(), destination.clone());
let value = env.storage().persistent().get(&key).unwrap_or(i128::MAX);
bump_key_ttl(env, &key);
value
}

/// Read the per-pair reported liquidity from persistent storage.
Expand All @@ -500,10 +501,10 @@ impl StableRouteRouter {
/// Callers needing the unbounded semantic should read the slot
/// directly with `unwrap_or(i128::MAX)`.
fn read_pair_liquidity(env: &Env, source: &Symbol, destination: &Symbol) -> i128 {
env.storage()
.persistent()
.get(&DataKey::PairLiquidity(source.clone(), destination.clone()))
.unwrap_or(0)
let key = DataKey::PairLiquidity(source.clone(), destination.clone());
let value = env.storage().persistent().get(&key).unwrap_or(0);
bump_key_ttl(env, &key);
value
}

/// Read the per-pair route cooldown from persistent storage.
Expand All @@ -512,10 +513,10 @@ impl StableRouteRouter {
/// documented default for an unconfigured pair. This is the single
/// source of truth for the [`DataKey::PairCooldown`] sentinel value.
fn read_pair_cooldown(env: &Env, source: &Symbol, destination: &Symbol) -> u64 {
env.storage()
.persistent()
.get(&DataKey::PairCooldown(source.clone(), destination.clone()))
.unwrap_or(0)
let key = DataKey::PairCooldown(source.clone(), destination.clone());
let value = env.storage().persistent().get(&key).unwrap_or(0);
bump_key_ttl(env, &key);
value
}

/// Returns the router contract version.
Expand Down Expand Up @@ -804,6 +805,7 @@ impl StableRouteRouter {
env.storage()
.persistent()
.set(&DataKey::Pair(source.clone(), destination.clone()), &true);
bump_key_ttl(&env, &DataKey::Pair(source.clone(), destination.clone()));
env.events()
.publish((symbol_short!("pair_reg"),), (source, destination));
}
Expand Down Expand Up @@ -835,6 +837,7 @@ impl StableRouteRouter {
env.storage()
.persistent()
.set(&DataKey::Pair(source.clone(), destination.clone()), &true);
bump_key_ttl(&env, &DataKey::Pair(source.clone(), destination.clone()));
env.events()
.publish((symbol_short!("pair_reg"),), (source, destination));
}
Expand Down Expand Up @@ -959,6 +962,10 @@ impl StableRouteRouter {
&DataKey::PairCooldown(source.clone(), destination.clone()),
&cooldown_secs,
);
bump_key_ttl(
&env,
&DataKey::PairCooldown(source.clone(), destination.clone()),
);
env.events().publish(
(symbol_short!("cd_set"),),
(source, destination, cooldown_secs),
Expand Down Expand Up @@ -1182,6 +1189,10 @@ impl StableRouteRouter {
&DataKey::PairLiquidity(source.clone(), destination.clone()),
&liquidity,
);
bump_key_ttl(
&env,
&DataKey::PairLiquidity(source.clone(), destination.clone()),
);
env.events().publish(
(symbol_short!("liq_set"),),
(source, destination, liquidity),
Expand Down Expand Up @@ -1212,6 +1223,10 @@ impl StableRouteRouter {
&DataKey::PairMaxAmount(source.clone(), destination.clone()),
&max_amount,
);
bump_key_ttl(
&env,
&DataKey::PairMaxAmount(source.clone(), destination.clone()),
);
env.events().publish(
(symbol_short!("max_set"),),
(source, destination, max_amount),
Expand Down Expand Up @@ -1242,6 +1257,10 @@ impl StableRouteRouter {
&DataKey::PairMinAmount(source.clone(), destination.clone()),
&min_amount,
);
bump_key_ttl(
&env,
&DataKey::PairMinAmount(source.clone(), destination.clone()),
);
env.events().publish(
(symbol_short!("min_set"),),
(source, destination, min_amount),
Expand Down Expand Up @@ -1346,6 +1365,10 @@ impl StableRouteRouter {
&DataKey::PairFeeBps(source.clone(), destination.clone()),
&fee_bps,
);
bump_key_ttl(
&env,
&DataKey::PairFeeBps(source.clone(), destination.clone()),
);
env.events()
.publish((symbol_short!("fee_set"),), (source, destination, fee_bps));
}
Expand All @@ -1372,6 +1395,10 @@ impl StableRouteRouter {
&DataKey::PairFeeBps(source.clone(), destination.clone()),
&fee_bps,
);
bump_key_ttl(
&env,
&DataKey::PairFeeBps(source.clone(), destination.clone()),
);
env.events()
.publish((symbol_short!("fee_set"),), (source, destination, fee_bps));
}
Expand Down Expand Up @@ -1559,6 +1586,7 @@ impl StableRouteRouter {
env.storage()
.persistent()
.set(&route_at_key, &env.ledger().timestamp());
bump_pair_ttl(&env, &source, &destination);

// Last use of source/destination — moved (consumed) rather than
// cloned, saving one Symbol clone pair on the hot path.
Expand Down
Loading
Loading