From 3c3f0f92e6f83aedf245134874bdf6abbdd0edf0 Mon Sep 17 00:00:00 2001 From: Keshinro Tanitoluwa Joseph Date: Thu, 4 Jun 2026 03:15:30 +0100 Subject: [PATCH] feat: implement emergency dispute window functionality and related tests --- contracts/vault/src/emergency.rs | 4 + contracts/vault/src/feature_tests.rs | 172 +++++++++++++++++++++++- contracts/vault/src/lib.rs | 85 +++++++++++- contracts/vault/src/storage_registry.rs | 4 + 4 files changed, 261 insertions(+), 4 deletions(-) diff --git a/contracts/vault/src/emergency.rs b/contracts/vault/src/emergency.rs index 5edb2f93..f34b2f76 100644 --- a/contracts/vault/src/emergency.rs +++ b/contracts/vault/src/emergency.rs @@ -27,6 +27,10 @@ pub struct EmergencyProposal { pub initiator: Address, pub confirmed: bool, pub executed: bool, + pub cancelled: bool, + /// Ledger timestamp after which the secondary approver may confirm. + /// The admin may cancel the proposal before this deadline passes. + pub dispute_deadline: u64, } pub fn read_proposal(env: &Env, id: u32) -> Option { diff --git a/contracts/vault/src/feature_tests.rs b/contracts/vault/src/feature_tests.rs index 4a98febf..a491af29 100644 --- a/contracts/vault/src/feature_tests.rs +++ b/contracts/vault/src/feature_tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::emergency::EmergencyActionKind; -use soroban_sdk::testutils::Address as _; +use soroban_sdk::testutils::{Address as _, Ledger as _}; use soroban_sdk::{token, Address, Env}; fn setup_vault( @@ -64,8 +64,12 @@ fn test_dual_approval_emergency_pause() { let proposal = vault.emergency_proposal(&proposal_id).unwrap(); assert!(!proposal.confirmed); assert!(!proposal.executed); + assert!(!proposal.cancelled); - vault.confirm_emergency_action(&secondary, &proposal_id); + // Advance past the 1-hour dispute window before the secondary can confirm. + env.ledger().set_timestamp(env.ledger().timestamp() + 3_601); + + vault.confirm_emergency_action(&secondary, &proposal_id).unwrap(); assert!(vault.is_paused()); assert_eq!(vault.pause_reason(), Some(PauseReason::SecurityIncident)); @@ -123,3 +127,167 @@ fn test_accrue_yield_fee_rounding_deterministic() { assert_eq!(vault.treasury_balance(), 3); assert_eq!(vault.total_assets(), 330); } + +// ── Dispute window tests ────────────────────────────────────────────────────── + +#[test] +fn test_confirm_blocked_during_dispute_window() { + let env = Env::default(); + env.mock_all_auths(); + + let (vault, _, _, _) = setup_vault(&env); + let primary = Address::generate(&env); + let secondary = Address::generate(&env); + vault.set_emergency_approvers(&primary, &secondary); + + let proposal_id = vault.propose_emergency_action( + &primary, + &EmergencyActionKind::Pause, + &(PauseReason::SecurityIncident as u32), + &None, + &None, + ); + + // Try to confirm immediately — should be blocked. + assert_eq!( + vault.try_confirm_emergency_action(&secondary, &proposal_id).unwrap_err().unwrap(), + VaultError::DisputeWindowActive + ); +} + +#[test] +fn test_confirm_allowed_after_dispute_window() { + let env = Env::default(); + env.mock_all_auths(); + + let (vault, _, _, _) = setup_vault(&env); + let primary = Address::generate(&env); + let secondary = Address::generate(&env); + vault.set_emergency_approvers(&primary, &secondary); + + let proposal_id = vault.propose_emergency_action( + &primary, + &EmergencyActionKind::Pause, + &(PauseReason::Governance as u32), + &None, + &None, + ); + + env.ledger().set_timestamp(env.ledger().timestamp() + 3_601); + vault.confirm_emergency_action(&secondary, &proposal_id).unwrap(); + assert!(vault.is_paused()); +} + +#[test] +fn test_admin_can_cancel_during_dispute_window() { + let env = Env::default(); + env.mock_all_auths(); + + let (vault, _, _, _) = setup_vault(&env); + let primary = Address::generate(&env); + let secondary = Address::generate(&env); + vault.set_emergency_approvers(&primary, &secondary); + + let proposal_id = vault.propose_emergency_action( + &primary, + &EmergencyActionKind::Pause, + &(PauseReason::SecurityIncident as u32), + &None, + &None, + ); + + vault.cancel_emergency_action(&proposal_id).unwrap(); + + let proposal = vault.emergency_proposal(&proposal_id).unwrap(); + assert!(proposal.cancelled); + assert!(!vault.is_paused()); +} + +#[test] +fn test_cancelled_proposal_cannot_be_confirmed() { + let env = Env::default(); + env.mock_all_auths(); + + let (vault, _, _, _) = setup_vault(&env); + let primary = Address::generate(&env); + let secondary = Address::generate(&env); + vault.set_emergency_approvers(&primary, &secondary); + + let proposal_id = vault.propose_emergency_action( + &primary, + &EmergencyActionKind::Pause, + &(PauseReason::Other as u32), + &None, + &None, + ); + + vault.cancel_emergency_action(&proposal_id).unwrap(); + + // Even after the window passes, a cancelled proposal must be rejected. + env.ledger().set_timestamp(env.ledger().timestamp() + 3_601); + assert_eq!( + vault.try_confirm_emergency_action(&secondary, &proposal_id).unwrap_err().unwrap(), + VaultError::ProposalCancelled + ); +} + +#[test] +fn test_cancel_fails_after_dispute_window_closes() { + let env = Env::default(); + env.mock_all_auths(); + + let (vault, _, _, _) = setup_vault(&env); + let primary = Address::generate(&env); + let secondary = Address::generate(&env); + vault.set_emergency_approvers(&primary, &secondary); + + let proposal_id = vault.propose_emergency_action( + &primary, + &EmergencyActionKind::Pause, + &(PauseReason::Maintenance as u32), + &None, + &None, + ); + + env.ledger().set_timestamp(env.ledger().timestamp() + 3_601); + + assert_eq!( + vault.try_cancel_emergency_action(&proposal_id).unwrap_err().unwrap(), + VaultError::DisputeWindowClosed + ); +} + +#[test] +fn test_custom_dispute_window_respected() { + let env = Env::default(); + env.mock_all_auths(); + + let (vault, _, _, _) = setup_vault(&env); + let primary = Address::generate(&env); + let secondary = Address::generate(&env); + vault.set_emergency_approvers(&primary, &secondary); + + // Set a shorter 10-minute window. + vault.set_emergency_dispute_window(&600u64); + assert_eq!(vault.emergency_dispute_window(), 600u64); + + let proposal_id = vault.propose_emergency_action( + &primary, + &EmergencyActionKind::Pause, + &(PauseReason::LiquidityCrisis as u32), + &None, + &None, + ); + + // Still blocked at 9 minutes. + env.ledger().set_timestamp(env.ledger().timestamp() + 540); + assert_eq!( + vault.try_confirm_emergency_action(&secondary, &proposal_id).unwrap_err().unwrap(), + VaultError::DisputeWindowActive + ); + + // Allowed after 10 minutes. + env.ledger().set_timestamp(env.ledger().timestamp() + 61); + vault.confirm_emergency_action(&secondary, &proposal_id).unwrap(); + assert!(vault.is_paused()); +} diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index 97334978..a3adace3 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -199,6 +199,8 @@ pub enum DataKey { RelayerWhitelist(Address), // Maximum entries allowed in a single batch_deposit call MaxBatchSize, + // Dispute window duration in seconds for emergency proposals (default 3600 = 1 hour) + EmergencyDisputeWindow, } #[contracttype] @@ -296,6 +298,12 @@ pub enum VaultError { BatchTooLarge = 16, /// Caller is not a registered relayer and cannot submit batch deposits. RelayerNotAuthorized = 17, + /// Emergency proposal is still within the dispute window and cannot be confirmed yet. + DisputeWindowActive = 18, + /// Emergency proposal has been cancelled and cannot be confirmed or executed. + ProposalCancelled = 19, + /// Dispute window has already closed; the proposal can no longer be cancelled. + DisputeWindowClosed = 20, } #[contractclient(name = "KoreanDebtStrategyClient")] @@ -548,6 +556,10 @@ impl YieldVault { } /// Primary approver initiates a dual-approval emergency action. + /// + /// A dispute window starts immediately. The admin may call + /// `cancel_emergency_action` before the window closes. The secondary + /// approver can only confirm after the dispute window has elapsed. pub fn propose_emergency_action( env: Env, initiator: Address, @@ -560,6 +572,17 @@ impl YieldVault { let primary = emergency::primary_approver(&env).expect("primary approver not set"); assert!(initiator == primary, "only primary approver can initiate"); + let window_secs: u64 = env + .storage() + .instance() + .get(&DataKey::EmergencyDisputeWindow) + .unwrap_or(3_600u64); + let dispute_deadline = env + .ledger() + .timestamp() + .checked_add(window_secs) + .expect("overflow"); + let proposal_id = emergency::next_proposal_id(&env); let proposal = emergency::EmergencyProposal { kind, @@ -569,15 +592,20 @@ impl YieldVault { initiator: initiator.clone(), confirmed: false, executed: false, + cancelled: false, + dispute_deadline, }; emergency::write_proposal(&env, proposal_id, &proposal); env.events() - .publish((symbol_short!("emrgprop"),), (proposal_id, kind as u32)); + .publish((symbol_short!("emrgprop"),), (proposal_id, kind as u32, dispute_deadline)); proposal_id } /// Secondary approver confirms and executes a pending emergency action. - pub fn confirm_emergency_action(env: Env, confirmer: Address, proposal_id: u32) { + /// + /// Confirmation is only allowed after the dispute window has closed and the + /// proposal has not been cancelled. + pub fn confirm_emergency_action(env: Env, confirmer: Address, proposal_id: u32) -> Result<(), VaultError> { confirmer.require_auth(); let secondary = emergency::secondary_approver(&env).expect("secondary approver not set"); assert!( @@ -593,6 +621,13 @@ impl YieldVault { "confirmer must differ from initiator" ); + if proposal.cancelled { + return Err(VaultError::ProposalCancelled); + } + if env.ledger().timestamp() < proposal.dispute_deadline { + return Err(VaultError::DisputeWindowActive); + } + proposal.confirmed = true; emergency::write_proposal(&env, proposal_id, &proposal); @@ -620,6 +655,52 @@ impl YieldVault { (symbol_short!("emrgexec"),), (proposal_id, proposal.kind as u32), ); + Ok(()) + } + + /// Admin cancels an emergency proposal during its dispute window. + /// + /// Once the dispute window closes the proposal can no longer be cancelled + /// and must proceed through secondary confirmation. + pub fn cancel_emergency_action(env: Env, proposal_id: u32) -> Result<(), VaultError> { + let admin: Address = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + + let mut proposal = emergency::read_proposal(&env, proposal_id).expect("proposal not found"); + assert!(!proposal.executed, "proposal already executed"); + + if proposal.cancelled { + return Err(VaultError::ProposalCancelled); + } + if env.ledger().timestamp() >= proposal.dispute_deadline { + return Err(VaultError::DisputeWindowClosed); + } + + proposal.cancelled = true; + emergency::write_proposal(&env, proposal_id, &proposal); + env.events() + .publish((symbol_short!("emrgcncl"),), (proposal_id,)); + Ok(()) + } + + /// Sets the dispute window duration (in seconds) for new emergency proposals. + /// + /// Only the admin may configure this. Defaults to `3600` (1 hour) if never set. + pub fn set_emergency_dispute_window(env: Env, seconds: u64) { + let admin: Address = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + assert!(seconds > 0, "dispute window must be positive"); + env.storage() + .instance() + .set(&DataKey::EmergencyDisputeWindow, &seconds); + } + + /// Returns the configured dispute window in seconds (default 3600). + pub fn emergency_dispute_window(env: Env) -> u64 { + env.storage() + .instance() + .get(&DataKey::EmergencyDisputeWindow) + .unwrap_or(3_600u64) } pub fn emergency_proposal(env: Env, proposal_id: u32) -> Option { diff --git a/contracts/vault/src/storage_registry.rs b/contracts/vault/src/storage_registry.rs index cc5e0e78..6f0504e0 100644 --- a/contracts/vault/src/storage_registry.rs +++ b/contracts/vault/src/storage_registry.rs @@ -158,6 +158,10 @@ pub fn registered_vault_keys(env: &soroban_sdk::Env) -> soroban_sdk::Vec