diff --git a/contracts/split/src/error.rs b/contracts/split/src/error.rs
index 2ffa89c..05e5309 100644
--- a/contracts/split/src/error.rs
+++ b/contracts/split/src/error.rs
@@ -80,4 +80,7 @@ pub enum ContractError {
CircularDependency = 45,
/// Issue #453: Source contract has exceeded its call rate limit within the current window.
SourceContractRateLimited = 46,
+ /// Storage migration framework: `schema_version` is behind the version
+ /// this Wasm build expects. Call `migrate` before retrying.
+ MigrationRequired = 47,
}
diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs
index 8378d7a..41f499c 100644
--- a/contracts/split/src/lib.rs
+++ b/contracts/split/src/lib.rs
@@ -66,6 +66,8 @@ mod storage_snapshot;
mod storage_keys;
+mod migrations;
+
use error::ContractError;
use soroban_sdk::crypto::bls12_381::{Fr, G1Affine};
use soroban_sdk::xdr::ToXdr;
@@ -806,6 +808,7 @@ fn compute_shard_id(env: &Env, payer: &Address) -> u64 {
}
fn require_admin(env: &Env) -> Address {
+ migrations::require_schema_current(env);
let admin: Address = env
.storage()
.instance()
@@ -2103,6 +2106,7 @@ fn is_paused(env: &Env) -> bool {
}
fn require_not_paused(env: &Env) {
+ migrations::require_schema_current(env);
assert!(!is_paused(env), "contract is paused");
// Issue #297: also check circuit breaker
let cb_active: bool = env
@@ -2114,6 +2118,7 @@ fn require_not_paused(env: &Env) {
}
fn check_not_paused(env: &Env) {
+ migrations::require_schema_current(env);
if is_paused(env) {
panic!("ContractPaused");
}
@@ -2141,6 +2146,15 @@ fn validate_allowed_token(env: &Env, token: &Address) {
fn require_admin_role(env: &Env, admin: &Address, min_role: AdminRole) {
+ migrations::require_schema_current(env);
+ require_admin_role_unguarded(env, admin, min_role);
+}
+
+/// Same authorisation check as [`require_admin_role`], without the
+/// schema-version guard. `SplitContract::migrate` must be reachable even when
+/// a migration is pending, so it calls this directly rather than
+/// `require_admin_role`.
+fn require_admin_role_unguarded(env: &Env, admin: &Address, min_role: AdminRole) {
admin.require_auth();
let admins: Map
= env
.storage()
@@ -2493,6 +2507,7 @@ fn check_creator_milestone(env: &Env, creator: &Address, new_volume: i128) {
/// Check if contract is frozen for upgrade. Panics if frozen.
fn require_not_frozen(env: &Env) {
+ migrations::require_schema_current(env);
let is_frozen: bool = env
.storage()
.instance()
@@ -2787,11 +2802,12 @@ impl SplitContract {
/// Issue #472: Pause the contract. Requires admin auth.
pub fn pause(env: Env, admin: Address) {
+ migrations::require_schema_current(&env);
admin.require_auth();
if let Some(stored_admin) = env.storage().instance().get::<_, Address>(&admin_key()) {
assert!(admin == stored_admin, "NotAuthorized");
} else {
- require_admin_role(&env, &admin, AdminRole::Operator);
+ require_admin_role_unguarded(&env, &admin, AdminRole::Operator);
}
env.storage().instance().set(&paused_key(), &true);
events::contract_paused(&env, &admin);
@@ -2799,11 +2815,12 @@ impl SplitContract {
/// Issue #472: Unpause the contract. Requires admin auth.
pub fn unpause(env: Env, admin: Address) {
+ migrations::require_schema_current(&env);
admin.require_auth();
if let Some(stored_admin) = env.storage().instance().get::<_, Address>(&admin_key()) {
assert!(admin == stored_admin, "NotAuthorized");
} else {
- require_admin_role(&env, &admin, AdminRole::Operator);
+ require_admin_role_unguarded(&env, &admin, AdminRole::Operator);
}
env.storage().instance().set(&paused_key(), &false);
events::contract_unpaused(&env, &admin);
@@ -2976,11 +2993,12 @@ impl SplitContract {
/// Issue #473: Add an asset contract address to the allowed tokens list.
pub fn add_allowed_token(env: Env, admin: Address, token: Address) {
+ migrations::require_schema_current(&env);
admin.require_auth();
if let Some(stored_admin) = env.storage().instance().get::<_, Address>(&admin_key()) {
assert!(admin == stored_admin, "NotAuthorized");
} else {
- require_admin_role(&env, &admin, AdminRole::Operator);
+ require_admin_role_unguarded(&env, &admin, AdminRole::Operator);
}
let mut allowed: Vec = env
.storage()
@@ -2995,11 +3013,12 @@ impl SplitContract {
/// Issue #473: Remove an asset contract address from the allowed tokens list.
pub fn remove_allowed_token(env: Env, admin: Address, token: Address) {
+ migrations::require_schema_current(&env);
admin.require_auth();
if let Some(stored_admin) = env.storage().instance().get::<_, Address>(&admin_key()) {
assert!(admin == stored_admin, "NotAuthorized");
} else {
- require_admin_role(&env, &admin, AdminRole::Operator);
+ require_admin_role_unguarded(&env, &admin, AdminRole::Operator);
}
let mut allowed: Vec = env
.storage()
@@ -4746,6 +4765,34 @@ impl SplitContract {
save_invoice(&env, invoice_id, &invoice);
}
+ /// Run all pending storage migrations in order, bringing a contract that
+ /// was upgraded from an older Wasm build up to the current schema
+ /// version. Returns the resulting `schema_version`.
+ ///
+ /// This is the **only** entry point exempt from the `MigrationRequired`
+ /// guard — every other entry point panics with `MigrationRequired` while
+ /// `schema_version` is behind the version this Wasm build expects.
+ /// No-op (but still auth-checked) once already current, so it is always
+ /// safe to call after an upgrade.
+ ///
+ /// # Errors
+ /// Panics if `admin` is not a `SuperAdmin`.
+ pub fn migrate(env: Env, admin: Address) -> u32 {
+ require_admin_role_unguarded(&env, &admin, AdminRole::SuperAdmin);
+ migrations::run_pending_migrations(&env)
+ }
+
+ /// Return the contract's current storage schema version.
+ pub fn get_schema_version(env: Env) -> u32 {
+ migrations::schema_version(&env)
+ }
+
+ /// Return the per-invoice note record introduced at schema v2 (renamed to
+ /// its current storage key at schema v3), if one has been migrated.
+ pub fn get_invoice_note(env: Env, invoice_id: u64) -> Option {
+ migrations::get_invoice_meta(&env, invoice_id)
+ }
+
// -----------------------------------------------------------------------
// Invoice creation
// -----------------------------------------------------------------------
diff --git a/contracts/split/src/migrations.rs b/contracts/split/src/migrations.rs
new file mode 100644
index 0000000..ba85268
--- /dev/null
+++ b/contracts/split/src/migrations.rs
@@ -0,0 +1,147 @@
+//! Storage migration framework (versioned schema keys).
+//!
+//! `schema_version` is a `u32` kept in instance storage that records which
+//! on-chain storage shape the contract currently has. The shared guard
+//! helpers used across the contract (`require_admin`, `require_admin_role`,
+//! `require_not_paused`, `check_not_paused`, `require_not_frozen`) all call
+//! [`require_schema_current`] first, so a contract left on a stale schema
+//! after a Wasm upgrade panics with `MigrationRequired` instead of reading or
+//! writing storage in the wrong shape. `SplitContract::migrate` is the only
+//! entry point exempt from that guard, since it is what performs the
+//! upgrade.
+//!
+//! Fresh deployments call `initialize()`, which stamps the schema at
+//! [`CURRENT_SCHEMA_VERSION`] immediately via [`init_schema_version`] — there
+//! is nothing to migrate for storage that never existed in an older shape.
+//! Only a contract whose Wasm was upgraded out from under existing data
+//! (leaving `schema_version` at a stale value) needs `migrate`.
+
+use super::*;
+use soroban_sdk::contracttype;
+
+/// Latest schema version this contract build understands. Bump when adding a
+/// new `migration_vN` and wire it into [`run_pending_migrations`].
+pub const CURRENT_SCHEMA_VERSION: u32 = 3;
+
+/// Instance storage: current on-chain schema version.
+pub(crate) fn schema_version_key() -> Symbol {
+ symbol_short!("schm_ver")
+}
+
+/// Current schema version. Contracts deployed before this framework existed
+/// never wrote the key, so absence means "version 1" — the original,
+/// pre-migration-framework storage layout.
+pub fn schema_version(env: &Env) -> u32 {
+ env.storage()
+ .instance()
+ .get(&schema_version_key())
+ .unwrap_or(1)
+}
+
+fn set_schema_version(env: &Env, version: u32) {
+ env.storage()
+ .instance()
+ .set(&schema_version_key(), &version);
+}
+
+/// Stamp a freshly-initialized contract at the current schema version.
+pub fn init_schema_version(env: &Env) {
+ set_schema_version(env, CURRENT_SCHEMA_VERSION);
+}
+
+/// Guard applied by every entry point except `migrate` itself: refuse to
+/// operate on stale storage so a partially-upgraded contract can't silently
+/// read or write data in the wrong shape.
+pub fn require_schema_current(env: &Env) {
+ if schema_version(env) < CURRENT_SCHEMA_VERSION {
+ panic!("MigrationRequired");
+ }
+}
+
+// ---------------------------------------------------------------------------
+// v1 -> v2: introduce a per-invoice note record.
+// ---------------------------------------------------------------------------
+// Every invoice created under schema v1 has no associated `InvoiceMeta`. This
+// migration backfills a default record for each existing invoice id so later
+// code can assume the record exists once schema_version >= 2.
+
+/// Per-invoice metadata introduced at schema v2, stored independently of the
+/// core `Invoice` record so existing invoice fields are untouched.
+#[contracttype]
+#[derive(Clone, Debug, PartialEq)]
+pub struct InvoiceMeta {
+ pub note: String,
+ pub priority: u32,
+}
+
+pub(crate) fn invoice_meta_key_v2(id: u64) -> (Symbol, u64) {
+ (symbol_short!("inv_meta"), id)
+}
+
+fn migration_v2(env: &Env) {
+ // The invoice counter holds the highest assigned id (ids are 1-based),
+ // not a next-id cursor — see `_create_invoice_inner`.
+ let last_id: u64 = env.storage().persistent().get(&counter_key()).unwrap_or(0);
+ let default_meta = InvoiceMeta {
+ note: String::from_str(env, ""),
+ priority: 0,
+ };
+ for id in 1..=last_id {
+ if env.storage().persistent().has(&invoice_key(id))
+ && !env.storage().persistent().has(&invoice_meta_key_v2(id))
+ {
+ env.storage()
+ .persistent()
+ .set(&invoice_meta_key_v2(id), &default_meta);
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// v2 -> v3: rename the InvoiceMeta storage key.
+// ---------------------------------------------------------------------------
+// The `inv_meta` key introduced at v2 is renamed to `inv_note` to match the
+// public `get_invoice_note` entry point. This demonstrates a pure key rename:
+// read under the old key, write under the new one, drop the old entry.
+
+pub(crate) fn invoice_meta_key_v3(id: u64) -> (Symbol, u64) {
+ (symbol_short!("inv_note"), id)
+}
+
+fn migration_v3(env: &Env) {
+ let last_id: u64 = env.storage().persistent().get(&counter_key()).unwrap_or(0);
+ for id in 1..=last_id {
+ let old_key = invoice_meta_key_v2(id);
+ if let Some(meta) = env.storage().persistent().get::<_, InvoiceMeta>(&old_key) {
+ env.storage()
+ .persistent()
+ .set(&invoice_meta_key_v3(id), &meta);
+ env.storage().persistent().remove(&old_key);
+ }
+ }
+}
+
+/// Look up an invoice's note under its current (post-v3) storage key.
+pub fn get_invoice_meta(env: &Env, id: u64) -> Option {
+ env.storage().persistent().get(&invoice_meta_key_v3(id))
+}
+
+/// Run every migration between the stored schema version and
+/// [`CURRENT_SCHEMA_VERSION`], in order, bumping the stored version after
+/// each step so a partially-upgraded contract resumes from wherever it left
+/// off if `migrate` is called again (e.g. after hitting the instruction
+/// budget on a contract with many invoices).
+pub fn run_pending_migrations(env: &Env) -> u32 {
+ let mut version = schema_version(env);
+ if version < 2 {
+ migration_v2(env);
+ version = 2;
+ set_schema_version(env, version);
+ }
+ if version < 3 {
+ migration_v3(env);
+ version = 3;
+ set_schema_version(env, version);
+ }
+ version
+}
diff --git a/contracts/split/src/test.rs b/contracts/split/src/test.rs
index 4ba3e5d..42f7d40 100644
--- a/contracts/split/src/test.rs
+++ b/contracts/split/src/test.rs
@@ -12685,4 +12685,4 @@ fn test_create_invoice_past_deadline_ledger_panics() {
amounts.push_back(100_i128);
c.create_invoice(&creator, &recipients, &amounts, &token_id, &(500_u32));
-}
\ No newline at end of file
+}