From 3b3820fbdc7c90a82dbb0a3ed691de9d24df9754 Mon Sep 17 00:00:00 2001 From: Samuel Ojetunde Date: Sun, 28 Jun 2026 00:12:56 +0100 Subject: [PATCH] feat: settlement V1->V2 migration --- contracts/settlement/src/admin.rs | 26 +- contracts/settlement/src/lib.rs | 54 +++ contracts/settlement/src/migrate.rs | 522 ++++++++++++++++++++++++++++ contracts/settlement/src/types.rs | 25 +- docs/MIGRATION.md | 193 ++++++++++ 5 files changed, 811 insertions(+), 9 deletions(-) create mode 100644 contracts/settlement/src/migrate.rs create mode 100644 docs/MIGRATION.md diff --git a/contracts/settlement/src/admin.rs b/contracts/settlement/src/admin.rs index 19a5566..c62c0a2 100644 --- a/contracts/settlement/src/admin.rs +++ b/contracts/settlement/src/admin.rs @@ -14,6 +14,15 @@ fn require_admin(env: &Env, caller: &Address) { } } +/// Retrieve the USDC token address from instance storage, panicking with +/// [`SettlementError::UsdcTokenNotConfigured`] if it has not been set. +fn get_usdc(env: &Env) -> Address { + env.storage() + .instance() + .get(&StorageKey::Usdc) + .unwrap_or_else(|| env.panic_with_error(SettlementError::UsdcTokenNotConfigured)) +} + pub(crate) fn propose_balance_migration(env: &Env, caller: &Address, from: &Address, to: &Address) { require_admin(env, caller); if from == to { @@ -23,10 +32,13 @@ pub(crate) fn propose_balance_migration(env: &Env, caller: &Address, from: &Addr env.panic_with_error(SettlementError::InvalidMigrationTarget); } + let usdc_token = get_usdc(env); + + // Read the source developer's V2 per-token balance. let amount: i128 = env .storage() .persistent() - .get(&StorageKey::DeveloperBalance(from.clone())) + .get(&StorageKey::DeveloperBalance(from.clone(), usdc_token.clone())) .unwrap_or(0); if amount <= 0 { env.panic_with_error(SettlementError::NoDeveloperBalance); @@ -60,26 +72,28 @@ pub(crate) fn execute_balance_migration(env: &Env, caller: &Address, from: &Addr env.panic_with_error(SettlementError::TimelockNotExpired); } + let usdc_token = get_usdc(env); + let source_key = StorageKey::DeveloperBalance(from.clone(), usdc_token.clone()); + let destination_key = StorageKey::DeveloperBalance(migration.to.clone(), usdc_token.clone()); + let source_balance: i128 = env .storage() .persistent() - .get(&StorageKey::DeveloperBalance(from.clone())) + .get(&source_key) .unwrap_or(0); let new_source_balance = source_balance .checked_sub(migration.amount) - .filter(|balance| *balance >= 0) + .filter(|b| *b >= 0) .unwrap_or_else(|| env.panic_with_error(SettlementError::MigrationBalanceChanged)); let destination_balance: i128 = env .storage() .persistent() - .get(&StorageKey::DeveloperBalance(migration.to.clone())) + .get(&destination_key) .unwrap_or(0); let new_destination_balance = destination_balance .checked_add(migration.amount) .unwrap_or_else(|| env.panic_with_error(SettlementError::DeveloperOverflow)); - let source_key = StorageKey::DeveloperBalance(from.clone()); - let destination_key = StorageKey::DeveloperBalance(migration.to.clone()); env.storage() .persistent() .set(&source_key, &new_source_balance); diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs index 1953647..d239be4 100644 --- a/contracts/settlement/src/lib.rs +++ b/contracts/settlement/src/lib.rs @@ -1188,9 +1188,63 @@ impl CalloraSettlement { } let _ = env; // env available for future use } + + /// One-shot V1 -> V2 storage migration (admin only). + /// + /// Converts all `DeveloperBalanceV1(addr)` persistent slots to per-token + /// `DeveloperBalance(addr, usdc_token)` slots in a single transaction. + /// For deployments with more than [`MAX_BATCH_SIZE`] developers use + /// [`migrate_v1_to_v2_page`] to spread the work across multiple ledgers. + /// + /// # Access Control + /// Only the current admin may call this function. + /// + /// # Idempotency + /// Safe to call multiple times; re-running after `StorageVersion == 2` + /// returns immediately without modifying any state. + /// + /// # Panics + /// - [`SettlementError::NotInitialized`] if the contract is not initialised. + /// - [`SettlementError::Unauthorized`] if the caller is not the admin. + /// - [`SettlementError::UsdcTokenNotConfigured`] if USDC is not configured. + pub fn migrate_v1_to_v2(env: Env, caller: Address) { + migrate::migrate_v1_to_v2(&env, &caller); + } + + /// Paginated V1 -> V2 storage migration (admin only). + /// + /// Processes up to `batch_size` (capped at [`MAX_BATCH_SIZE`]) developer + /// accounts per call, starting from index position `offset`. + /// + /// # Returns + /// `(next_offset, is_complete)`. When `is_complete` is `true` all developer + /// slots have been converted and `StorageVersion` is set to `2`. + /// + /// # Access Control + /// Only the current admin may call this function. + /// + /// # Idempotency + /// Returns `(0, true)` immediately when migration is already complete. + pub fn migrate_v1_to_v2_page( + env: Env, + caller: Address, + offset: u32, + batch_size: u32, + ) -> (u32, bool) { + migrate::migrate_v1_to_v2_page(&env, &caller, offset, batch_size) + } + + /// Return the current storage-layout version. + /// + /// `1` = V1 layout (pre-migration or key absent). + /// `2` = V2 per-token layout (migration complete). + pub fn migration_storage_version(env: Env) -> u32 { + migrate::storage_version(&env) + } } mod events; +pub mod migrate; #[cfg(test)] mod test; diff --git a/contracts/settlement/src/migrate.rs b/contracts/settlement/src/migrate.rs new file mode 100644 index 0000000..5d71167 --- /dev/null +++ b/contracts/settlement/src/migrate.rs @@ -0,0 +1,522 @@ +//! Settlement storage migration: V1 (single-token) -> V2 (per-token) layout. +//! +//! ## Background +//! +//! The original settlement contract stored developer balances as a flat, +//! single-token mapping keyed by `StorageKey::DeveloperBalanceV1(Address)`. +//! V2 introduces explicit per-token accounting via +//! `StorageKey::DeveloperBalance(Address, Address)` where the second address +//! is the token contract (typically USDC). +//! +//! ## Storage layout changes +//! +//! | Key | V1 | V2 | +//! |-----|----|----| +//! | `DeveloperBalanceV1(addr)` | `i128` | read, merged, removed | +//! | `DeveloperBalance(addr, usdc_token)` | - | written during migration | +//! | `StorageVersion` | absent | `2u32` on completion | +//! +//! Existing V2 credits written after WASM upgrade but before migration runs are +//! **preserved and merged** with the corresponding V1 balance. +//! +//! ## Usage +//! +//! ```text +//! // One-shot (<=50 developers) +//! client.migrate_v1_to_v2(&admin); +//! +//! // Paginated (large deployments) +//! let mut offset = 0u32; +//! loop { +//! let (next, done) = client.migrate_v1_to_v2_page(&admin, &offset, &50u32); +//! if done { break; } +//! offset = next; +//! } +//! assert_eq!(client.migration_storage_version(), 2u32); +//! ``` +//! +//! ## Security +//! +//! - All entry points call `caller.require_auth()` and verify the admin address. +//! - Re-running after `StorageVersion == 2` is a safe no-op (idempotent). +//! - Balance merging uses `checked_add`; no silent overflow. +//! - No `unwrap()` in production paths. + +use soroban_sdk::{Address, Env, Symbol, Vec}; + +use crate::{SettlementError, StorageKey, MAX_BATCH_SIZE}; + +/// Storage-layout version that predates version tracking. +pub const STORAGE_VERSION_V1: u32 = 1; +/// Storage-layout version set after the V1 -> V2 migration completes. +pub const STORAGE_VERSION_V2: u32 = 2; + +// ─── Public query ───────────────────────────────────────────────────────────── + +/// Return the current storage-layout version. +/// +/// Returns [`STORAGE_VERSION_V1`] when the `StorageVersion` key is absent +/// (contract initialised before version tracking was introduced). +/// Returns [`STORAGE_VERSION_V2`] once [`migrate_v1_to_v2`] has completed. +pub fn storage_version(env: &Env) -> u32 { + env.storage() + .instance() + .get(&StorageKey::StorageVersion) + .unwrap_or(STORAGE_VERSION_V1) +} + +// ─── Public entry points ────────────────────────────────────────────────────── + +/// One-shot V1 -> V2 storage migration (admin only). +/// +/// Iterates over every address in [`StorageKey::DeveloperIndex`], reads the +/// legacy `DeveloperBalanceV1(addr)` slot, merges it into +/// `DeveloperBalance(addr, usdc_token)`, and removes the V1 slot. +/// +/// Suitable for deployments with **<= [`MAX_BATCH_SIZE`] registered +/// developers**. For larger deployments use [`migrate_v1_to_v2_page`]. +/// +/// # Arguments +/// +/// * `caller` - Must be the current admin; `caller.require_auth()` is invoked. +/// +/// # Panics +/// +/// | Condition | Error | +/// |-----------|-------| +/// | Contract not initialised | [`SettlementError::NotInitialized`] | +/// | Caller is not the admin | [`SettlementError::Unauthorized`] | +/// | USDC token not configured | [`SettlementError::UsdcTokenNotConfigured`] | +/// | Balance merge overflows `i128` | [`SettlementError::DeveloperOverflow`] | +/// +/// # Idempotency +/// +/// Returns immediately without state changes when `StorageVersion == 2`. +pub fn migrate_v1_to_v2(env: &Env, caller: &Address) { + caller.require_auth(); + require_admin(env, caller); + + if storage_version(env) >= STORAGE_VERSION_V2 { + return; + } + + let inst = env.storage().instance(); + let usdc_token: Address = inst + .get(&StorageKey::Usdc) + .unwrap_or_else(|| env.panic_with_error(SettlementError::UsdcTokenNotConfigured)); + + let index: Vec
= inst + .get(&StorageKey::DeveloperIndex) + .unwrap_or_else(|| Vec::new(env)); + + for addr in index.iter() { + migrate_developer_slot(env, &addr, &usdc_token); + } + + inst.set(&StorageKey::StorageVersion, &STORAGE_VERSION_V2); + env.events() + .publish((Symbol::new(env, "mig_v1_v2_done"),), STORAGE_VERSION_V2); +} + +/// Paginated V1 -> V2 storage migration (admin only). +/// +/// Processes up to `batch_size` (capped at [`MAX_BATCH_SIZE`]) developer +/// accounts per call, starting at index position `offset`. Call repeatedly, +/// passing the returned `next_offset` as `offset`, until `is_complete == true`. +/// +/// # Snap-point API +/// +/// ```text +/// let mut offset = 0u32; +/// loop { +/// let (next, done) = client.migrate_v1_to_v2_page(&admin, &offset, &50u32); +/// if done { break; } +/// offset = next; +/// } +/// ``` +/// +/// # Arguments +/// +/// * `caller` - Must be the current admin. +/// * `offset` - Index of the first developer to process. Pass `0` on first call. +/// * `batch_size` - Max developers per call; capped at [`MAX_BATCH_SIZE`]. +/// A value of `0` is treated as `1`. +/// +/// # Returns +/// +/// `(next_offset, is_complete)`: +/// * `next_offset` - First unprocessed position for the next call. +/// * `is_complete` - `true` when all slots are migrated and `StorageVersion == 2`. +/// +/// # Panics +/// +/// Same conditions as [`migrate_v1_to_v2`]. +/// +/// # Idempotency +/// +/// Returns `(0, true)` immediately when migration is already at V2. +pub fn migrate_v1_to_v2_page( + env: &Env, + caller: &Address, + offset: u32, + batch_size: u32, +) -> (u32, bool) { + caller.require_auth(); + require_admin(env, caller); + + if storage_version(env) >= STORAGE_VERSION_V2 { + return (0, true); + } + + let inst = env.storage().instance(); + let usdc_token: Address = inst + .get(&StorageKey::Usdc) + .unwrap_or_else(|| env.panic_with_error(SettlementError::UsdcTokenNotConfigured)); + + let index: Vec
= inst + .get(&StorageKey::DeveloperIndex) + .unwrap_or_else(|| Vec::new(env)); + + let total = index.len(); + let effective = if batch_size == 0 { 1 } else { batch_size.min(MAX_BATCH_SIZE) }; + let end = offset.saturating_add(effective).min(total); + + let mut i = 0u32; + for addr in index.iter() { + if i >= end { + break; + } + if i >= offset { + migrate_developer_slot(env, &addr, &usdc_token); + } + i = i.saturating_add(1); + } + + let done = end >= total; + if done { + inst.set(&StorageKey::StorageVersion, &STORAGE_VERSION_V2); + env.events() + .publish((Symbol::new(env, "mig_v1_v2_done"),), STORAGE_VERSION_V2); + } + + (end, done) +} + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +/// Abort with `NotInitialized` if the contract has not been initialised, or +/// `Unauthorized` if `caller` is not the stored admin. +fn require_admin(env: &Env, caller: &Address) { + let inst = env.storage().instance(); + if !inst.has(&StorageKey::Admin) { + env.panic_with_error(SettlementError::NotInitialized); + } + let admin: Address = inst + .get(&StorageKey::Admin) + .unwrap_or_else(|| env.panic_with_error(SettlementError::NotInitialized)); + if caller != &admin { + env.panic_with_error(SettlementError::Unauthorized); + } +} + +/// Read the V1 balance for `addr`, merge it into the V2 per-token slot, and +/// remove the V1 key. Already-migrated addresses (no V1 slot) are skipped. +fn migrate_developer_slot(env: &Env, addr: &Address, usdc_token: &Address) { + let v1_key = StorageKey::DeveloperBalanceV1(addr.clone()); + let v1_balance: Option = env.storage().persistent().get(&v1_key); + if let Some(v1) = v1_balance { + let v2_key = StorageKey::DeveloperBalance(addr.clone(), usdc_token.clone()); + let existing_v2: i128 = env + .storage() + .persistent() + .get(&v2_key) + .unwrap_or(0i128); + let merged = v1 + .checked_add(existing_v2) + .unwrap_or_else(|| env.panic_with_error(SettlementError::DeveloperOverflow)); + env.storage().persistent().set(&v2_key, &merged); + env.storage() + .persistent() + .extend_ttl(&v2_key, 50_000, 50_000); + env.storage().persistent().remove(&v1_key); + } +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + extern crate std; + + use super::*; + use crate::{CalloraSettlement, CalloraSettlementClient}; + use soroban_sdk::testutils::Address as _; + use soroban_sdk::Env; + + // ── helpers ─────────────────────────────────────────────────────────────── + + /// Register a fresh contract and configure admin, vault, and USDC. + /// Returns `(contract_address, admin, usdc_token)`. + fn setup(env: &Env) -> (Address, Address, Address) { + let contract = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(env, &contract); + let admin = Address::generate(env); + let vault = Address::generate(env); + let usdc = Address::generate(env); + env.mock_all_auths(); + client.init(&admin, &vault); + client.set_usdc_token(&admin, &usdc); + (contract, admin, usdc) + } + + // ── storage_version ─────────────────────────────────────────────────────── + + #[test] + fn storage_version_is_one_before_migration() { + let env = Env::default(); + env.mock_all_auths(); + let (contract, ..) = setup(&env); + let client = CalloraSettlementClient::new(&env, &contract); + assert_eq!(client.migration_storage_version(), STORAGE_VERSION_V1); + } + + // ── one-shot migration ──────────────────────────────────────────────────── + + #[test] + fn one_shot_empty_contract_marks_v2() { + let env = Env::default(); + env.mock_all_auths(); + let (contract, admin, _) = setup(&env); + let client = CalloraSettlementClient::new(&env, &contract); + client.migrate_v1_to_v2(&admin); + assert_eq!(client.migration_storage_version(), STORAGE_VERSION_V2); + } + + #[test] + fn one_shot_migration_is_idempotent() { + let env = Env::default(); + env.mock_all_auths(); + let (contract, admin, _) = setup(&env); + let client = CalloraSettlementClient::new(&env, &contract); + client.migrate_v1_to_v2(&admin); + client.migrate_v1_to_v2(&admin); + assert_eq!(client.migration_storage_version(), STORAGE_VERSION_V2); + } + + #[test] + fn one_shot_migrates_v1_developer_balance() { + let env = Env::default(); + env.mock_all_auths(); + let (contract, admin, usdc) = setup(&env); + let client = CalloraSettlementClient::new(&env, &contract); + let dev = Address::generate(&env); + + env.as_contract(&contract, || { + env.storage() + .persistent() + .set(&StorageKey::DeveloperBalanceV1(dev.clone()), &5_000i128); + let mut idx: soroban_sdk::Vec
= env + .storage() + .instance() + .get(&StorageKey::DeveloperIndex) + .unwrap_or_else(|| soroban_sdk::Vec::new(&env)); + idx.push_back(dev.clone()); + env.storage() + .instance() + .set(&StorageKey::DeveloperIndex, &idx); + }); + + client.migrate_v1_to_v2(&admin); + assert_eq!(client.migration_storage_version(), STORAGE_VERSION_V2); + + let v2_balance: i128 = env.as_contract(&contract, || { + env.storage() + .persistent() + .get(&StorageKey::DeveloperBalance(dev.clone(), usdc.clone())) + .unwrap_or(0) + }); + assert_eq!(v2_balance, 5_000i128); + + let v1_gone: bool = env.as_contract(&contract, || { + env.storage() + .persistent() + .get::<_, i128>(&StorageKey::DeveloperBalanceV1(dev.clone())) + .is_none() + }); + assert!(v1_gone); + } + + #[test] + fn one_shot_merges_v1_and_existing_v2_balance() { + let env = Env::default(); + env.mock_all_auths(); + let (contract, admin, usdc) = setup(&env); + let client = CalloraSettlementClient::new(&env, &contract); + let dev = Address::generate(&env); + + env.as_contract(&contract, || { + env.storage() + .persistent() + .set(&StorageKey::DeveloperBalanceV1(dev.clone()), &3_000i128); + env.storage().persistent().set( + &StorageKey::DeveloperBalance(dev.clone(), usdc.clone()), + &1_500i128, + ); + let mut idx: soroban_sdk::Vec
= env + .storage() + .instance() + .get(&StorageKey::DeveloperIndex) + .unwrap_or_else(|| soroban_sdk::Vec::new(&env)); + idx.push_back(dev.clone()); + env.storage() + .instance() + .set(&StorageKey::DeveloperIndex, &idx); + }); + + client.migrate_v1_to_v2(&admin); + + let merged: i128 = env.as_contract(&contract, || { + env.storage() + .persistent() + .get(&StorageKey::DeveloperBalance(dev.clone(), usdc.clone())) + .unwrap_or(0) + }); + assert_eq!(merged, 4_500i128); + } + + #[test] + #[should_panic] + fn one_shot_panics_when_not_initialized() { + let env = Env::default(); + env.mock_all_auths(); + let contract = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &contract); + let caller = Address::generate(&env); + client.migrate_v1_to_v2(&caller); + } + + #[test] + #[should_panic] + fn one_shot_panics_when_caller_not_admin() { + let env = Env::default(); + env.mock_all_auths(); + let (contract, _, _) = setup(&env); + let client = CalloraSettlementClient::new(&env, &contract); + let non_admin = Address::generate(&env); + client.migrate_v1_to_v2(&non_admin); + } + + #[test] + #[should_panic] + fn one_shot_panics_when_usdc_not_configured() { + let env = Env::default(); + env.mock_all_auths(); + let contract = env.register(CalloraSettlement, ()); + let client = CalloraSettlementClient::new(&env, &contract); + let admin = Address::generate(&env); + let vault = Address::generate(&env); + client.init(&admin, &vault); + client.migrate_v1_to_v2(&admin); + } + + // ── paginated migration ─────────────────────────────────────────────────── + + #[test] + fn paginated_empty_contract_completes_in_one_call() { + let env = Env::default(); + env.mock_all_auths(); + let (contract, admin, _) = setup(&env); + let client = CalloraSettlementClient::new(&env, &contract); + let (_, done) = client.migrate_v1_to_v2_page(&admin, &0u32, &50u32); + assert!(done); + assert_eq!(client.migration_storage_version(), STORAGE_VERSION_V2); + } + + #[test] + fn paginated_idempotent_after_completion() { + let env = Env::default(); + env.mock_all_auths(); + let (contract, admin, _) = setup(&env); + let client = CalloraSettlementClient::new(&env, &contract); + client.migrate_v1_to_v2_page(&admin, &0u32, &50u32); + let (next, done) = client.migrate_v1_to_v2_page(&admin, &0u32, &50u32); + assert!(done); + assert_eq!(next, 0u32); + } + + #[test] + fn paginated_multi_page_processes_all_developers() { + let env = Env::default(); + env.mock_all_auths(); + let (contract, admin, usdc) = setup(&env); + let client = CalloraSettlementClient::new(&env, &contract); + + let dev_a = Address::generate(&env); + let dev_b = Address::generate(&env); + let dev_c = Address::generate(&env); + let dev_d = Address::generate(&env); + let dev_e = Address::generate(&env); + + let devs = [ + dev_a.clone(), + dev_b.clone(), + dev_c.clone(), + dev_d.clone(), + dev_e.clone(), + ]; + env.as_contract(&contract, || { + for dev in devs.iter() { + env.storage() + .persistent() + .set(&StorageKey::DeveloperBalanceV1(dev.clone()), &100i128); + let mut idx: soroban_sdk::Vec
= env + .storage() + .instance() + .get(&StorageKey::DeveloperIndex) + .unwrap_or_else(|| soroban_sdk::Vec::new(&env)); + idx.push_back(dev.clone()); + env.storage() + .instance() + .set(&StorageKey::DeveloperIndex, &idx); + } + }); + + let (o1, d1) = client.migrate_v1_to_v2_page(&admin, &0u32, &2u32); + assert!(!d1); + let (o2, d2) = client.migrate_v1_to_v2_page(&admin, &o1, &2u32); + assert!(!d2); + let (_, d3) = client.migrate_v1_to_v2_page(&admin, &o2, &2u32); + assert!(d3); + assert_eq!(client.migration_storage_version(), STORAGE_VERSION_V2); + + for dev in devs.iter() { + let bal: i128 = env.as_contract(&contract, || { + env.storage() + .persistent() + .get(&StorageKey::DeveloperBalance(dev.clone(), usdc.clone())) + .unwrap_or(0) + }); + assert_eq!(bal, 100i128); + } + } + + #[test] + fn paginated_batch_size_capped_at_max_batch_size() { + let env = Env::default(); + env.mock_all_auths(); + let (contract, admin, _) = setup(&env); + let client = CalloraSettlementClient::new(&env, &contract); + let (_, done) = client.migrate_v1_to_v2_page(&admin, &0u32, &u32::MAX); + assert!(done); + } + + #[test] + fn paginated_zero_batch_treated_as_one() { + let env = Env::default(); + env.mock_all_auths(); + let (contract, admin, _) = setup(&env); + let client = CalloraSettlementClient::new(&env, &contract); + let (_, done) = client.migrate_v1_to_v2_page(&admin, &0u32, &0u32); + assert!(done); + } +} diff --git a/contracts/settlement/src/types.rs b/contracts/settlement/src/types.rs index b39708c..6f12b04 100644 --- a/contracts/settlement/src/types.rs +++ b/contracts/settlement/src/types.rs @@ -14,7 +14,7 @@ pub const MAX_DEVELOPER_BALANCES_PAGE_SIZE: u32 = 100; /// /// # Migration note /// Discriminant 5 was the original `DeveloperBalance(Address)` (single-token, now -/// `DeveloperBalanceV1` — kept for migration only). New per-token entries use +/// `DeveloperBalanceV1` — kept for migration reads only). New per-token entries use /// `DeveloperBalance(Address, Address)` at discriminant 6. #[contracttype] #[derive(Clone, Debug, PartialEq)] @@ -24,8 +24,9 @@ pub enum StorageKey { PendingAdmin, PendingVault, DeveloperIndex, - /// Legacy single-token balance — kept for migration reads. Do NOT use for - /// new writes. + /// Legacy single-token balance — kept for V1 → V2 migration reads only. + /// Do **not** use for new writes; new per-token credits go to + /// [`StorageKey::DeveloperBalance`]. DeveloperBalanceV1(Address), /// Per-token developer balance `(developer, token)`. DeveloperBalance(Address, Address), @@ -34,6 +35,13 @@ pub enum StorageKey { DailyWithdrawCap(Address), WithdrawalToday(Address), ContractVersion, + /// Pending timelock'd developer balance migration record. + /// Key: source developer address. + PendingDeveloperMigration(Address), + /// Storage-layout version marker (u32). + /// Absent → V1 (pre-migration, no version tracking). + /// Value 2 → V2 (single-token → per-token migration complete). + StorageVersion, } /// Severity levels for admin broadcast messages. @@ -154,3 +162,14 @@ pub struct DeveloperForceCreditedEvent { pub reason: Symbol, pub new_balance: i128, } + +/// Emitted when the admin proposes or executes a timelock'd developer balance +/// migration (address rotation). +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub struct AdminMigrationEvent { + pub from: Address, + pub to: Address, + pub amount: i128, + pub executed_at: u64, +} diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..9a950e4 --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,193 @@ +# Settlement V1 -> V2 Storage Migration + +This document explains the storage layout change introduced between the V1 and +V2 Callora Settlement contract and describes the on-chain migration procedure. + +--- + +## Background + +The original (V1) settlement contract stored all developer balances as a flat, +single-token mapping: + +``` +DeveloperBalanceV1(developer: Address) -> i128 +``` + +V2 introduces explicit per-token accounting so the same contract can handle +settlements in multiple token denominations (USDC, USDT, etc.): + +``` +DeveloperBalance(developer: Address, token: Address) -> i128 +``` + +All V1 balances are denominated in the USDC token configured via +`StorageKey::Usdc`. The migration therefore converts every +`DeveloperBalanceV1(addr)` slot into `DeveloperBalance(addr, usdc_token)`. + +--- + +## Storage layout diff + +| Key | V1 type | V2 action | +|-----|---------|-----------| +| `DeveloperBalanceV1(addr)` | `i128` | Read, merged into V2 key, then **removed** | +| `DeveloperBalance(addr, usdc_token)` | - | Written during migration | +| `StorageKey::StorageVersion` | absent | Set to `2u32` on completion | +| `StorageKey::PendingDeveloperMigration(addr)` | - | New: timelock'd address migration | + +--- + +## Pre-migration checklist + +Before running the migration: + +1. **Upgrade the WASM** to the V2 binary via `upgrade(admin, new_wasm_hash)`. +2. **Configure USDC** if not already set: `set_usdc_token(admin, usdc_address)`. +3. **Freeze incoming payments** (recommended): pause the vault or route new + payments to a staging account during the migration window. +4. **Determine developer count**: query `get_developer_balances_page` to count + registered developers. If the count is > 50, use the paginated migration. + +--- + +## Migration procedure + +### Option A - one-shot (<=50 developers) + +Call once; the entire migration runs in a single transaction: + +```bash +stellar contract invoke \ + --id \ + --source-account \ + --network mainnet \ + -- migrate_v1_to_v2 \ + --caller +``` + +Verify completion: + +```bash +stellar contract invoke \ + --id \ + --source-account \ + --network mainnet \ + -- migration_storage_version +# Expected output: 2 +``` + +### Option B - paginated (>50 developers) + +Call in a loop until `is_complete` is `true`: + +```bash +OFFSET=0 +while true; do + RESULT=$(stellar contract invoke \ + --id \ + --source-account \ + --network mainnet \ + -- migrate_v1_to_v2_page \ + --caller \ + --offset $OFFSET \ + --batch_size 50) + + # RESULT is a JSON tuple: [next_offset, is_complete] + NEXT_OFFSET=$(echo $RESULT | jq '.[0]') + IS_COMPLETE=$(echo $RESULT | jq '.[1]') + + echo "Migrated up to offset $NEXT_OFFSET; complete=$IS_COMPLETE" + + if [ "$IS_COMPLETE" = "true" ]; then + break + fi + OFFSET=$NEXT_OFFSET +done +``` + +--- + +## Merge semantics + +If the V2 WASM began accepting payments for a developer **before** the +migration ran (e.g. a payment arrived immediately after WASM upgrade), that +developer will have both a V1 and a V2 balance. The migration merges them: + +``` +new_v2_balance = v1_balance + existing_v2_balance +``` + +Overflow of `i128` causes the transaction to abort with error code `8` +(`DeveloperOverflow`). This is an extreme edge case; balances would need to +approach 2^127 micro-units simultaneously. + +--- + +## Idempotency + +Every migration entry point is idempotent. Calling `migrate_v1_to_v2` or +`migrate_v1_to_v2_page` after a completed migration (`StorageVersion == 2`) +returns immediately without modifying state. It is safe to call the migration +more than once. + +--- + +## Rollback plan + +The migration is **irreversible** via the public API: V1 slots are removed +after conversion. However, Soroban ledger history is permanent; a rollback +would require redeploying the V1 WASM and replaying credits from historical +events, which is costly and not recommended. + +**Risk mitigation**: pause the vault before migrating to ensure no new V1 +credits can arrive after the migration window opens. + +--- + +## Verification + +After migration, confirm: + +1. `migration_storage_version()` returns `2`. +2. Each known developer has a non-zero V2 balance: + ```bash + stellar contract invoke -- get_developer_balance \ + --developer --token + ``` +3. No V1 slots remain (query `DeveloperBalanceV1` for known developers + returns `None`/`0`). +4. The `mig_done` event was emitted (check ledger event stream for topic + `"mig_done"` with data `2u32`). + +--- + +## API reference + +| Function | Arguments | Returns | Auth | +|----------|-----------|---------|------| +| `migrate_v1_to_v2(caller)` | admin address | `()` | Admin | +| `migrate_v1_to_v2_page(caller, offset, batch_size)` | admin, start index, page size | `(u32, bool)` | Admin | +| `migration_storage_version()` | - | `u32` | None | + +### Error codes + +| Code | Variant | Trigger | +|------|---------|---------| +| 1 | `NotInitialized` | Contract not initialised | +| 3 | `Unauthorized` | Caller is not the admin | +| 8 | `DeveloperOverflow` | V1 + V2 balance overflows `i128` | +| 9 | `UsdcTokenNotConfigured` | USDC token not configured | + +--- + +## Security considerations + +- All migration entry points call `caller.require_auth()` and verify the + stored admin address before mutating any state. +- The migration is protected by the standard two-step admin transfer; only + an address that has gone through `set_admin` -> `accept_admin` can act + as admin. +- Arithmetic uses `checked_add` throughout; no silent integer overflow is + possible. +- The `StorageVersion` marker ensures migration is not re-run accidentally.