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
4 changes: 4 additions & 0 deletions contracts/vault/src/emergency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<EmergencyProposal> {
Expand Down
172 changes: 170 additions & 2 deletions contracts/vault/src/feature_tests.rs
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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());
}
85 changes: 83 additions & 2 deletions contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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")]
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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!(
Expand All @@ -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);

Expand Down Expand Up @@ -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<emergency::EmergencyProposal> {
Expand Down
4 changes: 4 additions & 0 deletions contracts/vault/src/storage_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,10 @@ pub fn registered_vault_keys(env: &soroban_sdk::Env) -> soroban_sdk::Vec<Storage
name: symbol_short!("EmrgProp"),
parameterized: true,
});
keys.push_back(scalar(
StorageNamespace::Emergency,
"EmergencyDisputeWindow",
));

keys
}
Expand Down
Loading