From 980397fd4b0dd06bcd34aa3a28b9c022d0e0451f Mon Sep 17 00:00:00 2001 From: HuiNeng <3650306360@qq.com> Date: Wed, 25 Mar 2026 04:52:27 +0800 Subject: [PATCH] feat(admin): implement comprehensive emergency pause mechanism - Add is_paused() function to check pause state with audit trail - Add emergency_withdraw() for admin fund recovery during incidents - Add PauseInfo struct with timestamp and reason for transparency - Update pause() to accept pause_reason parameter - Add PoolNotPaused, EmergencyWithdrawExceedsBalance, InvalidEmergencyAmount errors - Add EmergencyWithdrawEvent for audit trail - Update PoolPausedEvent and PoolUnpausedEvent with timestamp - Add comprehensive tests for all new functionality Resolves #15 --- contracts/privacy_pool/src/contract.rs | 23 ++- contracts/privacy_pool/src/core/admin.rs | 133 +++++++++++++- contracts/privacy_pool/src/storage/config.rs | 25 ++- contracts/privacy_pool/src/test.rs | 177 ++++++++++++++++++- contracts/privacy_pool/src/types/errors.rs | 8 + contracts/privacy_pool/src/types/events.rs | 34 +++- contracts/privacy_pool/src/types/state.rs | 19 ++ 7 files changed, 399 insertions(+), 20 deletions(-) diff --git a/contracts/privacy_pool/src/contract.rs b/contracts/privacy_pool/src/contract.rs index 9de96ee..fa126b5 100644 --- a/contracts/privacy_pool/src/contract.rs +++ b/contracts/privacy_pool/src/contract.rs @@ -94,8 +94,9 @@ impl PrivacyPool { // ────────────────────────────────────────────────────────── /// Pause the pool (admin only). - pub fn pause(env: Env, admin: Address) -> Result<(), Error> { - admin::pause(env, admin) + /// Stores pause reason and timestamp for audit trail. + pub fn pause(env: Env, admin: Address, pause_reason: soroban_sdk::String) -> Result<(), Error> { + admin::pause(env, admin, pause_reason) } /// Unpause the pool (admin only). @@ -103,6 +104,24 @@ impl PrivacyPool { admin::unpause(env, admin) } + /// Check if the pool is paused. + /// Returns (is_paused, Option) with audit trail. + pub fn is_paused(env: Env) -> Result<(bool, Option), Error> { + admin::is_paused(env) + } + + /// Emergency withdraw - admin can recover funds during security incidents. + /// Pool MUST be paused before calling. + pub fn emergency_withdraw( + env: Env, + admin: Address, + recipient: Address, + amount: i128, + reason: soroban_sdk::String, + ) -> Result<(), Error> { + admin::emergency_withdraw(env, admin, recipient, amount, reason) + } + /// Update the Groth16 verifying key (admin only). pub fn set_verifying_key( env: Env, diff --git a/contracts/privacy_pool/src/core/admin.rs b/contracts/privacy_pool/src/core/admin.rs index b6886a2..ec9b3dd 100644 --- a/contracts/privacy_pool/src/core/admin.rs +++ b/contracts/privacy_pool/src/core/admin.rs @@ -2,41 +2,160 @@ // Admin Functions - Pool management // ============================================================ -use soroban_sdk::{Address, Env}; +use soroban_sdk::{token, Address, Env, String}; use crate::storage::config; use crate::types::errors::Error; -use crate::types::events::{emit_pool_paused, emit_pool_unpaused, emit_vk_updated}; -use crate::types::state::VerifyingKey; +use crate::types::events::{emit_emergency_withdraw, emit_pool_paused, emit_pool_unpaused, emit_vk_updated}; +use crate::types::state::{PauseInfo, VerifyingKey}; use crate::utils::validation; /// Pause the pool - blocks deposits and withdrawals. /// Only callable by admin. -pub fn pause(env: Env, admin: Address) -> Result<(), Error> { +/// +/// # Arguments +/// - `env` - Soroban environment +/// - `admin` - Admin address (must authorize) +/// - `pause_reason` - Human-readable reason for pause (for audit trail) +/// +/// # Events +/// Emits `PoolPausedEvent` with admin, reason, and timestamp. +pub fn pause(env: Env, admin: Address, pause_reason: String) -> Result<(), Error> { admin.require_auth(); let mut pool_config = config::load(&env)?; validation::require_admin(&admin, &pool_config)?; + // Check if already paused + if pool_config.paused { + return Ok(()); // Idempotent - already paused + } + + // Get current timestamp + let timestamp = env.ledger().timestamp(); + + // Update pool state pool_config.paused = true; config::save(&env, &pool_config); + + // Save pause info for audit trail + let pause_info = PauseInfo { + pause_timestamp: timestamp, + pause_reason: pause_reason.clone(), + paused_by: admin.clone(), + }; + config::save_pause_info(&env, &pause_info); - emit_pool_paused(&env, admin); + emit_pool_paused(&env, admin, pause_reason, timestamp); Ok(()) } /// Unpause the pool. /// Only callable by admin. +/// +/// # Events +/// Emits `PoolUnpausedEvent` with admin and timestamp. pub fn unpause(env: Env, admin: Address) -> Result<(), Error> { admin.require_auth(); let mut pool_config = config::load(&env)?; validation::require_admin(&admin, &pool_config)?; + // Check if already unpaused + if !pool_config.paused { + return Ok(()); // Idempotent - already unpaused + } + + // Get current timestamp + let timestamp = env.ledger().timestamp(); + + // Update pool state pool_config.paused = false; config::save(&env, &pool_config); + + // Clear pause info + config::clear_pause_info(&env); - emit_pool_unpaused(&env, admin); + emit_pool_unpaused(&env, admin, timestamp); + Ok(()) +} + +/// Check if the pool is currently paused. +/// Returns the paused state and pause info if paused. +pub fn is_paused(env: Env) -> Result<(bool, Option), Error> { + let pool_config = config::load(&env)?; + + if pool_config.paused { + let pause_info = config::load_pause_info(&env); + Ok((true, Some(pause_info))) + } else { + Ok((false, None)) + } +} + +/// Emergency withdraw - allows admin to recover funds during security incidents. +/// Pool MUST be paused before calling this function. +/// +/// # Security Considerations +/// - This function bypasses all privacy guarantees +/// - Should only be used in genuine emergencies +/// - Emits event for transparency and audit +/// +/// # Arguments +/// - `env` - Soroban environment +/// - `admin` - Admin address (must authorize) +/// - `recipient` - Address to receive the funds +/// - `amount` - Amount to withdraw (in stroops/microunits) +/// - `reason` - Human-readable reason for emergency withdrawal +/// +/// # Errors +/// - `Error::UnauthorizedAdmin` if caller is not admin +/// - `Error::PoolNotPaused` if pool is not paused +/// - `Error::InvalidEmergencyAmount` if amount is 0 +/// - `Error::EmergencyWithdrawExceedsBalance` if amount > contract balance +pub fn emergency_withdraw( + env: Env, + admin: Address, + recipient: Address, + amount: i128, + reason: String, +) -> Result<(), Error> { + admin.require_auth(); + + let pool_config = config::load(&env)?; + validation::require_admin(&admin, &pool_config)?; + + // Security check: pool must be paused + if !pool_config.paused { + return Err(Error::PoolNotPaused); + } + + // Validate amount + if amount <= 0 { + return Err(Error::InvalidEmergencyAmount); + } + + // Check contract balance + let token_client = token::Client::new(&env, &pool_config.token); + let contract_balance = token_client.balance(&env.current_contract_address()); + + if amount > contract_balance { + return Err(Error::EmergencyWithdrawExceedsBalance); + } + + // Get timestamp for event + let timestamp = env.ledger().timestamp(); + + // Execute transfer + token_client.transfer( + &env.current_contract_address(), + &recipient, + &amount, + ); + + // Emit event for audit trail + emit_emergency_withdraw(&env, admin, recipient, amount, timestamp, reason); + Ok(()) } @@ -56,4 +175,4 @@ pub fn set_verifying_key( emit_vk_updated(&env, admin); Ok(()) -} +} \ No newline at end of file diff --git a/contracts/privacy_pool/src/storage/config.rs b/contracts/privacy_pool/src/storage/config.rs index afe8191..5eccd26 100644 --- a/contracts/privacy_pool/src/storage/config.rs +++ b/contracts/privacy_pool/src/storage/config.rs @@ -7,7 +7,7 @@ use soroban_sdk::Env; use crate::types::errors::Error; -use crate::types::state::{DataKey, PoolConfig, VerifyingKey}; +use crate::types::state::{DataKey, PoolConfig, PauseInfo, VerifyingKey}; /// Check if the pool has been initialized. pub fn exists(env: &Env) -> bool { @@ -45,3 +45,26 @@ pub fn load_verifying_key(env: &Env) -> Result { pub fn save_verifying_key(env: &Env, vk: &VerifyingKey) { env.storage().persistent().set(&DataKey::VerifyingKey, vk); } + +// ────────────────────────────────────────────────────────────── +// Pause Info Storage +// ────────────────────────────────────────────────────────────── + +/// Load the pause information. +/// Returns default (empty) PauseInfo if not set. +pub fn load_pause_info(env: &Env) -> PauseInfo { + env.storage() + .persistent() + .get(&DataKey::PauseInfo) + .unwrap_or_default() +} + +/// Save the pause information. +pub fn save_pause_info(env: &Env, pause_info: &PauseInfo) { + env.storage().persistent().set(&DataKey::PauseInfo, pause_info); +} + +/// Clear the pause information (on unpause). +pub fn clear_pause_info(env: &Env) { + env.storage().persistent().remove(&DataKey::PauseInfo); +} diff --git a/contracts/privacy_pool/src/test.rs b/contracts/privacy_pool/src/test.rs index 76ec669..347a1fd 100644 --- a/contracts/privacy_pool/src/test.rs +++ b/contracts/privacy_pool/src/test.rs @@ -201,7 +201,8 @@ fn test_deposit_zero_commitment_rejected() { fn test_deposit_while_paused_fails() { let t = TestEnv::setup(); t.init(); - t.client.pause(&t.admin); + let reason = soroban_sdk::String::from_str(&t.env, "Security maintenance"); + t.client.pause(&t.admin, &reason); let result = t.client.try_deposit(&t.alice, &commitment(&t.env, 1)); assert!(result.is_err()); @@ -265,8 +266,9 @@ fn test_pause_blocks_deposits() { // Deposit works before pause t.client.deposit(&t.alice, &commitment(&t.env, 1)); - // Pause - t.client.pause(&t.admin); + // Pause with reason + let reason = soroban_sdk::String::from_str(&t.env, "Security incident"); + t.client.pause(&t.admin, &reason); // Deposit blocked let result = t.client.try_deposit(&t.alice, &commitment(&t.env, 2)); @@ -277,7 +279,8 @@ fn test_pause_blocks_deposits() { fn test_unpause_restores_deposits() { let t = TestEnv::setup(); t.init(); - t.client.pause(&t.admin); + let reason = soroban_sdk::String::from_str(&t.env, "Maintenance"); + t.client.pause(&t.admin, &reason); t.client.unpause(&t.admin); // Deposit works again @@ -289,7 +292,8 @@ fn test_unpause_restores_deposits() { fn test_non_admin_cannot_pause() { let t = TestEnv::setup(); t.init(); - let result = t.client.try_pause(&t.alice); // alice is not admin + let reason = soroban_sdk::String::from_str(&t.env, "Test"); + let result = t.client.try_pause(&t.alice, &reason); // alice is not admin assert!(result.is_err()); } @@ -297,7 +301,8 @@ fn test_non_admin_cannot_pause() { fn test_non_admin_cannot_unpause() { let t = TestEnv::setup(); t.init(); - t.client.pause(&t.admin); + let reason = soroban_sdk::String::from_str(&t.env, "Test"); + t.client.pause(&t.admin, &reason); let result = t.client.try_unpause(&t.bob); assert!(result.is_err()); } @@ -318,6 +323,166 @@ fn test_admin_can_set_vk() { t.client.set_verifying_key(&t.admin, &dummy_vk(&t.env)); } +// ────────────────────────────────────────────────────────────── +// Emergency Pause Mechanism Tests +// ────────────────────────────────────────────────────────────── + +#[test] +fn test_is_paused_returns_false_initially() { + let t = TestEnv::setup(); + t.init(); + let (is_paused, pause_info) = t.client.is_paused(); + assert!(!is_paused); + assert!(pause_info.is_none()); +} + +#[test] +fn test_is_paused_returns_true_after_pause() { + let t = TestEnv::setup(); + t.init(); + let reason = soroban_sdk::String::from_str(&t.env, "Emergency"); + t.client.pause(&t.admin, &reason); + + let (is_paused, pause_info) = t.client.is_paused(); + assert!(is_paused); + assert!(pause_info.is_some()); + + let info = pause_info.unwrap(); + assert_eq!(info.pause_reason, reason); + assert_eq!(info.paused_by, t.admin); +} + +#[test] +fn test_pause_is_idempotent() { + let t = TestEnv::setup(); + t.init(); + let reason = soroban_sdk::String::from_str(&t.env, "First pause"); + + // Pause twice should not error + t.client.pause(&t.admin, &reason); + t.client.pause(&t.admin, &reason); + + let (is_paused, _) = t.client.is_paused(); + assert!(is_paused); +} + +#[test] +fn test_unpause_is_idempotent() { + let t = TestEnv::setup(); + t.init(); + let reason = soroban_sdk::String::from_str(&t.env, "Pause"); + + t.client.pause(&t.admin, &reason); + t.client.unpause(&t.admin); + t.client.unpause(&t.admin); // Second unpause should not error + + let (is_paused, _) = t.client.is_paused(); + assert!(!is_paused); +} + +#[test] +fn test_emergency_withdraw_requires_paused_pool() { + let t = TestEnv::setup(); + t.init(); + + // Pool is not paused + let reason = soroban_sdk::String::from_str(&t.env, "Emergency withdraw"); + let result = t.client.try_emergency_withdraw( + &t.admin, + &t.bob, + &100, + &reason, + ); + assert!(result.is_err()); +} + +#[test] +fn test_emergency_withdraw_requires_admin() { + let t = TestEnv::setup(); + t.init(); + let reason = soroban_sdk::String::from_str(&t.env, "Pause"); + t.client.pause(&t.admin, &reason); + + // Alice is not admin + let withdraw_reason = soroban_sdk::String::from_str(&t.env, "Emergency"); + let result = t.client.try_emergency_withdraw( + &t.alice, + &t.bob, + &100, + &withdraw_reason, + ); + assert!(result.is_err()); +} + +#[test] +fn test_emergency_withdraw_rejects_zero_amount() { + let t = TestEnv::setup(); + t.init(); + let reason = soroban_sdk::String::from_str(&t.env, "Pause"); + t.client.pause(&t.admin, &reason); + + let withdraw_reason = soroban_sdk::String::from_str(&t.env, "Emergency"); + let result = t.client.try_emergency_withdraw( + &t.admin, + &t.bob, + &0, + &withdraw_reason, + ); + assert!(result.is_err()); +} + +#[test] +fn test_emergency_withdraw_rejects_excessive_amount() { + let t = TestEnv::setup(); + t.init(); + + // Make one deposit + t.client.deposit(&t.alice, &commitment(&t.env, 1)); + + // Pause + let reason = soroban_sdk::String::from_str(&t.env, "Security incident"); + t.client.pause(&t.admin, &reason); + + // Try to withdraw more than balance + let withdraw_reason = soroban_sdk::String::from_str(&t.env, "Emergency"); + let result = t.client.try_emergency_withdraw( + &t.admin, + &t.bob, + &(2 * DENOM_AMOUNT), // More than contract balance + &withdraw_reason, + ); + assert!(result.is_err()); +} + +#[test] +fn test_emergency_withdraw_succeeds() { + let t = TestEnv::setup(); + t.init(); + + // Make a deposit + t.client.deposit(&t.alice, &commitment(&t.env, 1)); + let contract_balance_before = t.contract_balance(); + let bob_before = t.token_balance(&t.bob); + + // Pause + let pause_reason = soroban_sdk::String::from_str(&t.env, "Security incident"); + t.client.pause(&t.admin, &pause_reason); + + // Emergency withdraw + let withdraw_amount = DENOM_AMOUNT / 2; + let withdraw_reason = soroban_sdk::String::from_str(&t.env, "Recovering funds"); + t.client.emergency_withdraw( + &t.admin, + &t.bob, + &withdraw_amount, + &withdraw_reason, + ); + + // Verify balances + assert_eq!(t.token_balance(&t.bob), bob_before + withdraw_amount); + assert_eq!(t.contract_balance(), contract_balance_before - withdraw_amount); +} + // ────────────────────────────────────────────────────────────── // View Function Tests // ────────────────────────────────────────────────────────────── diff --git a/contracts/privacy_pool/src/types/errors.rs b/contracts/privacy_pool/src/types/errors.rs index 2d5239f..b5254fd 100644 --- a/contracts/privacy_pool/src/types/errors.rs +++ b/contracts/privacy_pool/src/types/errors.rs @@ -63,4 +63,12 @@ pub enum Error { PointNotOnCurve = 70, /// BN254 pairing check failed unexpectedly PairingFailed = 71, + + // ── Emergency Operations ─────────────────────────── + /// Emergency withdrawal amount exceeds available balance + EmergencyWithdrawExceedsBalance = 80, + /// Pool must be paused for emergency withdrawal + PoolNotPaused = 81, + /// Invalid emergency withdrawal amount (must be > 0) + InvalidEmergencyAmount = 82, } diff --git a/contracts/privacy_pool/src/types/events.rs b/contracts/privacy_pool/src/types/events.rs index c9a04a3..577fe23 100644 --- a/contracts/privacy_pool/src/types/events.rs +++ b/contracts/privacy_pool/src/types/events.rs @@ -37,12 +37,15 @@ pub struct WithdrawEvent { #[derive(Clone, Debug, Eq, PartialEq)] pub struct PoolPausedEvent { pub admin: Address, + pub pause_reason: soroban_sdk::String, + pub timestamp: u64, } #[contractevent] #[derive(Clone, Debug, Eq, PartialEq)] pub struct PoolUnpausedEvent { pub admin: Address, + pub timestamp: u64, } #[contractevent] @@ -51,6 +54,16 @@ pub struct VkUpdatedEvent { pub admin: Address, } +#[contractevent] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EmergencyWithdrawEvent { + pub admin: Address, + pub recipient: Address, + pub amount: i128, + pub timestamp: u64, + pub reason: soroban_sdk::String, +} + /// Emitted when a commitment is successfully inserted into the shielded pool. /// /// The SDK client uses this event to sync the local Merkle tree, @@ -107,13 +120,13 @@ pub fn emit_withdraw( // ────────────────────────────────────────────────────────────── /// Emitted when the pool is paused by the admin. -pub fn emit_pool_paused(env: &Env, admin: Address) { - PoolPausedEvent { admin }.publish(env); +pub fn emit_pool_paused(env: &Env, admin: Address, pause_reason: soroban_sdk::String, timestamp: u64) { + PoolPausedEvent { admin, pause_reason, timestamp }.publish(env); } /// Emitted when the pool is unpaused by the admin. -pub fn emit_pool_unpaused(env: &Env, admin: Address) { - PoolUnpausedEvent { admin }.publish(env); +pub fn emit_pool_unpaused(env: &Env, admin: Address, timestamp: u64) { + PoolUnpausedEvent { admin, timestamp }.publish(env); } /// Emitted when the verifying key is updated by the admin. @@ -121,3 +134,16 @@ pub fn emit_pool_unpaused(env: &Env, admin: Address) { pub fn emit_vk_updated(env: &Env, admin: Address) { VkUpdatedEvent { admin }.publish(env); } + +/// Emitted when emergency withdrawal is executed by admin. +/// This is a critical operation — used only in security incidents. +pub fn emit_emergency_withdraw( + env: &Env, + admin: Address, + recipient: Address, + amount: i128, + timestamp: u64, + reason: soroban_sdk::String, +) { + EmergencyWithdrawEvent { admin, recipient, amount, timestamp, reason }.publish(env); +} diff --git a/contracts/privacy_pool/src/types/state.rs b/contracts/privacy_pool/src/types/state.rs index 2381aa3..a2d3835 100644 --- a/contracts/privacy_pool/src/types/state.rs +++ b/contracts/privacy_pool/src/types/state.rs @@ -30,6 +30,8 @@ pub enum DataKey { Nullifier(BytesN<32>), /// Verification key for the Groth16 proof system VerifyingKey, + /// Pause information (timestamp, reason) + PauseInfo, } // ────────────────────────────────────────────────────────────── @@ -156,3 +158,20 @@ pub struct Proof { /// G1 point: C (64 bytes, uncompressed) pub c: BytesN<64>, } + +// ────────────────────────────────────────────────────────────── +// Pause State +// ────────────────────────────────────────────────────────────── + +/// Pause information for audit trail and transparency. +/// Stores when the pool was paused and why. +#[contracttype] +#[derive(Clone, Debug, Default)] +pub struct PauseInfo { + /// Timestamp when the pool was paused (Unix timestamp) + pub pause_timestamp: u64, + /// Reason for the pause (for audit trail) + pub pause_reason: soroban_sdk::String, + /// Admin address that initiated the pause + pub paused_by: Address, +}