Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions contracts/settlement/src/admin.rs
Original file line number Diff line number Diff line change
@@ -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<Address> = 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,
},
);
}
14 changes: 14 additions & 0 deletions contracts/settlement/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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,
}
23 changes: 23 additions & 0 deletions contracts/settlement/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -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")
);
}
}
77 changes: 75 additions & 2 deletions contracts/settlement/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,41 @@
#![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;

/// 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)]
Expand All @@ -26,6 +51,7 @@ pub enum StorageKey {
DailyWithdrawCap(Address),
WithdrawalToday(Address),
ContractVersion,
PendingDeveloperMigration(Address),
}

/// Developer balance record in settlement contract
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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<PendingDeveloperMigration> {
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
Expand Down Expand Up @@ -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<Address>, addr: Address) {
pub(crate) fn sorted_insert(env: &Env, index: &mut Vec<Address>, addr: Address) {
// Check for duplicates and find insertion position in one pass.
let mut insert_pos: Option<u32> = None;
for (i, existing) in index.iter().enumerate() {
Expand Down Expand Up @@ -1222,3 +1292,6 @@ mod test_invariant;

#[cfg(test)]
mod test_error_codes;

#[cfg(test)]
mod test_admin_migration;
Loading
Loading