From 6a99f7affdaf0c7700114973cba27456906c011c Mon Sep 17 00:00:00 2001 From: "Ikenga Ifeanyi .M." Date: Mon, 1 Jun 2026 04:13:10 +0100 Subject: [PATCH] feat: enforce storage layout version on entry to state-changing methods --- src/lib.rs | 57 +++++++++++++++++++++++++++++ src/test_storage_layout_version.rs | 59 ++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/test_storage_layout_version.rs diff --git a/src/lib.rs b/src/lib.rs index a4eceed9..84508355 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -268,6 +268,7 @@ pub struct Proposal { const EVENT_SNAP_CONFIG: Symbol = symbol_short!("snap_cfg"); const EVENT_INIT: Symbol = symbol_short!("init"); +const EVENT_LAYOUT_VERSION: Symbol = symbol_short!("layout_v"); const EVENT_PAUSED: Symbol = symbol_short!("paused"); const EVENT_UNPAUSED: Symbol = symbol_short!("unpaused"); /// Versioned pause event carrying the tier (SoftPaused / HardPaused / NotPaused). @@ -352,6 +353,8 @@ const EVENT_CLAIM_DELAY_SET: Symbol = symbol_short!("dly_set"); // ── Data structures ────────────────────────────────────────── /// Contract version identifier (#23). Bumped when storage or semantics change; used for migration and compatibility. pub const CONTRACT_VERSION: u32 = 23; +/// Persistent storage layout version. Bump when adding/renaming DataKey variants. +pub const STORAGE_LAYOUT_VERSION: u32 = 1; #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -669,6 +672,8 @@ pub enum DataKey { EventOnlyMode, /// Last migrated storage version for upgrade hooks. DeployedVersion, + /// Persistent storage layout version stamp. Set during `initialize` and migrations. + StorageLayoutVersion, /// Platform fee in basis points. PlatformFeeBps, @@ -984,6 +989,9 @@ impl RevoraRevenueShare { /// Returns error if contract is frozen (#32). Call at start of state-mutating entrypoints. fn require_not_frozen(env: &Env) -> Result<(), RevoraError> { + // Ensure on-chain storage layout is compatible with this binary. + Self::assert_storage_layout_compatible(env)?; + let key = DataKey::Frozen; if env.storage().persistent().get::(&key).unwrap_or(false) { return Err(RevoraError::ContractFrozen); @@ -991,6 +999,30 @@ impl RevoraRevenueShare { Ok(()) } + /// Ensure the on-chain storage layout is compatible with this binary. + /// + /// - If the on-chain layout version is greater than the compiled `STORAGE_LAYOUT_VERSION`, + /// reject with `MigrationDowngradeNotAllowed`. + /// - If the on-chain layout version is absent or older, stamp the storage with the + /// compiled `STORAGE_LAYOUT_VERSION` and emit `EVENT_LAYOUT_VERSION` to signal migration. + fn assert_storage_layout_compatible(env: &Env) -> Result<(), RevoraError> { + let key = DataKey::StorageLayoutVersion; + if let Some(stored_v) = env.storage().persistent().get::(&key) { + if stored_v > STORAGE_LAYOUT_VERSION { + return Err(RevoraError::MigrationDowngradeNotAllowed); + } + if stored_v < STORAGE_LAYOUT_VERSION { + env.storage().persistent().set(&key, &STORAGE_LAYOUT_VERSION); + env.events().publish((EVENT_LAYOUT_VERSION,), STORAGE_LAYOUT_VERSION); + } + } else { + // No layout stamp found: stamp it now (first-time initialize/migration path). + env.storage().persistent().set(&key, &STORAGE_LAYOUT_VERSION); + env.events().publish((EVENT_LAYOUT_VERSION,), STORAGE_LAYOUT_VERSION); + } + Ok(()) + } + /// Returns true if the contract is in testnet mode (relaxed validation). fn is_testnet_mode(env: Env) -> bool { env.storage().persistent().get::(&DataKey::TestnetMode).unwrap_or(false) @@ -1596,6 +1628,25 @@ impl RevoraRevenueShare { Ok(()) } + /// Read-only accessor for the on-chain storage layout version stamp. + pub fn storage_layout_version(env: Env) -> Option { + env.storage().persistent().get(&DataKey::StorageLayoutVersion) + } + + /// Admin-only setter to adjust the stored layout version (used by migrations/tests). + /// Emits `EVENT_LAYOUT_VERSION` when the stored value is changed. + pub fn set_storage_layout_version(env: Env, caller: Address, v: u32) -> Result<(), RevoraError> { + let admin: Address = + env.storage().persistent().get(&DataKey::Admin).ok_or(RevoraError::NotInitialized)?; + admin.require_auth(); + if caller != admin { + return Err(RevoraError::NotAuthorized); + } + env.storage().persistent().set(&DataKey::StorageLayoutVersion, &v); + env.events().publish((EVENT_LAYOUT_VERSION,), v); + Ok(()) + } + pub fn get_pending_issuer_transfer( env: Env, issuer: Address, @@ -1960,6 +2011,12 @@ impl RevoraRevenueShare { env.storage().persistent().set(&DataKey::Paused, &PauseState::NotPaused); let eo = event_only.unwrap_or(false); env.storage().persistent().set(&DataKey2::ContractFlags, &(false, eo)); + // Stamp storage layout version for future compatibility checks. + env.storage() + .persistent() + .set(&DataKey::StorageLayoutVersion, &STORAGE_LAYOUT_VERSION); + env.events().publish((EVENT_LAYOUT_VERSION,), STORAGE_LAYOUT_VERSION); + env.events().publish((EVENT_INIT, admin.clone()), (safety, eo)); } diff --git a/src/test_storage_layout_version.rs b/src/test_storage_layout_version.rs new file mode 100644 index 00000000..0e6ea4bb --- /dev/null +++ b/src/test_storage_layout_version.rs @@ -0,0 +1,59 @@ +#![cfg(test)] + +use soroban_sdk::{testutils::Address as _, Address, Env}; + +use crate::{RevoraRevenueShareClient, RevoraError, RevoraRevenueShare, STORAGE_LAYOUT_VERSION}; + +#[test] +fn initialize_writes_storage_layout_version() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.initialize(&admin, &None::
, &None::); + + let v = client.storage_layout_version(); + assert_eq!(v, Some(STORAGE_LAYOUT_VERSION)); +} + +#[test] +fn downgrade_attempt_is_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.initialize(&admin, &None::
, &None::); + + // Admin sets the on-chain layout to a newer value (simulating a migrated state) + client.set_storage_layout_version(&admin, &(STORAGE_LAYOUT_VERSION + 1)).unwrap(); + + // Any state-changing entrypoint should now be rejected due to newer on-chain layout. + let res = client.set_testnet_mode(&true); + match res { + Err(Ok(RevoraError::MigrationDowngradeNotAllowed)) => {} + other => panic!("expected MigrationDowngradeNotAllowed, got: {:?}", other), + } +} + +#[test] +fn upgrade_path_allows_operation_and_stamps_layout() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register_contract(None, RevoraRevenueShare); + let client = RevoraRevenueShareClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + client.initialize(&admin, &None::
, &None::); + + // Simulate older on-chain layout (0) and ensure operations proceed and storage is stamped. + client.set_storage_layout_version(&admin, &0).unwrap(); + + // This should succeed and cause the contract to stamp the layout to the compiled value. + client.set_testnet_mode(&true).unwrap(); + let v = client.storage_layout_version(); + assert_eq!(v, Some(STORAGE_LAYOUT_VERSION)); +}