diff --git a/contracts/settlement/src/admin.rs b/contracts/settlement/src/admin.rs
new file mode 100644
index 0000000..19a5566
--- /dev/null
+++ b/contracts/settlement/src/admin.rs
@@ -0,0 +1,120 @@
+//! Admin-only developer balance recovery operations.
+
+use soroban_sdk::{Address, Env, Vec};
+
+use crate::{
+ events, timelock, AdminMigrationEvent, CalloraSettlement, SettlementError, StorageKey,
+};
+
+fn require_admin(env: &Env, caller: &Address) {
+ caller.require_auth();
+ let admin = CalloraSettlement::get_admin(env.clone());
+ if caller != &admin {
+ env.panic_with_error(SettlementError::Unauthorized);
+ }
+}
+
+pub(crate) fn propose_balance_migration(env: &Env, caller: &Address, from: &Address, to: &Address) {
+ require_admin(env, caller);
+ if from == to {
+ env.panic_with_error(SettlementError::MigrationSameAddress);
+ }
+ if to == &env.current_contract_address() {
+ env.panic_with_error(SettlementError::InvalidMigrationTarget);
+ }
+
+ let amount: i128 = env
+ .storage()
+ .persistent()
+ .get(&StorageKey::DeveloperBalance(from.clone()))
+ .unwrap_or(0);
+ if amount <= 0 {
+ env.panic_with_error(SettlementError::NoDeveloperBalance);
+ }
+
+ let proposed_at = env.ledger().timestamp();
+ let execute_after = proposed_at
+ .checked_add(timelock::DEVELOPER_MIGRATION_TIMELOCK_SECONDS)
+ .unwrap_or_else(|| env.panic_with_error(SettlementError::TimelockOverflow));
+ let migration = timelock::PendingDeveloperMigration {
+ from: from.clone(),
+ to: to.clone(),
+ amount,
+ proposed_at,
+ execute_after,
+ };
+ timelock::set_pending_migration(env, &migration);
+
+ env.events().publish(
+ (events::event_admin_migration_proposed(env), from.clone()),
+ migration,
+ );
+}
+
+pub(crate) fn execute_balance_migration(env: &Env, caller: &Address, from: &Address) {
+ require_admin(env, caller);
+ let migration = timelock::get_pending_migration(env, from)
+ .unwrap_or_else(|| env.panic_with_error(SettlementError::MigrationNotFound));
+ let executed_at = env.ledger().timestamp();
+ if executed_at < migration.execute_after {
+ env.panic_with_error(SettlementError::TimelockNotExpired);
+ }
+
+ let source_balance: i128 = env
+ .storage()
+ .persistent()
+ .get(&StorageKey::DeveloperBalance(from.clone()))
+ .unwrap_or(0);
+ let new_source_balance = source_balance
+ .checked_sub(migration.amount)
+ .filter(|balance| *balance >= 0)
+ .unwrap_or_else(|| env.panic_with_error(SettlementError::MigrationBalanceChanged));
+ let destination_balance: i128 = env
+ .storage()
+ .persistent()
+ .get(&StorageKey::DeveloperBalance(migration.to.clone()))
+ .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);
+ env.storage()
+ .persistent()
+ .set(&destination_key, &new_destination_balance);
+ env.storage()
+ .persistent()
+ .extend_ttl(&source_key, 50_000, 50_000);
+ env.storage()
+ .persistent()
+ .extend_ttl(&destination_key, 50_000, 50_000);
+
+ let mut index: Vec
= env
+ .storage()
+ .instance()
+ .get(&StorageKey::DeveloperIndex)
+ .unwrap_or_else(|| Vec::new(env));
+ CalloraSettlement::sorted_insert(env, &mut index, migration.to.clone());
+ env.storage()
+ .instance()
+ .set(&StorageKey::DeveloperIndex, &index);
+ timelock::remove_pending_migration(env, from);
+
+ env.events().publish(
+ (
+ events::event_admin_migration(env),
+ from.clone(),
+ migration.to.clone(),
+ ),
+ AdminMigrationEvent {
+ from: from.clone(),
+ to: migration.to,
+ amount: migration.amount,
+ executed_at,
+ },
+ );
+}
diff --git a/contracts/settlement/src/errors.rs b/contracts/settlement/src/errors.rs
index 25707fd..880a2dc 100644
--- a/contracts/settlement/src/errors.rs
+++ b/contracts/settlement/src/errors.rs
@@ -23,6 +23,13 @@ use soroban_sdk::contracterror;
/// | 13 | DailyWithdrawCapExceeded | Daily developer withdrawal cap would be exceeded |
/// | 14 | GasExhaustionRisk | Full scan is too large; use paginated access |
/// | 15 | ReasonTooLong | Reason `Symbol` exceeds the allowed length |
+/// | 16 | MigrationSameAddress | Migration source and target are identical |
+/// | 17 | InvalidMigrationTarget | Migration target is the settlement contract |
+/// | 18 | NoDeveloperBalance | Migration source has no positive balance |
+/// | 19 | TimelockOverflow | Timelock timestamp addition overflowed |
+/// | 20 | MigrationNotFound | No migration is pending for the source |
+/// | 21 | TimelockNotExpired | Migration delay has not elapsed |
+/// | 22 | MigrationBalanceChanged | Approved amount is no longer available |
#[contracterror]
#[derive(Clone, Copy, Debug, PartialEq)]
#[repr(u32)]
@@ -42,4 +49,11 @@ pub enum SettlementError {
DailyWithdrawCapExceeded = 13,
GasExhaustionRisk = 14,
ReasonTooLong = 15,
+ MigrationSameAddress = 16,
+ InvalidMigrationTarget = 17,
+ NoDeveloperBalance = 18,
+ TimelockOverflow = 19,
+ MigrationNotFound = 20,
+ TimelockNotExpired = 21,
+ MigrationBalanceChanged = 22,
}
diff --git a/contracts/settlement/src/events.rs b/contracts/settlement/src/events.rs
index c94fd08..9dfd41e 100644
--- a/contracts/settlement/src/events.rs
+++ b/contracts/settlement/src/events.rs
@@ -83,6 +83,16 @@ pub fn event_admin_broadcast(env: &Env) -> Symbol {
Symbol::new(env, "admin_broadcast")
}
+/// Returns the Symbol for a proposed developer balance migration.
+pub fn event_admin_migration_proposed(env: &Env) -> Symbol {
+ Symbol::new(env, "admin_migration_proposed")
+}
+
+/// Returns the Symbol for an executed developer balance migration.
+pub fn event_admin_migration(env: &Env) -> Symbol {
+ Symbol::new(env, "admin_migration")
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -160,4 +170,17 @@ mod tests {
let env = Env::default();
assert_eq!(event_admin_broadcast(&env), Symbol::new(&env, "admin_broadcast"));
}
+
+ #[test]
+ fn test_admin_migration_event_bytes() {
+ let env = Env::default();
+ assert_eq!(
+ event_admin_migration_proposed(&env),
+ Symbol::new(&env, "admin_migration_proposed")
+ );
+ assert_eq!(
+ event_admin_migration(&env),
+ Symbol::new(&env, "admin_migration")
+ );
+ }
}
diff --git a/contracts/settlement/src/lib.rs b/contracts/settlement/src/lib.rs
index 86ad38d..c1ba038 100644
--- a/contracts/settlement/src/lib.rs
+++ b/contracts/settlement/src/lib.rs
@@ -1,9 +1,14 @@
#![no_std]
-use soroban_sdk::{contract, contractimpl, contracttype, token, Address, BytesN, Env, Symbol, Vec};
+use soroban_sdk::{
+ contract, contractimpl, contracttype, token, Address, BytesN, Env, String, Symbol, Vec,
+};
+mod admin;
mod errors;
+mod timelock;
pub use errors::SettlementError;
+pub use timelock::{PendingDeveloperMigration, DEVELOPER_MIGRATION_TIMELOCK_SECONDS};
/// Maximum number of items allowed in a single `batch_receive_payment` call.
pub const MAX_BATCH_SIZE: u32 = 50;
@@ -11,6 +16,26 @@ pub const MAX_BATCH_SIZE: u32 = 50;
/// Maximum number of developer balances returned per page in paginated queries.
pub const MAX_DEVELOPER_BALANCES_PAGE_SIZE: u32 = 100;
+/// Maximum length for admin broadcast messages.
+pub const MAX_MESSAGE_LEN: u32 = 256;
+
+/// Severity level for an administrative broadcast.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum Severity {
+ Info,
+ Warn,
+ Crit,
+}
+
+/// Event payload emitted by `broadcast`.
+#[contracttype]
+#[derive(Clone, Debug, PartialEq)]
+pub struct AdminBroadcast {
+ pub severity: Severity,
+ pub message: String,
+}
+
/// Persistent storage keys for settlement contract
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
@@ -26,6 +51,7 @@ pub enum StorageKey {
DailyWithdrawCap(Address),
WithdrawalToday(Address),
ContractVersion,
+ PendingDeveloperMigration(Address),
}
/// Developer balance record in settlement contract
@@ -126,6 +152,16 @@ pub struct DeveloperForceCreditedEvent {
pub new_balance: i128,
}
+/// Emitted after an approved developer balance migration is executed.
+#[contracttype]
+#[derive(Clone, Debug, PartialEq)]
+pub struct AdminMigrationEvent {
+ pub from: Address,
+ pub to: Address,
+ pub amount: i128,
+ pub executed_at: u64,
+}
+
/// Maximum byte length for the `reason` Symbol in `force_credit_developer`.
/// The Soroban SDK enforces a 32-byte limit on Symbol values at construction;
/// this constant is used for explicit defense-in-depth validation.
@@ -409,6 +445,40 @@ impl CalloraSettlement {
.unwrap_or(0)
}
+ /// Propose moving a developer's current balance to a replacement address.
+ ///
+ /// The current admin must authorize this state change. If the admin is a
+ /// Stellar multisig account, `require_auth` enforces that account's signer
+ /// thresholds. The proposal snapshots the source balance and becomes
+ /// executable after [`DEVELOPER_MIGRATION_TIMELOCK_SECONDS`]. Re-proposing
+ /// for the same source replaces the prior proposal and restarts the delay.
+ ///
+ /// # Errors
+ /// Panics with a typed [`SettlementError`] when the caller is unauthorized,
+ /// the addresses are equal or unsafe, the source balance is empty, or the
+ /// execution timestamp cannot be represented.
+ pub fn propose_balance_migration(env: Env, caller: Address, from: Address, to: Address) {
+ admin::propose_balance_migration(&env, &caller, &from, &to);
+ }
+
+ /// Execute a matured developer balance migration proposal.
+ ///
+ /// The current admin must authorize execution independently of proposal.
+ /// Exactly the amount approved at proposal time is moved; credits received
+ /// afterward remain at `from`. The destination balance addition is checked
+ /// for overflow, and the consumed proposal is removed to prevent replay.
+ ///
+ /// # Events
+ /// Emits `admin_migration` with [`AdminMigrationEvent`] after success.
+ pub fn execute_balance_migration(env: Env, caller: Address, from: Address) {
+ admin::execute_balance_migration(&env, &caller, &from);
+ }
+
+ /// Return the pending migration for `from`, if one exists.
+ pub fn get_balance_migration(env: Env, from: Address) -> Option {
+ timelock::get_pending_migration(&env, &from)
+ }
+
/// Configure the USDC token contract address.
///
/// Only the current admin may set the on-chain USDC token address that this
@@ -1188,7 +1258,7 @@ pub fn withdraw_developer_balance(
/// and the result is a deterministic, stable ordering that cursors can rely on.
///
/// If `addr` is already present the index is left unchanged.
- fn sorted_insert(env: &Env, index: &mut Vec, addr: Address) {
+ pub(crate) fn sorted_insert(env: &Env, index: &mut Vec, addr: Address) {
// Check for duplicates and find insertion position in one pass.
let mut insert_pos: Option = None;
for (i, existing) in index.iter().enumerate() {
@@ -1222,3 +1292,6 @@ mod test_invariant;
#[cfg(test)]
mod test_error_codes;
+
+#[cfg(test)]
+mod test_admin_migration;
diff --git a/contracts/settlement/src/test.rs b/contracts/settlement/src/test.rs
index e5c6881..52462fe 100644
--- a/contracts/settlement/src/test.rs
+++ b/contracts/settlement/src/test.rs
@@ -459,7 +459,7 @@ mod settlement_tests {
let developer = Address::generate(&env);
let addr = env.register(CalloraSettlement, ());
let client = CalloraSettlementClient::new(&env, &addr);
- let (usdc_address, usdc_admin_client) = create_usdc(&env, &admin);
+ let (usdc_address, _, usdc_admin_client) = create_usdc(&env, &admin);
client.init(&admin, &vault);
client.set_usdc_token(&admin, &usdc_address);
@@ -2042,12 +2042,12 @@ mod settlement_tests {
usdc_admin_client.mint(&addr, &1000i128);
// First withdrawal of 300 should succeed (under 500 cap)
- let result = client.try_withdraw_developer_balance(&developer, &300i128);
+ let result = client.try_withdraw_developer_balance(&developer, &300i128, &None);
assert!(result.is_ok());
assert_eq!(client.get_developer_balance(&developer), 700i128);
// Second withdrawal of 300 would push total to 600 (over 500 cap)
- let result = client.try_withdraw_developer_balance(&developer, &300i128);
+ let result = client.try_withdraw_developer_balance(&developer, &300i128, &None);
assert!(is_error(result, SettlementError::DailyWithdrawCapExceeded));
assert_eq!(client.get_developer_balance(&developer), 700i128);
}
@@ -2070,16 +2070,22 @@ mod settlement_tests {
usdc_admin_client.mint(&addr, &1000i128);
// Withdraw 200 + 200 = 400, still under 500
- assert!(client.try_withdraw_developer_balance(&developer, &200i128).is_ok());
- assert!(client.try_withdraw_developer_balance(&developer, &200i128).is_ok());
+ assert!(client
+ .try_withdraw_developer_balance(&developer, &200i128, &None)
+ .is_ok());
+ assert!(client
+ .try_withdraw_developer_balance(&developer, &200i128, &None)
+ .is_ok());
assert_eq!(client.get_developer_balance(&developer), 600i128);
// Third withdrawal of 100 would push to 500 (exact cap — allowed)
- assert!(client.try_withdraw_developer_balance(&developer, &100i128).is_ok());
+ assert!(client
+ .try_withdraw_developer_balance(&developer, &100i128, &None)
+ .is_ok());
assert_eq!(client.get_developer_balance(&developer), 500i128);
// Fourth withdrawal of 1 would exceed cap
- let result = client.try_withdraw_developer_balance(&developer, &1i128);
+ let result = client.try_withdraw_developer_balance(&developer, &1i128, &None);
assert!(is_error(result, SettlementError::DailyWithdrawCapExceeded));
}
@@ -2101,7 +2107,9 @@ mod settlement_tests {
client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()));
usdc_admin_client.mint(&addr, &1000i128);
- assert!(client.try_withdraw_developer_balance(&developer, &1000i128).is_ok());
+ assert!(client
+ .try_withdraw_developer_balance(&developer, &1000i128, &None)
+ .is_ok());
assert_eq!(client.get_developer_balance(&developer), 0i128);
}
@@ -2122,7 +2130,9 @@ mod settlement_tests {
client.receive_payment(&vault, &1000i128, &false, &Some(developer.clone()));
usdc_admin_client.mint(&addr, &1000i128);
- assert!(client.try_withdraw_developer_balance(&developer, &1000i128).is_ok());
+ assert!(client
+ .try_withdraw_developer_balance(&developer, &1000i128, &None)
+ .is_ok());
assert_eq!(client.get_developer_balance(&developer), 0i128);
}
@@ -2146,11 +2156,13 @@ mod settlement_tests {
usdc_admin_client.mint(&addr, &1000i128);
// Withdraw 400 on day 0
- assert!(client.try_withdraw_developer_balance(&developer, &400i128).is_ok());
+ assert!(client
+ .try_withdraw_developer_balance(&developer, &400i128, &None)
+ .is_ok());
assert_eq!(client.get_developer_balance(&developer), 600i128);
// Another 200 would exceed the 500 cap
- let result = client.try_withdraw_developer_balance(&developer, &200i128);
+ let result = client.try_withdraw_developer_balance(&developer, &200i128, &None);
assert!(is_error(result, SettlementError::DailyWithdrawCapExceeded));
// Advance to day 1
@@ -2159,7 +2171,9 @@ mod settlement_tests {
usdc_admin_client.mint(&addr, &500i128);
// Withdrawal should succeed now (cap resets)
- assert!(client.try_withdraw_developer_balance(&developer, &500i128).is_ok());
+ assert!(client
+ .try_withdraw_developer_balance(&developer, &500i128, &None)
+ .is_ok());
assert_eq!(client.get_developer_balance(&developer), 100i128);
}
@@ -2252,10 +2266,10 @@ mod settlement_tests {
assert_eq!(client.get_withdrawal_today(&developer), 0i128);
- client.withdraw_developer_balance(&developer, &300i128);
+ client.withdraw_developer_balance(&developer, &300i128, &None);
assert_eq!(client.get_withdrawal_today(&developer), 300i128);
- client.withdraw_developer_balance(&developer, &200i128);
+ client.withdraw_developer_balance(&developer, &200i128, &None);
assert_eq!(client.get_withdrawal_today(&developer), 500i128);
}
@@ -2281,15 +2295,21 @@ mod settlement_tests {
usdc_admin_client.mint(&addr, &1500i128);
// dev1 hits cap at 500
- assert!(client.try_withdraw_developer_balance(&dev1, &300i128).is_ok());
+ assert!(client
+ .try_withdraw_developer_balance(&dev1, &300i128, &None)
+ .is_ok());
// Still within cap (300 < 500)
- assert!(client.try_withdraw_developer_balance(&dev1, &200i128).is_ok());
+ assert!(client
+ .try_withdraw_developer_balance(&dev1, &200i128, &None)
+ .is_ok());
// Exceeds cap (300 + 200 + 1 > 500)
- let result = client.try_withdraw_developer_balance(&dev1, &1i128);
+ let result = client.try_withdraw_developer_balance(&dev1, &1i128, &None);
assert!(is_error(result, SettlementError::DailyWithdrawCapExceeded));
// dev2 can still withdraw (no cap)
- assert!(client.try_withdraw_developer_balance(&dev2, &500i128).is_ok());
+ assert!(client
+ .try_withdraw_developer_balance(&dev2, &500i128, &None)
+ .is_ok());
}
// ── cursor-based pagination tests ────────────────────────────────────────
diff --git a/contracts/settlement/src/test_admin_migration.rs b/contracts/settlement/src/test_admin_migration.rs
new file mode 100644
index 0000000..00e6f35
--- /dev/null
+++ b/contracts/settlement/src/test_admin_migration.rs
@@ -0,0 +1,234 @@
+extern crate std;
+
+use crate::{
+ AdminMigrationEvent, CalloraSettlement, CalloraSettlementClient, SettlementError, StorageKey,
+ DEVELOPER_MIGRATION_TIMELOCK_SECONDS,
+};
+use soroban_sdk::testutils::{Address as _, Events as _, Ledger as _};
+use soroban_sdk::{Address, Env, Error, IntoVal, InvokeError, Symbol};
+
+fn setup() -> (Env, Address, Address, Address, Address, Address) {
+ let env = Env::default();
+ env.mock_all_auths();
+ env.ledger().set_timestamp(1_700_000_000);
+ let admin = Address::generate(&env);
+ let vault = Address::generate(&env);
+ let from = Address::generate(&env);
+ let to = Address::generate(&env);
+ let contract = env.register(CalloraSettlement, ());
+ let client = CalloraSettlementClient::new(&env, &contract);
+ client.init(&admin, &vault);
+ client.receive_payment(&vault, &500, &false, &Some(from.clone()));
+ (env, contract, admin, vault, from, to)
+}
+
+fn is_error, E: Into>(
+ result: Result, Result>,
+ expected: SettlementError,
+) -> bool {
+ match result {
+ Err(Ok(error)) => error.into().get_code() == expected as u32,
+ _ => false,
+ }
+}
+
+#[test]
+fn proposal_stores_balance_snapshot_and_deadline() {
+ let (env, contract, admin, _vault, from, to) = setup();
+ let client = CalloraSettlementClient::new(&env, &contract);
+
+ client.propose_balance_migration(&admin, &from, &to);
+
+ let pending = client.get_balance_migration(&from).unwrap();
+ assert_eq!(pending.from, from);
+ assert_eq!(pending.to, to);
+ assert_eq!(pending.amount, 500);
+ assert_eq!(pending.proposed_at, 1_700_000_000);
+ assert_eq!(
+ pending.execute_after,
+ 1_700_000_000 + DEVELOPER_MIGRATION_TIMELOCK_SECONDS
+ );
+}
+
+#[test]
+fn execution_requires_timelock_and_succeeds_at_boundary() {
+ let (env, contract, admin, _vault, from, to) = setup();
+ let client = CalloraSettlementClient::new(&env, &contract);
+ client.propose_balance_migration(&admin, &from, &to);
+
+ let early = client.try_execute_balance_migration(&admin, &from);
+ assert!(is_error(early, SettlementError::TimelockNotExpired));
+ assert_eq!(client.get_developer_balance(&from), 500);
+ assert_eq!(client.get_developer_balance(&to), 0);
+
+ env.ledger()
+ .set_timestamp(1_700_000_000 + DEVELOPER_MIGRATION_TIMELOCK_SECONDS);
+ client.execute_balance_migration(&admin, &from);
+
+ assert_eq!(client.get_developer_balance(&from), 0);
+ assert_eq!(client.get_developer_balance(&to), 500);
+ assert_eq!(client.get_balance_migration(&from), None);
+}
+
+#[test]
+fn execution_adds_to_destination_and_leaves_later_source_credits() {
+ let (env, contract, admin, vault, from, to) = setup();
+ let client = CalloraSettlementClient::new(&env, &contract);
+ client.receive_payment(&vault, &40, &false, &Some(to.clone()));
+ client.propose_balance_migration(&admin, &from, &to);
+ client.receive_payment(&vault, &25, &false, &Some(from.clone()));
+ env.ledger()
+ .set_timestamp(1_700_000_000 + DEVELOPER_MIGRATION_TIMELOCK_SECONDS);
+
+ client.execute_balance_migration(&admin, &from);
+
+ assert_eq!(client.get_developer_balance(&from), 25);
+ assert_eq!(client.get_developer_balance(&to), 540);
+}
+
+#[test]
+fn execute_emits_admin_migration_event_and_cannot_replay() {
+ let (env, contract, admin, _vault, from, to) = setup();
+ let client = CalloraSettlementClient::new(&env, &contract);
+ client.propose_balance_migration(&admin, &from, &to);
+ let executed_at = 1_700_000_000 + DEVELOPER_MIGRATION_TIMELOCK_SECONDS + 1;
+ env.ledger().set_timestamp(executed_at);
+
+ client.execute_balance_migration(&admin, &from);
+
+ let events = env.events().all();
+ let event = events
+ .iter()
+ .find(|event| {
+ let topic: Symbol = event.1.get(0).unwrap().into_val(&env);
+ topic == Symbol::new(&env, "admin_migration")
+ })
+ .expect("admin_migration event");
+ let data: AdminMigrationEvent = event.2.into_val(&env);
+ assert_eq!(data.from, from);
+ assert_eq!(data.to, to);
+ assert_eq!(data.amount, 500);
+ assert_eq!(data.executed_at, executed_at);
+
+ let replay = client.try_execute_balance_migration(&admin, &from);
+ assert!(is_error(replay, SettlementError::MigrationNotFound));
+}
+
+#[test]
+fn both_state_changes_require_current_admin_auth() {
+ let (env, contract, admin, _vault, from, to) = setup();
+ let client = CalloraSettlementClient::new(&env, &contract);
+ env.set_auths(&[]);
+ assert!(client
+ .try_propose_balance_migration(&admin, &from, &to)
+ .is_err());
+
+ env.mock_all_auths();
+ client.propose_balance_migration(&admin, &from, &to);
+ env.ledger()
+ .set_timestamp(1_700_000_000 + DEVELOPER_MIGRATION_TIMELOCK_SECONDS);
+ env.set_auths(&[]);
+ assert!(client.try_execute_balance_migration(&admin, &from).is_err());
+}
+
+#[test]
+fn unauthorized_address_is_rejected_even_when_it_authorizes() {
+ let (env, contract, _admin, _vault, from, to) = setup();
+ let client = CalloraSettlementClient::new(&env, &contract);
+ let outsider = Address::generate(&env);
+
+ let result = client.try_propose_balance_migration(&outsider, &from, &to);
+ assert!(is_error(result, SettlementError::Unauthorized));
+}
+
+#[test]
+fn invalid_proposals_are_rejected() {
+ let (env, contract, admin, _vault, from, _to) = setup();
+ let client = CalloraSettlementClient::new(&env, &contract);
+ assert!(is_error(
+ client.try_propose_balance_migration(&admin, &from, &from),
+ SettlementError::MigrationSameAddress
+ ));
+ assert!(is_error(
+ client.try_propose_balance_migration(&admin, &from, &contract),
+ SettlementError::InvalidMigrationTarget
+ ));
+ let empty = Address::generate(&env);
+ let target = Address::generate(&env);
+ assert!(is_error(
+ client.try_propose_balance_migration(&admin, &empty, &target),
+ SettlementError::NoDeveloperBalance
+ ));
+}
+
+#[test]
+fn reproposal_replaces_target_and_restarts_timelock() {
+ let (env, contract, admin, _vault, from, first_to) = setup();
+ let client = CalloraSettlementClient::new(&env, &contract);
+ let second_to = Address::generate(&env);
+ client.propose_balance_migration(&admin, &from, &first_to);
+ env.ledger().set_timestamp(1_700_000_100);
+
+ client.propose_balance_migration(&admin, &from, &second_to);
+
+ let pending = client.get_balance_migration(&from).unwrap();
+ assert_eq!(pending.to, second_to);
+ assert_eq!(
+ pending.execute_after,
+ 1_700_000_100 + DEVELOPER_MIGRATION_TIMELOCK_SECONDS
+ );
+}
+
+#[test]
+fn proposal_rejects_timestamp_overflow() {
+ let (env, contract, admin, _vault, from, to) = setup();
+ let client = CalloraSettlementClient::new(&env, &contract);
+ env.ledger().set_timestamp(u64::MAX);
+
+ let result = client.try_propose_balance_migration(&admin, &from, &to);
+
+ assert!(is_error(result, SettlementError::TimelockOverflow));
+ assert_eq!(client.get_balance_migration(&from), None);
+}
+
+#[test]
+fn execution_rejects_a_spent_snapshot_without_partial_writes() {
+ let (env, contract, admin, _vault, from, to) = setup();
+ let client = CalloraSettlementClient::new(&env, &contract);
+ client.propose_balance_migration(&admin, &from, &to);
+ env.as_contract(&contract, || {
+ env.storage()
+ .persistent()
+ .set(&StorageKey::DeveloperBalance(from.clone()), &499_i128);
+ });
+ env.ledger()
+ .set_timestamp(1_700_000_000 + DEVELOPER_MIGRATION_TIMELOCK_SECONDS);
+
+ let result = client.try_execute_balance_migration(&admin, &from);
+
+ assert!(is_error(result, SettlementError::MigrationBalanceChanged));
+ assert_eq!(client.get_developer_balance(&from), 499);
+ assert_eq!(client.get_developer_balance(&to), 0);
+ assert!(client.get_balance_migration(&from).is_some());
+}
+
+#[test]
+fn destination_overflow_reverts_all_migration_state() {
+ let (env, contract, admin, _vault, from, to) = setup();
+ let client = CalloraSettlementClient::new(&env, &contract);
+ client.propose_balance_migration(&admin, &from, &to);
+ env.as_contract(&contract, || {
+ env.storage()
+ .persistent()
+ .set(&StorageKey::DeveloperBalance(to.clone()), &i128::MAX);
+ });
+ env.ledger()
+ .set_timestamp(1_700_000_000 + DEVELOPER_MIGRATION_TIMELOCK_SECONDS);
+
+ let result = client.try_execute_balance_migration(&admin, &from);
+
+ assert!(is_error(result, SettlementError::DeveloperOverflow));
+ assert_eq!(client.get_developer_balance(&from), 500);
+ assert_eq!(client.get_developer_balance(&to), i128::MAX);
+ assert!(client.get_balance_migration(&from).is_some());
+}
diff --git a/contracts/settlement/src/test_error_codes.rs b/contracts/settlement/src/test_error_codes.rs
index ecf3b44..83d3741 100644
--- a/contracts/settlement/src/test_error_codes.rs
+++ b/contracts/settlement/src/test_error_codes.rs
@@ -21,6 +21,13 @@ fn settlement_error_codes_are_stable_and_unique() {
(13, SettlementError::DailyWithdrawCapExceeded),
(14, SettlementError::GasExhaustionRisk),
(15, SettlementError::ReasonTooLong),
+ (16, SettlementError::MigrationSameAddress),
+ (17, SettlementError::InvalidMigrationTarget),
+ (18, SettlementError::NoDeveloperBalance),
+ (19, SettlementError::TimelockOverflow),
+ (20, SettlementError::MigrationNotFound),
+ (21, SettlementError::TimelockNotExpired),
+ (22, SettlementError::MigrationBalanceChanged),
];
let mut seen = BTreeSet::new();
@@ -32,7 +39,7 @@ fn settlement_error_codes_are_stable_and_unique() {
);
}
- assert_eq!(seen.len(), 15);
+ assert_eq!(seen.len(), 22);
}
#[test]
@@ -54,6 +61,13 @@ fn error_code_docs_list_every_settlement_code() {
"| 13 | `DailyWithdrawCapExceeded` | Settlement | Daily developer withdrawal cap would be exceeded |",
"| 14 | `GasExhaustionRisk` | Settlement | Full scan is too large; use paginated access |",
"| 15 | `ReasonTooLong` | Settlement | Reason `Symbol` exceeds the allowed length |",
+ "| 16 | `MigrationSameAddress` | Settlement | Migration source and target are identical |",
+ "| 17 | `InvalidMigrationTarget` | Settlement | Migration target is the settlement contract |",
+ "| 18 | `NoDeveloperBalance` | Settlement | Migration source has no positive balance |",
+ "| 19 | `TimelockOverflow` | Settlement | Timelock timestamp addition overflowed |",
+ "| 20 | `MigrationNotFound` | Settlement | No migration is pending for the source |",
+ "| 21 | `TimelockNotExpired` | Settlement | Migration delay has not elapsed |",
+ "| 22 | `MigrationBalanceChanged` | Settlement | Approved amount is no longer available |",
];
for line in expected_lines {
diff --git a/contracts/settlement/src/test_invariant.rs b/contracts/settlement/src/test_invariant.rs
index 06c51e9..86e1a3a 100644
--- a/contracts/settlement/src/test_invariant.rs
+++ b/contracts/settlement/src/test_invariant.rs
@@ -33,6 +33,8 @@
extern crate std;
+use std::boxed::Box;
+
use proptest::prelude::*;
use soroban_sdk::testutils::Address as _;
use soroban_sdk::{token, Address, Env, Vec};
@@ -207,7 +209,7 @@ fn setup_env() -> (
let contract = env.register(CalloraSettlement, ());
// Mint a large enough USDC reserve so withdrawals don't run out.
- let (usdc_addr, _usdc_client, usdc_sac) = make_usdc(env, &contract, i128::MAX / 2);
+ let (usdc_addr, _usdc_client, _usdc_sac) = make_usdc(env, &contract, i128::MAX / 2);
let client = CalloraSettlementClient::new(env, &contract);
client.init(&admin, &vault);
@@ -216,7 +218,15 @@ fn setup_env() -> (
let usdc_sac_static: token::StellarAssetClient<'static> =
token::StellarAssetClient::new(env, &usdc_addr);
- (env, contract, client, admin, vault, usdc_addr, usdc_sac_static)
+ (
+ (*env).clone(),
+ contract,
+ client,
+ admin,
+ vault,
+ usdc_addr,
+ usdc_sac_static,
+ )
}
// ---------------------------------------------------------------------------
@@ -228,7 +238,7 @@ fn setup_env() -> (
/// The global pool is tracked separately; this checks only the developer side.
/// A full conservation check is: `total_in == dev_sum + pool`.
fn check_invariant(
- env: &Env,
+ _env: &Env,
client: &CalloraSettlementClient<'_>,
admin: &Address,
expected_dev_total: i128,
diff --git a/contracts/settlement/src/timelock.rs b/contracts/settlement/src/timelock.rs
new file mode 100644
index 0000000..d7f3d35
--- /dev/null
+++ b/contracts/settlement/src/timelock.rs
@@ -0,0 +1,43 @@
+//! Timelock state and storage helpers for developer balance migrations.
+
+use soroban_sdk::{contracttype, Address, Env};
+
+use crate::StorageKey;
+
+/// Mandatory delay between proposing and executing a balance migration.
+pub const DEVELOPER_MIGRATION_TIMELOCK_SECONDS: u64 = 86_400;
+
+/// Immutable approval snapshot stored for a pending developer migration.
+#[contracttype]
+#[derive(Clone, Debug, PartialEq)]
+pub struct PendingDeveloperMigration {
+ pub from: Address,
+ pub to: Address,
+ pub amount: i128,
+ pub proposed_at: u64,
+ pub execute_after: u64,
+}
+
+/// Read a pending migration without mutating contract state.
+pub(crate) fn get_pending_migration(
+ env: &Env,
+ from: &Address,
+) -> Option {
+ env.storage()
+ .persistent()
+ .get(&StorageKey::PendingDeveloperMigration(from.clone()))
+}
+
+/// Persist a pending migration and refresh its storage lifetime.
+pub(crate) fn set_pending_migration(env: &Env, migration: &PendingDeveloperMigration) {
+ let key = StorageKey::PendingDeveloperMigration(migration.from.clone());
+ env.storage().persistent().set(&key, migration);
+ env.storage().persistent().extend_ttl(&key, 50_000, 50_000);
+}
+
+/// Consume a successfully executed proposal to make replay impossible.
+pub(crate) fn remove_pending_migration(env: &Env, from: &Address) {
+ env.storage()
+ .persistent()
+ .remove(&StorageKey::PendingDeveloperMigration(from.clone()));
+}
diff --git a/docs/ACCESS_CONTROL.md b/docs/ACCESS_CONTROL.md
index 683d2da..29a9ab4 100644
--- a/docs/ACCESS_CONTROL.md
+++ b/docs/ACCESS_CONTROL.md
@@ -94,6 +94,11 @@ Allows the current admin to cancel a pending admin transfer before the nominee a
### Overview
The Callora Settlement contract tracks individual developer balances and global protocol revenue. It enforces strict access control for incoming payments and administrative updates.
+Developer address compliance recoveries use `propose_balance_migration` and
+`execute_balance_migration`. Both calls require authorization by the current
+admin address, including its native Stellar multisig thresholds, and execution
+is delayed by 24 hours. See [Admin developer balance migration](ADMIN_BALANCE_MIGRATION.md).
+
### Roles
- **Admin**: Primary authority over contract configuration and sensitive data.
- **Vault**: The registered vault contract authorized to send payments.
diff --git a/docs/ADMIN_BALANCE_MIGRATION.md b/docs/ADMIN_BALANCE_MIGRATION.md
new file mode 100644
index 0000000..fc32401
--- /dev/null
+++ b/docs/ADMIN_BALANCE_MIGRATION.md
@@ -0,0 +1,44 @@
+# Admin developer balance migration
+
+The settlement contract supports compliance recovery when a developer must move
+their accrued balance to a replacement address. Recovery is a two-transaction,
+admin-only workflow with a fixed 24-hour timelock.
+
+## Workflow
+
+1. Call `propose_balance_migration(admin, from, to)`.
+2. Record and review the emitted `admin_migration_proposed` event.
+3. Wait until the proposal's `execute_after` timestamp. The proposal can be
+ queried with `get_balance_migration(from)`.
+4. Call `execute_balance_migration(admin, from)`.
+5. Verify the `admin_migration` event and both balances.
+
+Both state-changing calls invoke `admin.require_auth()`. When `Admin` is a
+Stellar multisig account, Stellar's account thresholds and signer weights are
+therefore enforced for each transaction. Operators should configure the admin
+account's medium threshold to match their governance policy.
+
+## Security semantics
+
+- A proposal snapshots the positive source balance. Credits arriving after the
+ proposal remain at the source and require a later proposal.
+- Re-proposing the same source replaces the proposal and restarts the full
+ delay. This provides a safe correction path for an incorrect target.
+- The destination may already have a balance; addition uses checked `i128`
+ arithmetic. All writes are atomic under Soroban transaction semantics.
+- Successful execution deletes the proposal, preventing replay.
+- The settlement contract itself cannot be the destination, and source and
+ destination must differ.
+- If the source spends enough of the approved balance before execution,
+ execution fails with `MigrationBalanceChanged`; governance must re-propose.
+
+The migration changes internal settlement accounting only. It does not transfer
+USDC on-ledger because those funds remain held by the settlement contract.
+
+## Events
+
+`admin_migration_proposed` uses topics `(event, from)` and stores the complete
+`PendingDeveloperMigration` as event data.
+
+`admin_migration` uses topics `(event, from, to)` and stores
+`AdminMigrationEvent { from, to, amount, executed_at }` as event data.
diff --git a/docs/ERROR_CODES.md b/docs/ERROR_CODES.md
index 16785f6..1b9402b 100644
--- a/docs/ERROR_CODES.md
+++ b/docs/ERROR_CODES.md
@@ -69,6 +69,13 @@ must not be reassigned once released.
| 13 | `DailyWithdrawCapExceeded` | Settlement | Daily developer withdrawal cap would be exceeded |
| 14 | `GasExhaustionRisk` | Settlement | Full scan is too large; use paginated access |
| 15 | `ReasonTooLong` | Settlement | Reason `Symbol` exceeds the allowed length |
+| 16 | `MigrationSameAddress` | Settlement | Migration source and target are identical |
+| 17 | `InvalidMigrationTarget` | Settlement | Migration target is the settlement contract |
+| 18 | `NoDeveloperBalance` | Settlement | Migration source has no positive balance |
+| 19 | `TimelockOverflow` | Settlement | Timelock timestamp addition overflowed |
+| 20 | `MigrationNotFound` | Settlement | No migration is pending for the source |
+| 21 | `TimelockNotExpired` | Settlement | Migration delay has not elapsed |
+| 22 | `MigrationBalanceChanged` | Settlement | Approved amount is no longer available |
## Revenue Pool
diff --git a/docs/interfaces/settlement.json b/docs/interfaces/settlement.json
index ca2ff55..4c7a7af 100644
--- a/docs/interfaces/settlement.json
+++ b/docs/interfaces/settlement.json
@@ -70,9 +70,22 @@
},
{
"code": 13,
+ "name": "DailyWithdrawCapExceeded",
+ "when": "A withdrawal would exceed the developer's daily cap."
+ },
+ {
+ "code": 14,
"name": "GasExhaustionRisk",
"when": "Index exceeds 100 entries; use get_developer_balances_cursor instead of get_all_developer_balances."
- }
+ },
+ { "code": 15, "name": "ReasonTooLong", "when": "Reason Symbol exceeds the allowed length." },
+ { "code": 16, "name": "MigrationSameAddress", "when": "Migration source and target are identical." },
+ { "code": 17, "name": "InvalidMigrationTarget", "when": "Migration target is the settlement contract." },
+ { "code": 18, "name": "NoDeveloperBalance", "when": "Migration source has no positive balance." },
+ { "code": 19, "name": "TimelockOverflow", "when": "Timelock timestamp addition overflowed." },
+ { "code": 20, "name": "MigrationNotFound", "when": "No migration is pending for the source." },
+ { "code": 21, "name": "TimelockNotExpired", "when": "Migration delay has not elapsed." },
+ { "code": 22, "name": "MigrationBalanceChanged", "when": "The approved amount is no longer available at the source." }
]
},
@@ -90,6 +103,25 @@
}
}
},
+ "PendingDeveloperMigration": {
+ "description": "Timelocked snapshot of an approved developer balance migration.",
+ "fields": {
+ "from": { "type": "Address" },
+ "to": { "type": "Address" },
+ "amount": { "type": "i128" },
+ "proposed_at": { "type": "u64" },
+ "execute_after": { "type": "u64" }
+ }
+ },
+ "AdminMigrationEvent": {
+ "description": "Audit payload emitted when a migration executes.",
+ "fields": {
+ "from": { "type": "Address" },
+ "to": { "type": "Address" },
+ "amount": { "type": "i128" },
+ "executed_at": { "type": "u64" }
+ }
+ },
"GlobalPool": {
"description": "Aggregate pool state.",
"fields": {
@@ -433,6 +465,37 @@
]
},
+ {
+ "name": "propose_balance_migration",
+ "description": "Snapshot a developer balance and begin the fixed 24-hour migration timelock. Re-proposal replaces the prior target and restarts the delay.",
+ "access": "admin (must sign; native account multisig thresholds apply)",
+ "params": [
+ { "name": "caller", "type": "Address", "optional": false },
+ { "name": "from", "type": "Address", "optional": false },
+ { "name": "to", "type": "Address", "optional": false }
+ ],
+ "returns": "void",
+ "events": [{ "topics": ["admin_migration_proposed", "from"], "data": "PendingDeveloperMigration" }]
+ },
+ {
+ "name": "execute_balance_migration",
+ "description": "Move the approved balance snapshot after the timelock and consume the proposal.",
+ "access": "admin (must sign; native account multisig thresholds apply)",
+ "params": [
+ { "name": "caller", "type": "Address", "optional": false },
+ { "name": "from", "type": "Address", "optional": false }
+ ],
+ "returns": "void",
+ "events": [{ "topics": ["admin_migration", "from", "to"], "data": "AdminMigrationEvent" }]
+ },
+ {
+ "name": "get_balance_migration",
+ "description": "Return the pending migration for a source address, or null.",
+ "access": "any",
+ "params": [{ "name": "from", "type": "Address", "optional": false }],
+ "returns": "PendingDeveloperMigration | null",
+ "events": []
+ },
{
"name": "set_vault",
"description": "Update the registered vault address. Only admin may call this.",