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.",