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.