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
3 changes: 3 additions & 0 deletions contracts/split/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
55 changes: 51 additions & 4 deletions contracts/split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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");
}
Expand Down Expand Up @@ -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<Address, AdminRole> = env
.storage()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -2787,23 +2802,25 @@ 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);
}

/// 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);
Expand Down Expand Up @@ -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<Address> = env
.storage()
Expand All @@ -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<Address> = env
.storage()
Expand Down Expand Up @@ -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::InvoiceMeta> {
migrations::get_invoice_meta(&env, invoice_id)
}

// -----------------------------------------------------------------------
// Invoice creation
// -----------------------------------------------------------------------
Expand Down
147 changes: 147 additions & 0 deletions contracts/split/src/migrations.rs
Original file line number Diff line number Diff line change
@@ -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<InvoiceMeta> {
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
}
2 changes: 1 addition & 1 deletion contracts/split/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Loading