diff --git a/docs/storage.md b/docs/storage.md index a1d6adb..0c1270b 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -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. diff --git a/src/lib.rs b/src/lib.rs index 6fdd3e6..18267c0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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] @@ -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. @@ -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. @@ -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. @@ -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. @@ -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. @@ -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. @@ -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)); } @@ -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)); } @@ -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), @@ -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), @@ -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), @@ -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), @@ -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)); } @@ -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)); } @@ -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. diff --git a/src/pool_storage.rs b/src/pool_storage.rs new file mode 100644 index 0000000..f231bd2 --- /dev/null +++ b/src/pool_storage.rs @@ -0,0 +1,432 @@ +//! Centralized namespaced storage policy for pool/pair state. +//! +//! The router's original `DataKey` variants are part of the deployed layout +//! and therefore cannot be replaced during a routine maintenance release. +//! This module gives those variants one typed namespace and one TTL policy. +//! `key_for` deliberately maps to the existing variants, preserving their +//! XDR layout while preventing call sites from inventing ad-hoc keys. + +use crate::DataKey; +use soroban_sdk::{contracttype, storage::Persistent, Env, Symbol, Vec}; + +/// Persistent entries in a pair namespace. +#[contracttype] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PoolSlot { + /// Pair registration flag. + Registration, + /// Relative fee in basis points. + FeeBps, + /// Minimum accepted route amount. + MinAmount, + /// Maximum accepted route amount. + MaxAmount, + /// Reported available liquidity. + Liquidity, + /// Last successful route timestamp. + LastRouteAt, + /// Number of successful routes. + RouteCount, + /// Cumulative routed volume. + Volume, + /// Minimum interval between routes. + Cooldown, +} + +/// Typed pair namespace. Source and destination order is significant. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PoolNamespace { + pub source: Symbol, + pub destination: Symbol, +} + +/// A key descriptor useful for audits and layout tests. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PoolStorageKey { + pub namespace: PoolNamespace, + pub slot: PoolSlot, +} + +/// TTL values for all pair-scoped persistent entries. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PoolTtlPolicy { + /// Entries are extended when their remaining TTL reaches this threshold. + pub threshold: u32, + /// Entries are renewed to at least this many ledgers. + pub extend_to: u32, +} + +/// Roughly 30 days at five seconds per ledger. +pub const PAIR_TTL_THRESHOLD: u32 = 518_400; +/// Roughly 60 days at five seconds per ledger. +pub const PAIR_TTL_EXTEND_TO: u32 = 1_036_800; + +/// The one TTL policy used for pair configuration and metrics. +pub const PAIR_TTL_POLICY: PoolTtlPolicy = PoolTtlPolicy { + threshold: PAIR_TTL_THRESHOLD, + extend_to: PAIR_TTL_EXTEND_TO, +}; + +/// Number of pair-scoped slots in the current layout. +pub const POOL_SLOT_COUNT: u32 = 9; + +/// Construct an immutable namespace descriptor. +pub fn namespace(source: Symbol, destination: Symbol) -> PoolNamespace { + PoolNamespace { + source, + destination, + } +} + +/// Construct an audit descriptor for a pair slot. +pub fn descriptor(source: Symbol, destination: Symbol, slot: PoolSlot) -> PoolStorageKey { + PoolStorageKey { + namespace: namespace(source, destination), + slot, + } +} + +/// Map the typed descriptor to the deployed `DataKey` layout. +pub fn key_for(key: &PoolStorageKey) -> DataKey { + let source = key.namespace.source.clone(); + let destination = key.namespace.destination.clone(); + match key.slot { + PoolSlot::Registration => DataKey::Pair(source, destination), + PoolSlot::FeeBps => DataKey::PairFeeBps(source, destination), + PoolSlot::MinAmount => DataKey::PairMinAmount(source, destination), + PoolSlot::MaxAmount => DataKey::PairMaxAmount(source, destination), + PoolSlot::Liquidity => DataKey::PairLiquidity(source, destination), + PoolSlot::LastRouteAt => DataKey::PairLastRouteAt(source, destination), + PoolSlot::RouteCount => DataKey::PairRouteCount(source, destination), + PoolSlot::Volume => DataKey::PairVolume(source, destination), + PoolSlot::Cooldown => DataKey::PairCooldown(source, destination), + } +} + +/// Return every pair key in canonical layout order. +pub fn pair_descriptors(env: &Env, source: Symbol, destination: Symbol) -> Vec { + let mut result = Vec::new(env); + let slots = [ + PoolSlot::Registration, + PoolSlot::FeeBps, + PoolSlot::MinAmount, + PoolSlot::MaxAmount, + PoolSlot::Liquidity, + PoolSlot::LastRouteAt, + PoolSlot::RouteCount, + PoolSlot::Volume, + PoolSlot::Cooldown, + ]; + for slot in slots { + result.push_back(descriptor(source.clone(), destination.clone(), slot)); + } + result +} + +/// Return the canonical slot order used by layout snapshots and audits. +pub fn all_slots(env: &Env) -> Vec { + let mut result = Vec::new(env); + for slot in [ + PoolSlot::Registration, + PoolSlot::FeeBps, + PoolSlot::MinAmount, + PoolSlot::MaxAmount, + PoolSlot::Liquidity, + PoolSlot::LastRouteAt, + PoolSlot::RouteCount, + PoolSlot::Volume, + PoolSlot::Cooldown, + ] { + result.push_back(slot); + } + result +} + +/// Return the append-only ordinal assigned to a slot in the audit layout. +pub fn slot_index(slot: PoolSlot) -> u32 { + match slot { + PoolSlot::Registration => 0, + PoolSlot::FeeBps => 1, + PoolSlot::MinAmount => 2, + PoolSlot::MaxAmount => 3, + PoolSlot::Liquidity => 4, + PoolSlot::LastRouteAt => 5, + PoolSlot::RouteCount => 6, + PoolSlot::Volume => 7, + PoolSlot::Cooldown => 8, + } +} + +/// Extend every existing pair entry without creating missing storage slots. +/// +/// The `has` check is important: a read of an unregistered pair must remain a +/// read and must not create an orphan persistent entry. A later registration +/// writes the key and the next bump renews it under this same policy. +pub fn bump_pair_ttl(env: &Env, source: &Symbol, destination: &Symbol) { + let descriptors = pair_descriptors(env, source.clone(), destination.clone()); + let persistent = env.storage().persistent(); + for descriptor in descriptors.iter() { + let key = key_for(&descriptor); + bump_key_ttl_with_storage(&persistent, &key); + } +} + +/// Extend one existing slot. Hot call sites use this narrower helper to keep +/// batch registration gas bounded; read surfaces can refresh only the field +/// they touch, while a maintenance pass can call `bump_pair_ttl` for all. +pub fn bump_key_ttl(env: &Env, key: &DataKey) { + let persistent = env.storage().persistent(); + bump_key_ttl_with_storage(&persistent, key); +} + +fn bump_key_ttl_with_storage(storage: &Persistent, key: &DataKey) { + if storage.has(key) { + storage.extend_ttl(key, PAIR_TTL_POLICY.threshold, PAIR_TTL_POLICY.extend_to); + } +} + +/// Return a stable, human-readable slot label for off-chain diagnostics. +/// +/// Labels are not storage keys and must never be used as a substitute for +/// `key_for`; they exist to make audit logs and layout snapshots legible. +pub fn slot_label(slot: PoolSlot) -> &'static str { + match slot { + PoolSlot::Registration => "pair.registration", + PoolSlot::FeeBps => "pair.fee_bps", + PoolSlot::MinAmount => "pair.min_amount", + PoolSlot::MaxAmount => "pair.max_amount", + PoolSlot::Liquidity => "pair.liquidity", + PoolSlot::LastRouteAt => "pair.last_route_at", + PoolSlot::RouteCount => "pair.route_count", + PoolSlot::Volume => "pair.volume", + PoolSlot::Cooldown => "pair.cooldown", + } +} + +/// Return whether the slot contains operational history rather than config. +pub fn is_metric_slot(slot: PoolSlot) -> bool { + matches!( + slot, + PoolSlot::LastRouteAt | PoolSlot::RouteCount | PoolSlot::Volume + ) +} + +/// Return whether a slot is live configuration rather than history. +pub fn is_config_slot(slot: PoolSlot) -> bool { + !is_metric_slot(slot) +} + +/// Validate the invariants required before adding a slot to the layout. +pub fn layout_is_complete(env: &Env, source: Symbol, destination: Symbol) -> bool { + let slots = all_slots(env); + if slots.len() != POOL_SLOT_COUNT { + return false; + } + let descriptors = pair_descriptors(env, source, destination); + if descriptors.len() != POOL_SLOT_COUNT { + return false; + } + for slot in slots.iter() { + if slot_index(slot) >= POOL_SLOT_COUNT || slot_label(slot).is_empty() { + return false; + } + } + true +} + +/// Return whether the slot is cleared when a pair is unregistered. +pub fn is_cleared_on_unregister(slot: PoolSlot) -> bool { + matches!( + slot, + PoolSlot::Registration + | PoolSlot::FeeBps + | PoolSlot::MinAmount + | PoolSlot::MaxAmount + | PoolSlot::Liquidity + | PoolSlot::Cooldown + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::symbol_short; + + fn pair(slot: PoolSlot) -> PoolStorageKey { + descriptor(symbol_short!("USDC"), symbol_short!("EURC"), slot) + } + + #[test] + fn namespace_is_directional() { + let forward = namespace(symbol_short!("USDC"), symbol_short!("EURC")); + let reverse = namespace(symbol_short!("EURC"), symbol_short!("USDC")); + assert_ne!(forward, reverse); + } + + #[test] + fn every_slot_maps_to_a_pair_variant() { + assert_eq!( + key_for(&pair(PoolSlot::Registration)), + DataKey::Pair(symbol_short!("USDC"), symbol_short!("EURC")) + ); + assert_eq!( + key_for(&pair(PoolSlot::FeeBps)), + DataKey::PairFeeBps(symbol_short!("USDC"), symbol_short!("EURC")) + ); + assert_eq!( + key_for(&pair(PoolSlot::MinAmount)), + DataKey::PairMinAmount(symbol_short!("USDC"), symbol_short!("EURC")) + ); + assert_eq!( + key_for(&pair(PoolSlot::MaxAmount)), + DataKey::PairMaxAmount(symbol_short!("USDC"), symbol_short!("EURC")) + ); + assert_eq!( + key_for(&pair(PoolSlot::Liquidity)), + DataKey::PairLiquidity(symbol_short!("USDC"), symbol_short!("EURC")) + ); + assert_eq!( + key_for(&pair(PoolSlot::LastRouteAt)), + DataKey::PairLastRouteAt(symbol_short!("USDC"), symbol_short!("EURC")) + ); + assert_eq!( + key_for(&pair(PoolSlot::RouteCount)), + DataKey::PairRouteCount(symbol_short!("USDC"), symbol_short!("EURC")) + ); + assert_eq!( + key_for(&pair(PoolSlot::Volume)), + DataKey::PairVolume(symbol_short!("USDC"), symbol_short!("EURC")) + ); + assert_eq!( + key_for(&pair(PoolSlot::Cooldown)), + DataKey::PairCooldown(symbol_short!("USDC"), symbol_short!("EURC")) + ); + } + + #[test] + fn labels_are_unique_and_stable() { + let labels = [ + slot_label(PoolSlot::Registration), + slot_label(PoolSlot::FeeBps), + slot_label(PoolSlot::MinAmount), + slot_label(PoolSlot::MaxAmount), + slot_label(PoolSlot::Liquidity), + slot_label(PoolSlot::LastRouteAt), + slot_label(PoolSlot::RouteCount), + slot_label(PoolSlot::Volume), + slot_label(PoolSlot::Cooldown), + ]; + for (index, label) in labels.iter().enumerate() { + assert!(!label.is_empty()); + assert!(!labels[index + 1..].contains(label)); + } + } + + #[test] + fn metric_slots_are_not_cleared_by_normal_unregister() { + assert!(!is_cleared_on_unregister(PoolSlot::LastRouteAt)); + assert!(!is_cleared_on_unregister(PoolSlot::RouteCount)); + assert!(!is_cleared_on_unregister(PoolSlot::Volume)); + assert!(is_metric_slot(PoolSlot::Volume)); + } + + #[test] + fn config_slots_are_cleared() { + assert!(is_cleared_on_unregister(PoolSlot::Registration)); + assert!(is_cleared_on_unregister(PoolSlot::FeeBps)); + assert!(is_cleared_on_unregister(PoolSlot::Cooldown)); + assert!(!is_metric_slot(PoolSlot::Cooldown)); + } + + #[test] + fn ttl_policy_has_a_safe_renewal_window() { + assert!(PAIR_TTL_POLICY.threshold > 0); + assert!(PAIR_TTL_POLICY.extend_to > PAIR_TTL_POLICY.threshold); + } + + #[test] + fn labels_identify_the_storage_namespace_without_being_keys() { + let descriptor = pair(PoolSlot::FeeBps); + assert_eq!(slot_label(descriptor.slot), "pair.fee_bps"); + assert_eq!(descriptor.namespace.source, symbol_short!("USDC")); + assert_eq!(descriptor.namespace.destination, symbol_short!("EURC")); + } + + #[test] + fn canonical_slot_order_is_append_only() { + let env = Env::default(); + let slots = all_slots(&env); + assert_eq!(slots.len(), POOL_SLOT_COUNT); + for (index, slot) in slots.iter().enumerate() { + assert_eq!(slot_index(slot), index as u32); + } + } + + #[test] + fn each_descriptor_has_the_same_namespace() { + let env = Env::default(); + let descriptors = pair_descriptors(&env, symbol_short!("USDC"), symbol_short!("EURC")); + assert_eq!(descriptors.len(), POOL_SLOT_COUNT); + for descriptor in descriptors.iter() { + assert_eq!(descriptor.namespace.source, symbol_short!("USDC")); + assert_eq!(descriptor.namespace.destination, symbol_short!("EURC")); + } + } + + #[test] + fn layout_completeness_rejects_no_current_slots() { + let env = Env::default(); + assert!(layout_is_complete( + &env, + symbol_short!("USDC"), + symbol_short!("EURC") + )); + } + + #[test] + fn config_and_metric_partitions_cover_the_layout() { + let env = Env::default(); + let mut config = 0; + let mut metrics = 0; + for slot in all_slots(&env).iter() { + if is_config_slot(slot) { + config += 1; + } + if is_metric_slot(slot) { + metrics += 1; + } + } + assert_eq!(config + metrics, POOL_SLOT_COUNT); + assert_eq!(metrics, 3); + assert_eq!(config, 6); + } + + #[test] + fn unregister_partition_matches_documented_history_policy() { + let env = Env::default(); + for slot in all_slots(&env).iter() { + assert_eq!(is_metric_slot(slot), !is_cleared_on_unregister(slot)); + } + } + + #[test] + fn all_slot_labels_are_namespaced() { + let env = Env::default(); + for slot in all_slots(&env).iter() { + assert!(slot_label(slot).starts_with("pair.")); + } + } + + #[test] + fn slot_ordinals_have_no_gaps() { + let env = Env::default(); + let mut expected = 0; + for slot in all_slots(&env).iter() { + assert_eq!(slot_index(slot), expected); + expected += 1; + } + assert_eq!(expected, POOL_SLOT_COUNT); + } +}