diff --git a/contracts/validators/docs/storage.md b/contracts/validators/docs/storage.md new file mode 100644 index 00000000..4a032885 --- /dev/null +++ b/contracts/validators/docs/storage.md @@ -0,0 +1,222 @@ +# Validators Contract — Storage Keys by Tier + +`callora-validators` is a stateless pure-validation library consumed by other +Callora contracts. It does **not** own a deployed contract instance on its +own. Nevertheless, the crate defines three storage keys (all in the +**Instance** tier) for the migration and upgrade infrastructure used by any +parent contract that embeds the validators module. + +This page documents every storage key, the Soroban tier it lives in, the +reason it was assigned to that tier, the TTL strategy that keeps it alive, +and — for the **Persistent** and **Temporary** tiers — why the crate +intentionally defines no keys there. + +--- + +## Soroban Storage Tier Overview + +| Tier | Location inside the ledger | Typical use case | TTL behaviour | +|------------|--------------------------------------------|----------------------------------------------------------|--------------------------------------------------------------------------| +| Instance | Embedded in the contract instance entry | Singleton configuration, small admin / version metadata | Shares the instance entry TTL; `extend_ttl` on one key bumps **all** instance keys together. | +| Persistent | Stand-alone ledger entry per key | Per-user balances, high-cardinality mappings, BLOBs | Each key owns its own TTL. Pruned independently when the TTL expires. | +| Temporary | Scratchpad; never written to the ledger | Intra-txn caches, reentrancy guards, short-lived flags | **Cleared at ledger close.** Never survives to the next transaction. | + +--- + +## Instance Tier + +Every storage key used by `callora-validators` resides in the **Instance** +tier (`env.storage().instance()`). The keys are all singletons — at most +one value per key for the contract's lifetime. + +### 1. `Admin` + +| Field | Value | +|----------------|-----------------------------------------------------| +| **Tier** | Instance | +| **Data type** | `Address` | +| **Written by** | Embedding contract's initialisation flow | +| **Read by** | `require_admin()` → `migrate_v1_to_v2`, `authorize_upgrade` | +| **Cardinality**| 1 per contract (singleton admin) | + +**Rationale — Instance:** +`Admin` is a singleton configuration value consulted on every admin-gated +entrypoint. Using Instance storage guarantees (a) it shares the contract +instance entry's TTL bump so the admin key cannot be independently pruned, +and (b) reads are zero-cost compared to a dedicated Persistent ledger entry. +A Persistent-tier admin key could be silently evicted when its separate TTL +expired, bricking every admin-gated function until the key was manually +re-sent. Temporary is inapplicable because the admin address must survive +across ledger closes. + +### 2. `StorageVersion` + +| Field | Value | +|----------------|-----------------------------------------------------| +| **Tier** | Instance | +| **Data type** | `u32` (default = `1` when key is absent) | +| **Written by** | `migrate_v1_to_v2()` (writes `2` after idempotency check) | +| **Read by** | `storage_version()` (bumps TTL, then reads), `migrate_v1_to_v2`, `authorize_upgrade` | +| **Cardinality**| 1 per contract | + +**Rationale — Instance:** +The version marker participates in the same always-hot lifecycle as the admin +key; every migration or upgrade entrypoint reads it first. Persistent +storage would force deployers to TTL-bump a **second** ledger entry on every +admin operation, with a failure mode where admin was alive but version was +independently evicted (→ V1 default → migrations could re-run accidentally). +Temporary is inappropriate because a version marker must persist across +ledger closes to keep migrations idempotent. + +### 3. `AuthorisedUpgrade` + +| Field | Value | +|----------------|-----------------------------------------------------| +| **Tier** | Instance | +| **Data type** | `(u32, BytesN<32>)` — `(target_version, wasm_hash)` | +| **Written by** | `authorize_upgrade()` | +| **Read by** | Deployment tool (out-of-band) before WASM upgrade | +| **Cardinality**| 1 per contract; overwritten on each authorisation | + +**Rationale — Instance:** +`AuthorisedUpgrade` is a short-lived deployment guard written and consumed +within a single deployment cycle. It piggybacks on the Instance TTL bump +from `storage_version()` and never needs the dedicated ledger-entry +footprint that high-cardinality per-user data would warrant. Persistent +storage would cost an extra write + separate TTL management for no benefit. +Temporary is inapplicable because the authorisation must survive until the +deployment tool executes the platform upgrade in a later transaction. + +--- + +## Persistent Tier + +**No Persistent-tier storage keys are defined in `callora-validators`.** + +### What WOULD live in Persistent tier (for reference) + +Persistent storage is the right choice when: + +- You have **per-user records** (e.g. validator stakes, delegated balances, + one slot per user-address) — cardinalities of thousands or more. +- The data needs to **survive independently** of whether the contract + instance entry is touched (e.g. archival records that are only + infrequently read by admin-only queries). +- You have **bulk BLOBs** or large byte arrays that exceed the size budget + for the contract instance ledger entry. + +### Why the validators crate uses none of this + +The crate is a **pure validator library**: it exposes `checked_add_amount`, +`require_capability`, boolean predicates, and the migration/upgrade guards. +It has no notion of user accounts, no stakes, no balances, and no archival +records. All three existing keys are singletons whose combined size fits +comfortably inside the Instance entry. + +> **Audit guard:** If a future PR introduces per-validator or per-user +> state in `callora-validators`, that key MUST be assigned to the +> **Persistent** tier and documented here — adding it as a new `StorageKey` +> enum variant will fail the `storagekey_enum_has_exactly_three_variants` +> test until both the enum and this doc are updated. + +--- + +## Temporary Tier + +**No Temporary-tier storage keys are defined in `callora-validators`.** + +### What WOULD live in Temporary tier (for reference) + +Temporary storage is the right choice when: + +- You need a **reentrancy guard** flag (locked during a cross-contract call, + cleared at ledger-close even if the call panics partway through). +- You need an **intra-transaction cache** — e.g. memoising an expensive + lookup that will be consulted multiple times in the same call. +- You need **per-call scratch values** that should never be observable to + the next transaction. + +### Why the validators crate uses none of this + +All public functions are either (a) pure validators (no storage reads at all +— `checked_add_amount`, `require_capability`) or (b) short admin flows that +never re-enter the contract and perform no cross-contract calls. There is +no reentrancy surface to guard and no repeated lookups worth caching. + +> **Audit guard:** Adding a future cross-contract call to a migration path +> typically warrants a Temporary-tier reentrancy guard; document it here if +> that pattern is introduced. + +--- + +## Full Key Inventory + +| # | Key | Tier | Data type | Written by | Read by | Cardinality | +|----|----------------------|------------|--------------------------|-------------------------|---------------------------|-------------| +| 1 | `Admin` | Instance | `Address` | init (embedder) | `require_admin` | 1 | +| 2 | `StorageVersion` | Instance | `u32` | `migrate_v1_to_v2` | `storage_version` | 1 | +| 3 | `AuthorisedUpgrade` | Instance | `(u32, BytesN<32>)` | `authorize_upgrade` | deployment tool (out-of-band) | 1 | +| — | *(no Persistent keys)* | Persistent | — | — | — | 0 | +| — | *(no Temporary keys)* | Temporary | — | — | — | 0 | + +--- + +## TTL Management Strategy + +Soroban Instance-tier storage shares **one TTL across every key in the +contract instance entry**. The bump in `storage_version()` therefore +protects `Admin`, `StorageVersion`, and `AuthorisedUpgrade` simultaneously. + +| Key | Bump site | Threshold (ledgers) | Amount (ledgers) | Effective window | +|---------------------|----------------------------|---------------------|------------------|------------------| +| `Admin` | `storage_version()` (auto) | 17 280 × 30 | 17 280 × 60 | ~30 days / ~60 days | +| `StorageVersion` | `storage_version()` (auto) | 17 280 × 30 | 17 280 × 60 | ~30 days / ~60 days | +| `AuthorisedUpgrade` | `storage_version()` (auto) | 17 280 × 30 | 17 280 × 60 | ~30 days / ~60 days | + +- **2:1 bump ratio**: `amount` is double `threshold`. Any contract that is + touched through an admin or upgrade path at least once within the + 30-day threshold window will keep all Instance keys alive indefinitely + without manual TTL upkeep. +- **Idempotent reads**: `storage_version()` does not write anything when + the key is absent; it safely returns the V1 default. + +--- + +## Design Rationale — Why All-Instance? + +Three reasons `callora-validators` deliberately avoids Persistent and +Temporary storage entirely: + +1. **Cardinality = singletons.** All three keys are one-value-per-contract. + Instance storage exists for exactly this case. Persistent keys pay an + extra write cost and require independent TTL management for every key + with no upside here. + +2. **Access frequency.** Every admin or upgrade path reads all three keys + in the same transaction. Instance storage co-locates them, yielding a + single ledger fetch and a single TTL bump. + +3. **Survival dependence.** `Admin` and `StorageVersion` must never be + independently evictable. If `Admin` used Persistent storage and was + pruned while the instance entry was still alive, every admin-gated + function would panic with "admin not set" until an admin manually + re-sent the key. Instance storage ties their fates together. + +--- + +## Security Notes + +1. **Admin must be set before migration.** Calling `migrate_v1_to_v2` or + `authorize_upgrade` before the embedding contract writes `StorageKey::Admin` + panics with `"require_admin: admin not set"`. This is the expected + initialisation ordering: init admin → then migrate. + +2. **`StorageVersion` default semantics.** Absence of the key is + semantically identical to `STORAGE_VERSION_V1`. Code paths must not + treat "absent" differently from "= 1". The production function + `storage_version()` enforces this via `.unwrap_or(STORAGE_VERSION_V1)`. + +3. **`AuthorisedUpgrade` is a guard only.** Writing to this key does NOT + perform the actual WASM upgrade. A separate deployment transaction must + run `env.update_current_contract_wasm(wasm_hash)`; the guard can be + verified in that transaction's preconditions. diff --git a/contracts/validators/src/migrate.rs b/contracts/validators/src/migrate.rs index 31b948a4..1e41ec8e 100644 --- a/contracts/validators/src/migrate.rs +++ b/contracts/validators/src/migrate.rs @@ -10,10 +10,16 @@ //! //! ## Storage layout //! +//! All keys below use **Instance** tier storage (`env.storage().instance()`). +//! //! | Key | Added by | Contents | //! |-----|----------|----------| -//! | `StorageVersion` | this module | `u32` — `2` after migration | -//! | `Admin` | this module | `Address` — admin who can call `migrate` | +//! | `Admin` | this module | `Address` — admin who can call `migrate_v1_to_v2` and `authorize_upgrade` | +//! | `StorageVersion` | this module | `u32` — `2` after V1→V2 migration | +//! | `AuthorisedUpgrade` | this module | `(u32, BytesN<32>)` — (version, wasm_hash) authorisation guard | +//! +//! See `docs/storage.md` for the full tier rationale, TTL strategy, and +//! per-key design notes. //! //! ## Security //! @@ -24,9 +30,23 @@ use soroban_sdk::{contracttype, Address, BytesN, Env, Symbol}; -/// Ledger-bump threshold for instance storage (~30 days at 5s/ledger). +/// Minimum remaining TTL (in ledgers) before the instance storage entry is +/// bumped when [`storage_version`] is read. +/// +/// Set to ~30 days at 5 s/ledger (`17_280 ledgers/day × 30 days`). If the +/// contract instance has fewer ledgers of life remaining than this threshold +/// the bump in [`storage_version`] will extend it. Because every migration +/// and upgrade path calls [`storage_version`], this guarantees the instance +/// storage — including the `Admin` and `StorageVersion` keys — cannot be +/// pruned while the contract is actively used. pub const INSTANCE_BUMP_THRESHOLD: u32 = 17_280 * 30; -/// Ledger-bump amount for instance storage (~60 days). +/// TTL extension amount (in ledgers) applied to the instance storage entry +/// when the remaining TTL drops below [`INSTANCE_BUMP_THRESHOLD`]. +/// +/// Set to ~60 days at 5 s/ledger (`17_280 ledgers/day × 60 days`). The 2× +/// multiplier over the threshold means a contract that is touched at least +/// once per 30-day window will keep its instance storage alive indefinitely +/// without requiring explicit TTL management from the admin. pub const INSTANCE_BUMP_AMOUNT: u32 = 17_280 * 60; /// Storage-layout version before migration (absent / no version tracking). @@ -34,25 +54,88 @@ pub const STORAGE_VERSION_V1: u32 = 1; /// Storage-layout version set after the V1 → V2 migration completes. pub const STORAGE_VERSION_V2: u32 = 2; -/// Instance storage keys. +/// Storage keys for the validators migration and upgrade infrastructure. +/// +/// **All keys use the Instance storage tier** (`env.storage().instance()`). +/// Every variant below is a singleton — at most one value exists per key for +/// the contract's lifetime. Instance storage is chosen over Persistent or +/// Temporary because: +/// +/// - The keys fit comfortably inside the contract instance ledger entry. +/// - `Admin` and `StorageVersion` are read on every admin / upgrade path and +/// must never be independently evictable. +/// - No per-user or bulk data warrants a separate Persistent ledger entry. +/// - No transient scratchpad or reentrancy-guard behaviour requires Temporary +/// storage. +/// +/// See `docs/storage.md` for the full tier rationale and TTL strategy. #[contracttype] #[derive(Clone, Debug, PartialEq)] pub enum StorageKey { - /// Admin address authorised to run migration. + /// Admin address authorised to call [`migrate_v1_to_v2`] and + /// [`authorize_upgrade`]. Stored as `Address`. + /// + /// Must be written by the initialisation routine of whatever contract + /// embeds the validators library; calling admin-gated functions before + /// this key is set will panic with `"require_admin: admin not set"`. + /// + /// **Instance-tier rationale**: Admin is a singleton configuration value + /// consulted on every admin / upgrade entrypoint. Using Instance storage + /// guarantees TTL-bumped survival alongside the contract instance; a + /// Persistent-tier admin key could be pruned independently and silently + /// brick all admin-gated functions. Admin, - /// Storage-layout version marker (`u32`). + /// Storage-layout version marker. Stored as `u32`. + /// + /// Absence of this key is semantically equivalent to + /// [`STORAGE_VERSION_V1`]. `migrate_*` functions compare this value to + /// decide whether a migration needs to run, ensuring idempotency across + /// repeated invocations. + /// + /// **Instance-tier rationale**: The version marker participates in the + /// same "always-hot" lifecycle as the admin key — any migration or + /// upgrade flow reads it first. Persistent storage would force callers + /// to manage a second ledger entry's TTL independently. Temporary + /// storage is inappropriate because a version marker must survive across + /// ledger closes. StorageVersion, - /// Authorised upgrade WASM hash (tuple of `(u32, BytesN<32>)`). + /// Authorised upgrade WASM hash paired with the target storage version. + /// Stored as a 2-tuple `(u32, BytesN<32>)` where the first element is the + /// expected storage version and the second is the pre-authorised WASM + /// deployment hash. + /// + /// Written by [`authorize_upgrade`] and consumed (out-of-band) by the + /// deployment tool that actually performs the platform-level + /// `update_current_contract_wasm` call. + /// + /// **Instance-tier rationale**: AuthorisedUpgrade is a short-lived guard + /// written and consumed within a single deployment cycle. It piggybacks + /// on the instance TTL bump from [`storage_version`] and, being a + /// singleton, never needs the dedicated Peristent ledger entry footprint + /// that high-cardinality per-user data would require. AuthorisedUpgrade, } // ─── Public query ───────────────────────────────────────────────────────────── -/// Return the current storage-layout version. +/// Return the current storage-layout version from Instance storage. +/// +/// # TTL side-effect +/// +/// Before reading, this function bumps the contract instance storage entry's +/// TTL using [`INSTANCE_BUMP_THRESHOLD`] and [`INSTANCE_BUMP_AMOUNT`]. Since +/// every migration and upgrade path consults the storage version first, this +/// keeps `Admin`, `StorageVersion`, and `AuthorisedUpgrade` alive as long as +/// the contract is actively used, without the admin needing to perform +/// explicit TTL maintenance. /// -/// Returns [`STORAGE_VERSION_V1`] when the `StorageVersion` key is absent -/// (pre-migration). Returns [`STORAGE_VERSION_V2`] once [`migrate_v1_to_v2`] -/// has completed. +/// # Return value +/// +/// - [`STORAGE_VERSION_V1`] when the `StorageVersion` key is absent +/// (pre-migration state — no migrations have ever been applied). +/// - [`STORAGE_VERSION_V2`] once [`migrate_v1_to_v2`] has completed +/// successfully. +/// - Future schema bumps will return values ≥ 3. pub fn storage_version(env: &Env) -> u32 { env.storage() .instance() @@ -176,7 +259,6 @@ mod tests { use super::*; use soroban_sdk::testutils::Address as _; - /// Verify the module-level constants have the expected values. #[test] fn constants_have_expected_values() { assert_eq!(STORAGE_VERSION_V1, 1); @@ -188,15 +270,10 @@ mod tests { let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); - env.storage().instance().set(&StorageKey::Admin, &admin); - assert_eq!(storage_version(&env), STORAGE_VERSION_V1); - migrate_v1_to_v2(&env, &admin); - assert_eq!(storage_version(&env), STORAGE_VERSION_V2); - migrate_v1_to_v2(&env, &admin); assert_eq!(storage_version(&env), STORAGE_VERSION_V2); } @@ -208,9 +285,7 @@ mod tests { env.mock_all_auths(); let admin = Address::generate(&env); let imposter = Address::generate(&env); - env.storage().instance().set(&StorageKey::Admin, &admin); - migrate_v1_to_v2(&env, &imposter); } @@ -219,13 +294,14 @@ mod tests { let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); - env.storage().instance().set(&StorageKey::Admin, &admin); - let hash = BytesN::from_array(&env, &[1u8; 32]); authorize_upgrade(&env, &admin, STORAGE_VERSION_V1, hash.clone()); - - let stored: (u32, BytesN<32>) = env.storage().instance().get(&StorageKey::AuthorisedUpgrade).unwrap(); + let stored: (u32, BytesN<32>) = env + .storage() + .instance() + .get(&StorageKey::AuthorisedUpgrade) + .unwrap(); assert_eq!(stored.0, STORAGE_VERSION_V1); assert_eq!(stored.1, hash); } @@ -236,10 +312,443 @@ mod tests { let env = Env::default(); env.mock_all_auths(); let admin = Address::generate(&env); - env.storage().instance().set(&StorageKey::Admin, &admin); - let hash = BytesN::from_array(&env, &[1u8; 32]); authorize_upgrade(&env, &admin, STORAGE_VERSION_V2, hash.clone()); } + + #[test] + fn instance_storage_admin_round_trip() { + let env = Env::default(); + let admin = Address::generate(&env); + let other = Address::generate(&env); + assert!(env + .storage() + .instance() + .get::<_, Address>(&StorageKey::Admin) + .is_none()); + env.storage().instance().set(&StorageKey::Admin, &admin); + let stored: Address = env + .storage() + .instance() + .get(&StorageKey::Admin) + .expect("Admin key should be present after set"); + assert_eq!(stored, admin); + assert_ne!(stored, other); + env.storage().instance().remove(&StorageKey::Admin); + assert!(env + .storage() + .instance() + .get::<_, Address>(&StorageKey::Admin) + .is_none()); + } + + #[test] + fn instance_storage_version_round_trip() { + let env = Env::default(); + assert!(env + .storage() + .instance() + .get::<_, u32>(&StorageKey::StorageVersion) + .is_none()); + env.storage() + .instance() + .set(&StorageKey::StorageVersion, &STORAGE_VERSION_V2); + let stored: u32 = env + .storage() + .instance() + .get(&StorageKey::StorageVersion) + .expect("StorageVersion key should be present after set"); + assert_eq!(stored, STORAGE_VERSION_V2); + env.storage() + .instance() + .set(&StorageKey::StorageVersion, &99u32); + let overwritten: u32 = env + .storage() + .instance() + .get(&StorageKey::StorageVersion) + .unwrap(); + assert_eq!(overwritten, 99); + } + + #[test] + fn instance_storage_authorised_upgrade_round_trip() { + let env = Env::default(); + assert!(env + .storage() + .instance() + .get::<_, (u32, BytesN<32>)>(&StorageKey::AuthorisedUpgrade) + .is_none()); + let version = 7u32; + let hash = BytesN::from_array(&env, &[0xABu8; 32]); + env.storage() + .instance() + .set(&StorageKey::AuthorisedUpgrade, &(version, hash.clone())); + let stored: (u32, BytesN<32>) = env + .storage() + .instance() + .get(&StorageKey::AuthorisedUpgrade) + .expect("AuthorisedUpgrade key should be present after set"); + assert_eq!(stored.0, version); + assert_eq!(stored.1, hash); + let other_hash = BytesN::from_array(&env, &[0xCDu8; 32]); + env.storage() + .instance() + .set(&StorageKey::AuthorisedUpgrade, &(version, other_hash.clone())); + let updated: (u32, BytesN<32>) = env + .storage() + .instance() + .get(&StorageKey::AuthorisedUpgrade) + .unwrap(); + assert_eq!(updated.1, other_hash); + assert_ne!(updated.1, hash); + } + + #[test] + fn storage_version_defaults_to_v1_when_absent() { + let env = Env::default(); + assert_eq!(storage_version(&env), STORAGE_VERSION_V1); + } + + #[test] + fn storage_version_returns_written_v2() { + let env = Env::default(); + env.storage() + .instance() + .set(&StorageKey::StorageVersion, &STORAGE_VERSION_V2); + assert_eq!(storage_version(&env), STORAGE_VERSION_V2); + } + + #[test] + #[should_panic(expected = "require_admin: admin not set")] + fn require_admin_panics_when_admin_absent() { + let env = Env::default(); + let caller = Address::generate(&env); + super::require_admin(&env, &caller); + } + + #[test] + fn require_admin_accepts_matching_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + env.storage().instance().set(&StorageKey::Admin, &admin); + super::require_admin(&env, &admin); + } + + #[test] + #[should_panic(expected = "require_admin: caller is not the admin")] + fn require_admin_rejects_non_admin() { + let env = Env::default(); + let admin = Address::generate(&env); + let imposter = Address::generate(&env); + env.storage().instance().set(&StorageKey::Admin, &admin); + super::require_admin(&env, &imposter); + } + + #[test] + fn migrate_v1_to_v2_emits_event() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + env.storage().instance().set(&StorageKey::Admin, &admin); + migrate_v1_to_v2(&env, &admin); + let events = env.events().all(); + assert_eq!(events.len(), 1); + let (topic, payload): ((Symbol,), u32) = + events.into_iter().next().unwrap().tuple(); + assert_eq!(topic.0, Symbol::new(&env, "mig_v1_v2_done")); + assert_eq!(payload, STORAGE_VERSION_V2); + } + + #[test] + fn migrate_v1_to_v2_is_idempotent_no_extra_events() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + env.storage().instance().set(&StorageKey::Admin, &admin); + migrate_v1_to_v2(&env, &admin); + migrate_v1_to_v2(&env, &admin); + migrate_v1_to_v2(&env, &admin); + assert_eq!(env.events().all().len(), 1); + assert_eq!(storage_version(&env), STORAGE_VERSION_V2); + } + + #[test] + fn authorize_upgrade_emits_event() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + env.storage().instance().set(&StorageKey::Admin, &admin); + let hash = BytesN::from_array(&env, &[0x42u8; 32]); + authorize_upgrade(&env, &admin, STORAGE_VERSION_V1, hash.clone()); + let events = env.events().all(); + assert_eq!(events.len(), 1); + let (topic, payload): ((Symbol, BytesN<32>), u32) = + events.into_iter().next().unwrap().tuple(); + assert_eq!(topic.0, Symbol::new(&env, "upg_authorised")); + assert_eq!(topic.1, hash); + assert_eq!(payload, STORAGE_VERSION_V1); + } + + #[test] + fn ttl_constants_match_documentation() { + let ledgers_per_day = 17_280u32; + assert_eq!(INSTANCE_BUMP_THRESHOLD, ledgers_per_day * 30); + assert_eq!(INSTANCE_BUMP_AMOUNT, ledgers_per_day * 60); + assert!(INSTANCE_BUMP_AMOUNT > INSTANCE_BUMP_THRESHOLD); + } + + #[test] + fn storage_keys_do_not_collide_in_instance() { + let env = Env::default(); + let admin = Address::generate(&env); + let hash = BytesN::from_array(&env, &[0xEEu8; 32]); + env.storage().instance().set(&StorageKey::Admin, &admin); + env.storage() + .instance() + .set(&StorageKey::StorageVersion, &STORAGE_VERSION_V2); + env.storage() + .instance() + .set(&StorageKey::AuthorisedUpgrade, &(STORAGE_VERSION_V2, hash.clone())); + let stored_admin: Address = env.storage().instance().get(&StorageKey::Admin).unwrap(); + let stored_ver: u32 = env + .storage() + .instance() + .get(&StorageKey::StorageVersion) + .unwrap(); + let stored_upg: (u32, BytesN<32>) = env + .storage() + .instance() + .get(&StorageKey::AuthorisedUpgrade) + .unwrap(); + assert_eq!(stored_admin, admin); + assert_eq!(stored_ver, STORAGE_VERSION_V2); + assert_eq!(stored_upg.0, STORAGE_VERSION_V2); + assert_eq!(stored_upg.1, hash); + } + + #[test] + fn storage_version_handles_future_versions() { + let env = Env::default(); + env.storage() + .instance() + .set(&StorageKey::StorageVersion, &42u32); + assert_eq!(storage_version(&env), 42); + assert!(storage_version(&env) > STORAGE_VERSION_V2); + } + + #[test] + fn migrate_skips_when_version_already_higher() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + env.storage().instance().set(&StorageKey::Admin, &admin); + env.storage() + .instance() + .set(&StorageKey::StorageVersion, &99u32); + migrate_v1_to_v2(&env, &admin); + let stored: u32 = env + .storage() + .instance() + .get(&StorageKey::StorageVersion) + .unwrap(); + assert_eq!(stored, 99); + assert_eq!(env.events().all().len(), 0); + } + + // ─── Tier segregation tests ────────────────────────────────────────────── + + #[test] + fn tier_segregation_migrate_writes_instance_only() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + env.storage().instance().set(&StorageKey::Admin, &admin); + migrate_v1_to_v2(&env, &admin); + assert_eq!( + env.storage() + .instance() + .get::<_, u32>(&StorageKey::StorageVersion) + .unwrap(), + STORAGE_VERSION_V2 + ); + assert!(env + .storage() + .persistent() + .get::<_, u32>(&StorageKey::StorageVersion) + .is_none()); + assert!(env + .storage() + .temporary() + .get::<_, u32>(&StorageKey::StorageVersion) + .is_none()); + } + + #[test] + fn tier_segregation_authorize_upgrade_writes_instance_only() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + env.storage().instance().set(&StorageKey::Admin, &admin); + let hash = BytesN::from_array(&env, &[0x55u8; 32]); + authorize_upgrade(&env, &admin, STORAGE_VERSION_V1, hash.clone()); + let stored: (u32, BytesN<32>) = env + .storage() + .instance() + .get(&StorageKey::AuthorisedUpgrade) + .unwrap(); + assert_eq!(stored.0, STORAGE_VERSION_V1); + assert_eq!(stored.1, hash); + assert!(env + .storage() + .persistent() + .get::<_, (u32, BytesN<32>)>(&StorageKey::AuthorisedUpgrade) + .is_none()); + assert!(env + .storage() + .temporary() + .get::<_, (u32, BytesN<32>)>(&StorageKey::AuthorisedUpgrade) + .is_none()); + } + + #[test] + fn tier_segregation_admin_reads_from_instance_only() { + let env = Env::default(); + let admin = Address::generate(&env); + let fake_admin = Address::generate(&env); + env.storage().instance().set(&StorageKey::Admin, &admin); + env.storage().persistent().set(&StorageKey::Admin, &fake_admin); + env.storage().temporary().set(&StorageKey::Admin, &fake_admin); + super::require_admin(&env, &admin); + } + + #[test] + fn tier_segregation_storage_version_reads_from_instance_only() { + let env = Env::default(); + env.storage() + .instance() + .set(&StorageKey::StorageVersion, &STORAGE_VERSION_V2); + env.storage() + .persistent() + .set(&StorageKey::StorageVersion, &999u32); + env.storage() + .temporary() + .set(&StorageKey::StorageVersion, &888u32); + assert_eq!(storage_version(&env), STORAGE_VERSION_V2); + } + + #[test] + fn persistent_tier_can_store_storagekey_discriminants_but_is_unused() { + let env = Env::default(); + let admin = Address::generate(&env); + env.storage().persistent().set(&StorageKey::Admin, &admin); + assert_eq!( + env.storage() + .persistent() + .get::<_, Address>(&StorageKey::Admin) + .unwrap(), + admin + ); + assert!(env + .storage() + .instance() + .get::<_, Address>(&StorageKey::Admin) + .is_none()); + } + + #[test] + fn temporary_tier_can_store_storagekey_discriminants_but_is_unused() { + let env = Env::default(); + env.storage() + .temporary() + .set(&StorageKey::StorageVersion, &42u32); + assert_eq!( + env.storage() + .temporary() + .get::<_, u32>(&StorageKey::StorageVersion) + .unwrap(), + 42 + ); + assert_eq!(storage_version(&env), STORAGE_VERSION_V1); + } + + // ─── TTL bump path coverage ────────────────────────────────────────────── + + #[test] + fn storage_version_repeated_calls_are_idempotent() { + let env = Env::default(); + env.storage() + .instance() + .set(&StorageKey::StorageVersion, &STORAGE_VERSION_V2); + for _ in 0..10 { + assert_eq!(storage_version(&env), STORAGE_VERSION_V2); + } + } + + #[test] + fn storage_version_repeated_calls_with_absent_key_returns_v1() { + let env = Env::default(); + for _ in 0..5 { + assert_eq!(storage_version(&env), STORAGE_VERSION_V1); + } + assert!(env + .storage() + .instance() + .get::<_, u32>(&StorageKey::StorageVersion) + .is_none()); + } + + // ─── AuthorisedUpgrade overwrite coverage ──────────────────────────────── + + #[test] + fn authorize_upgrade_overwrites_previous_authorisation() { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + env.storage().instance().set(&StorageKey::Admin, &admin); + let hash_v1 = BytesN::from_array(&env, &[0x11u8; 32]); + authorize_upgrade(&env, &admin, STORAGE_VERSION_V1, hash_v1.clone()); + migrate_v1_to_v2(&env, &admin); + let hash_v2 = BytesN::from_array(&env, &[0x22u8; 32]); + authorize_upgrade(&env, &admin, STORAGE_VERSION_V2, hash_v2.clone()); + let stored: (u32, BytesN<32>) = env + .storage() + .instance() + .get(&StorageKey::AuthorisedUpgrade) + .unwrap(); + assert_eq!(stored.0, STORAGE_VERSION_V2); + assert_eq!(stored.1, hash_v2); + assert_ne!(stored.1, hash_v1); + } + + // ─── StorageKey enum exhaustiveness ────────────────────────────────────── + + #[test] + fn storagekey_enum_has_exactly_three_variants() { + let env = Env::default(); + let address = Address::generate(&env); + let hash = BytesN::from_array(&env, &[0u8; 32]); + let keys = [ + StorageKey::Admin, + StorageKey::StorageVersion, + StorageKey::AuthorisedUpgrade, + ]; + assert_eq!(keys.len(), 3); + env.storage().instance().set(&keys[0], &address); + env.storage().instance().set(&keys[1], &7u32); + env.storage().instance().set(&keys[2], &(1u32, hash)); + for k in keys.iter() { + match k { + StorageKey::Admin => { + let _: Address = env.storage().instance().get(k).unwrap(); + } + StorageKey::StorageVersion => { + let _: u32 = env.storage().instance().get(k).unwrap(); + } + StorageKey::AuthorisedUpgrade => { + let _: (u32, BytesN<32>) = env.storage().instance().get(k).unwrap(); + } + } + } + } }