|
| 1 | +/// Privileged administration: admin rotation, token update, pause toggle, drain. |
| 2 | +/// |
| 3 | +/// # Centralization disclosure (for users / auditors) |
| 4 | +/// |
| 5 | +/// Community policyholders govern claim outcomes via DAO voting — no admin |
| 6 | +/// override exists on individual claims. However, the following protocol |
| 7 | +/// parameters remain admin-controlled in the MVP: |
| 8 | +/// |
| 9 | +/// - Token contract address (treasury asset) |
| 10 | +/// - Pause / unpause (emergency circuit-breaker) |
| 11 | +/// - Admin key itself (rotation) |
| 12 | +/// - Treasury drain (emergency fund recovery) |
| 13 | +/// |
| 14 | +/// This is a deliberate MVP trade-off. The seams for future decentralisation |
| 15 | +/// are documented below each function. Production deployments SHOULD use a |
| 16 | +/// Stellar multisig account (e.g. 3-of-5 signers) as the admin address. |
| 17 | +/// |
| 18 | +/// # Multisig guidance for production |
| 19 | +/// |
| 20 | +/// Stellar natively supports weighted multisig via `set_options`. Recommended |
| 21 | +/// setup: |
| 22 | +/// - Create a dedicated admin account with master weight 0. |
| 23 | +/// - Add 5 co-signer keys with weight 1 each; set thresholds to 3. |
| 24 | +/// - The resulting address is the `admin` passed to `initialize`. |
| 25 | +/// - All admin calls require 3-of-5 signatures in the transaction envelope. |
| 26 | +/// - For higher assurance, use a hardware-wallet-backed signer set. |
| 27 | +/// |
| 28 | +/// # Future timelock / governance seam |
| 29 | +/// |
| 30 | +/// Each privileged setter is a single function call today. To add a timelock: |
| 31 | +/// 1. Replace the direct write with a `Proposal { action, value, eta }` stored |
| 32 | +/// at `DataKey::Proposal(action_id)`. |
| 33 | +/// 2. Add `execute_proposal(env, action_id)` that checks `env.ledger().timestamp() |
| 34 | +/// >= eta` before applying. |
| 35 | +/// 3. The event schema below is already action-typed, so the NestJS |
| 36 | +/// `admin_audit_log` ingestion requires no changes. |
| 37 | +/// |
| 38 | +/// # Event schema (machine-readable for NestJS ingestion) |
| 39 | +/// |
| 40 | +/// Every mutation emits: |
| 41 | +/// topic: ("admin", "<action_name>") |
| 42 | +/// payload: depends on action — see individual functions below. |
| 43 | +/// |
| 44 | +/// The NestJS handler can key on `topic[1]` (the action symbol) to route to |
| 45 | +/// the correct `admin_audit_log` column without per-action parsers. |
| 46 | +use soroban_sdk::{contracttype, panic_with_error, symbol_short, Address, Env}; |
| 47 | + |
| 48 | +use crate::storage; |
| 49 | + |
| 50 | +// ── Error codes ─────────────────────────────────────────────────────────────── |
| 51 | + |
| 52 | +#[contracttype] |
| 53 | +#[derive(Copy, Clone, Debug, PartialEq)] |
| 54 | +#[repr(u32)] |
| 55 | +pub enum AdminError { |
| 56 | + /// Caller is not the current admin. |
| 57 | + Unauthorized = 100, |
| 58 | + /// initialize() has already been called. |
| 59 | + AlreadyInitialized = 101, |
| 60 | + /// No pending admin proposal exists. |
| 61 | + NoPendingAdmin = 102, |
| 62 | + /// Caller is not the pending admin. |
| 63 | + NotPendingAdmin = 103, |
| 64 | + /// Supplied address is the zero/invalid sentinel. |
| 65 | + InvalidAddress = 104, |
| 66 | + /// Drain amount must be > 0. |
| 67 | + InvalidDrainAmount = 105, |
| 68 | +} |
| 69 | + |
| 70 | +// ── Auth helper ─────────────────────────────────────────────────────────────── |
| 71 | + |
| 72 | +/// Loads the admin, calls `require_auth()`, and returns the address. |
| 73 | +/// Panics with `AdminError::Unauthorized` if storage has no admin yet |
| 74 | +/// (should never happen after initialize, but guards against mis-ordering). |
| 75 | +pub fn require_admin(env: &Env) -> Address { |
| 76 | + let admin = env |
| 77 | + .storage() |
| 78 | + .instance() |
| 79 | + .get::<_, Address>(&storage::DataKey::Admin) |
| 80 | + .unwrap_or_else(|| panic_with_error!(env, AdminError::Unauthorized)); |
| 81 | + admin.require_auth(); |
| 82 | + admin |
| 83 | +} |
| 84 | + |
| 85 | +// ── Admin rotation (two-step handoff) ──────────────────────────────────────── |
| 86 | +// |
| 87 | +// Two-step pattern chosen over immediate replacement because: |
| 88 | +// - Immediate replacement risks locking out the protocol if the new address |
| 89 | +// is a typo or an uncontrolled key. |
| 90 | +// - Two-step requires the incoming admin to prove key control before the |
| 91 | +// handoff completes, eliminating that class of operational error. |
| 92 | +// |
| 93 | +// Flow: |
| 94 | +// 1. current admin calls propose_admin(new_admin) |
| 95 | +// 2. new_admin calls accept_admin() → rotation complete |
| 96 | +// OR current admin calls cancel_admin() → proposal withdrawn |
| 97 | +// |
| 98 | +// Future timelock seam: step 1 could store an `eta` and step 2 could check it. |
| 99 | + |
| 100 | +/// Propose a new admin address. The current admin must authorize. |
| 101 | +/// Emits: ("admin", "proposed") → (old_admin, new_admin) |
| 102 | +pub fn propose_admin(env: &Env, new_admin: Address) { |
| 103 | + let current = require_admin(env); |
| 104 | + |
| 105 | + // Reject zero-address / self-proposal is allowed (idempotent re-proposal) |
| 106 | + // but the address must be a valid Soroban Address (type system guarantees this). |
| 107 | + |
| 108 | + storage::set_pending_admin(env, &new_admin); |
| 109 | + |
| 110 | + env.events().publish( |
| 111 | + (symbol_short!("admin"), symbol_short!("proposed")), |
| 112 | + (current, new_admin), |
| 113 | + ); |
| 114 | +} |
| 115 | + |
| 116 | +/// Accept a pending admin proposal. The *new* (pending) admin must authorize. |
| 117 | +/// Emits: ("admin", "accepted") → (old_admin, new_admin) |
| 118 | +pub fn accept_admin(env: &Env) { |
| 119 | + let pending = storage::get_pending_admin(env) |
| 120 | + .unwrap_or_else(|| panic_with_error!(env, AdminError::NoPendingAdmin)); |
| 121 | + |
| 122 | + // The pending admin must sign — prevents hijack by unrelated signers |
| 123 | + pending.require_auth(); |
| 124 | + |
| 125 | + let old_admin = storage::get_admin(env); |
| 126 | + storage::set_admin(env, &pending); |
| 127 | + storage::clear_pending_admin(env); |
| 128 | + |
| 129 | + env.events().publish( |
| 130 | + (symbol_short!("admin"), symbol_short!("accepted")), |
| 131 | + (old_admin, pending), |
| 132 | + ); |
| 133 | +} |
| 134 | + |
| 135 | +/// Cancel a pending admin proposal. Only the current admin may cancel. |
| 136 | +/// Emits: ("admin", "cancelled") → (current_admin, cancelled_pending) |
| 137 | +pub fn cancel_admin(env: &Env) { |
| 138 | + let current = require_admin(env); |
| 139 | + let pending = storage::get_pending_admin(env) |
| 140 | + .unwrap_or_else(|| panic_with_error!(env, AdminError::NoPendingAdmin)); |
| 141 | + |
| 142 | + storage::clear_pending_admin(env); |
| 143 | + |
| 144 | + env.events().publish( |
| 145 | + (symbol_short!("admin"), symbol_short!("cancelled")), |
| 146 | + (current, pending), |
| 147 | + ); |
| 148 | +} |
| 149 | + |
| 150 | +// ── Token update ────────────────────────────────────────────────────────────── |
| 151 | +// |
| 152 | +// Future governance seam: replace with a proposal + timelock so token |
| 153 | +// migrations are visible on-chain before they take effect. |
| 154 | + |
| 155 | +/// Update the treasury token contract address. |
| 156 | +/// Emits: ("admin", "token_set") → (old_token, new_token) |
| 157 | +pub fn set_token(env: &Env, new_token: Address) { |
| 158 | + let _admin = require_admin(env); |
| 159 | + |
| 160 | + let old_token = storage::get_token(env); |
| 161 | + storage::set_token(env, &new_token); |
| 162 | + |
| 163 | + env.events().publish( |
| 164 | + (symbol_short!("admin"), symbol_short!("token")), |
| 165 | + (old_token, new_token), |
| 166 | + ); |
| 167 | +} |
| 168 | + |
| 169 | +// ── Pause toggle ────────────────────────────────────────────────────────────── |
| 170 | +// |
| 171 | +// Pause blocks file_claim and vote_on_claim (see claim.rs). |
| 172 | +// It does NOT retroactively invalidate in-flight votes or tallies. |
| 173 | +// Future seam: add a community-vote-triggered unpause path. |
| 174 | + |
| 175 | +/// Pause the contract. Admin must authorize. |
| 176 | +/// Emits: ("admin", "paused") → (admin) |
| 177 | +pub fn pause(env: &Env) { |
| 178 | + let admin = require_admin(env); |
| 179 | + storage::set_paused(env, true); |
| 180 | + env.events() |
| 181 | + .publish((symbol_short!("admin"), symbol_short!("paused")), (admin,)); |
| 182 | +} |
| 183 | + |
| 184 | +/// Unpause the contract. Admin must authorize. |
| 185 | +/// Emits: ("admin", "unpaused") → (admin) |
| 186 | +pub fn unpause(env: &Env) { |
| 187 | + let admin = require_admin(env); |
| 188 | + storage::set_paused(env, false); |
| 189 | + env.events().publish( |
| 190 | + (symbol_short!("admin"), symbol_short!("unpaused")), |
| 191 | + (admin,), |
| 192 | + ); |
| 193 | +} |
| 194 | + |
| 195 | +// ── Treasury drain ──────────────────────────────────────────────────────────── |
| 196 | +// |
| 197 | +// Emergency fund recovery. Transfers `amount` stroops of the treasury token |
| 198 | +// from the contract to `recipient`. Admin must authorize. |
| 199 | +// |
| 200 | +// Future governance seam: require a time-delayed proposal before drain executes, |
| 201 | +// giving policyholders a window to exit if they disagree with the action. |
| 202 | + |
| 203 | +/// Drain `amount` stroops from the contract treasury to `recipient`. |
| 204 | +/// Emits: ("admin", "drained") → (admin, recipient, amount) |
| 205 | +pub fn drain(env: &Env, recipient: Address, amount: i128) { |
| 206 | + let admin = require_admin(env); |
| 207 | + |
| 208 | + if amount <= 0 { |
| 209 | + panic_with_error!(env, AdminError::InvalidDrainAmount); |
| 210 | + } |
| 211 | + |
| 212 | + let token = storage::get_token(env); |
| 213 | + crate::token::transfer(env, &token, &env.current_contract_address(), &recipient, amount); |
| 214 | + |
| 215 | + env.events().publish( |
| 216 | + (symbol_short!("admin"), symbol_short!("drained")), |
| 217 | + (admin, recipient, amount), |
| 218 | + ); |
| 219 | +} |
0 commit comments