From a4f957d431baf59d61566693ca85baf96c7ca84d Mon Sep 17 00:00:00 2001 From: Oseji Fabian Daniel Date: Sat, 30 May 2026 15:00:18 +0000 Subject: [PATCH] feat(vault): add timelocked parameter update mechanism (#557) Add a propose/execute/cancel timelock pattern for critical vault config changes. Sensitive parameters (fee_bps, min_deposit, large_withdrawal_threshold, min_liquidity_buffer) now require a 48-hour delay between proposal and execution. Changes: - Add ParamKey enum identifying the four critical parameters - Add PendingParamUpdate struct (new_value + unlock_timestamp) - Add PendingParamUpdate(ParamKey) variant to DataKey - Add PARAM_TIMELOCK_DELAY constant (172800s = 48h) - Add three new error codes: NoPendingParamUpdate (12), ParamTimelockNotExpired (13), ParamUpdateAlreadyPending (14) - Add propose_param_update(): admin queues a change with 48h delay - Add execute_param_update(): applies change after timelock expires - Add cancel_param_update(): admin cancels a pending change - Add pending_param_update(): read-only query for pending state - Add 10 tests covering all happy paths and error cases Closes #557 --- contracts/vault/src/lib.rs | 174 +++++++++++++++++++++++++++++++++++- contracts/vault/src/test.rs | 135 ++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+), 2 deletions(-) diff --git a/contracts/vault/src/lib.rs b/contracts/vault/src/lib.rs index e84f9700..11c94ba6 100644 --- a/contracts/vault/src/lib.rs +++ b/contracts/vault/src/lib.rs @@ -83,6 +83,9 @@ const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); const MAX_PAGE_SIZE: u32 = 50; +/// Timelock delay for critical parameter updates: 48 hours in seconds. +const PARAM_TIMELOCK_DELAY: u64 = 172_800; + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] /// Shipment status for RWA asset tracking. @@ -149,12 +152,29 @@ pub enum DataKey { PriceOracle, OracleEnabled, OracleHeartbeat, + // Timelocked parameter updates + PendingParamUpdate(ParamKey), +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +/// Identifies which critical parameter is being updated via timelock. +pub enum ParamKey { + FeeBps, + MinDeposit, + LargeWithdrawalThreshold, + MinLiquidityBuffer, } #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -/// DAO governance proposal for strategy selection. -pub struct StrategyProposal { +/// A queued parameter update that becomes effective after the timelock expires. +pub struct PendingParamUpdate { + pub new_value: i128, + pub unlock_timestamp: u64, +} + + pub strategy: Address, pub yes_votes: i128, pub no_votes: i128, @@ -196,6 +216,12 @@ pub enum VaultError { ExceedsStrategyCap = 10, /// Strategy allocation exceeds configured risk threshold. ExceedsRiskThreshold = 11, + /// No pending parameter update exists for this key. + NoPendingParamUpdate = 12, + /// Parameter update timelock has not expired yet. + ParamTimelockNotExpired = 13, + /// A pending parameter update already exists for this key. + ParamUpdateAlreadyPending = 14, } #[contractclient(name = "KoreanDebtStrategyClient")] @@ -1265,6 +1291,150 @@ impl YieldVault { env.storage().instance().set(&DataKey::StrategyRiskThreshold(strategy), &threshold); } + // ── Timelocked parameter updates ───────────────────────────────────────── + + /// Propose a critical parameter update. The change will not take effect until + /// `execute_param_update` is called after the 48-hour timelock expires. + /// + /// ### Parameters + /// * `param` - Which parameter to update. + /// * `new_value` - The proposed new value. + /// + /// ### Errors + /// * `ParamUpdateAlreadyPending` - If a pending update already exists for this param. + pub fn propose_param_update( + env: Env, + param: ParamKey, + new_value: i128, + ) -> Result { + let admin: Address = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + + let key = DataKey::PendingParamUpdate(param); + if env.storage().instance().has(&key) { + return Err(VaultError::ParamUpdateAlreadyPending); + } + + let unlock_ts = env + .ledger() + .timestamp() + .checked_add(PARAM_TIMELOCK_DELAY) + .expect("overflow"); + + let pending = PendingParamUpdate { + new_value, + unlock_timestamp: unlock_ts, + }; + env.storage().instance().set(&key, &pending); + + env.events() + .publish((symbol_short!("prmprop"),), (new_value, unlock_ts)); + + Ok(unlock_ts) + } + + /// Execute a previously proposed parameter update after the timelock has expired. + /// + /// ### Errors + /// * `NoPendingParamUpdate` - If no pending update exists for this param. + /// * `ParamTimelockNotExpired` - If the timelock has not yet elapsed. + pub fn execute_param_update(env: Env, param: ParamKey) -> Result<(), VaultError> { + let admin: Address = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + + let key = DataKey::PendingParamUpdate(param.clone()); + let pending: PendingParamUpdate = env + .storage() + .instance() + .get(&key) + .ok_or(VaultError::NoPendingParamUpdate)?; + + if env.ledger().timestamp() < pending.unlock_timestamp { + return Err(VaultError::ParamTimelockNotExpired); + } + + env.storage().instance().remove(&key); + + match param { + ParamKey::FeeBps => { + let new_bps = pending.new_value; + if new_bps < 0 || new_bps > 10_000 { + panic!("fee_bps must be 0-10000"); + } + let old_bps: i128 = + env.storage().instance().get(&DataKey::FeeBps).unwrap_or(0); + env.storage().instance().set(&DataKey::FeeBps, &new_bps); + env.events() + .publish((symbol_short!("feechg"),), (old_bps, new_bps)); + } + ParamKey::MinDeposit => { + let new_min = pending.new_value; + if new_min < 0 { + panic!("min_deposit must be >= 0"); + } + let old_min: i128 = env + .storage() + .instance() + .get(&DataKey::MinDeposit) + .unwrap_or(0); + env.storage().instance().set(&DataKey::MinDeposit, &new_min); + env.events() + .publish((symbol_short!("mindepchg"),), (old_min, new_min)); + } + ParamKey::LargeWithdrawalThreshold => { + let new_threshold = pending.new_value; + if new_threshold <= 0 { + panic!("threshold must be > 0"); + } + env.storage() + .instance() + .set(&DataKey::LargeWithdrawalThreshold, &new_threshold); + } + ParamKey::MinLiquidityBuffer => { + let new_buffer = pending.new_value; + if new_buffer < 0 { + panic!("min_liquidity_buffer must be >= 0"); + } + let old_buffer = Self::min_liquidity_buffer(env.clone()); + env.storage() + .instance() + .set(&DataKey::MinLiquidityBuffer, &new_buffer); + env.events() + .publish((symbol_short!("liqbufchg"),), (old_buffer, new_buffer)); + } + } + + env.events() + .publish((symbol_short!("prmexec"),), pending.new_value); + + Ok(()) + } + + /// Cancel a pending parameter update before it is executed. + /// + /// ### Errors + /// * `NoPendingParamUpdate` - If no pending update exists for this param. + pub fn cancel_param_update(env: Env, param: ParamKey) -> Result<(), VaultError> { + let admin: Address = get_admin(&env).expect("Admin not set"); + admin.require_auth(); + + let key = DataKey::PendingParamUpdate(param); + if !env.storage().instance().has(&key) { + return Err(VaultError::NoPendingParamUpdate); + } + + env.storage().instance().remove(&key); + env.events().publish((symbol_short!("prmcancel"),), ()); + Ok(()) + } + + /// Returns the pending parameter update for a given param key, if any. + pub fn pending_param_update(env: Env, param: ParamKey) -> Option { + env.storage() + .instance() + .get(&DataKey::PendingParamUpdate(param)) + } + pub fn report_benji_yield(env: Env, strategy: Address, amount: i128) { strategy.require_auth(); if amount <= 0 { diff --git a/contracts/vault/src/test.rs b/contracts/vault/src/test.rs index 1e8fcd05..4866781f 100644 --- a/contracts/vault/src/test.rs +++ b/contracts/vault/src/test.rs @@ -1150,3 +1150,138 @@ fn test_multiple_deposits_atomic_state_updates() { assert_eq!(vault.total_shares(), 200); assert_eq!(vault.total_assets(), 200); } + +// ─── Timelocked parameter updates ──────────────────────────────────────────── + +#[test] +fn test_propose_param_update_stores_pending() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _, _, _) = setup_vault(&env); + + let unlock_ts = vault.propose_param_update(&ParamKey::FeeBps, &500); + let pending = vault.pending_param_update(&ParamKey::FeeBps).unwrap(); + assert_eq!(pending.new_value, 500); + assert_eq!(pending.unlock_timestamp, unlock_ts); +} + +#[test] +fn test_propose_param_update_duplicate_panics() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _, _, _) = setup_vault(&env); + + vault.propose_param_update(&ParamKey::FeeBps, &500); + let result = vault.try_propose_param_update(&ParamKey::FeeBps, &200); + assert_eq!(result, Err(Ok(VaultError::ParamUpdateAlreadyPending))); +} + +#[test] +fn test_execute_param_update_before_timelock_panics() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _, _, _) = setup_vault(&env); + + vault.propose_param_update(&ParamKey::FeeBps, &500); + let result = vault.try_execute_param_update(&ParamKey::FeeBps); + assert_eq!(result, Err(Ok(VaultError::ParamTimelockNotExpired))); +} + +#[test] +fn test_execute_param_update_fee_bps_after_timelock() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _, _, _) = setup_vault(&env); + + vault.propose_param_update(&ParamKey::FeeBps, &300); + + // Advance time past the 48-hour timelock + env.ledger().with_mut(|l| l.timestamp += 172_801); + + vault.execute_param_update(&ParamKey::FeeBps); + assert_eq!(vault.fee_bps(), 300); + // Pending entry should be cleared + assert!(vault.pending_param_update(&ParamKey::FeeBps).is_none()); +} + +#[test] +fn test_execute_param_update_min_deposit_after_timelock() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _, _, _) = setup_vault(&env); + + vault.propose_param_update(&ParamKey::MinDeposit, &100); + env.ledger().with_mut(|l| l.timestamp += 172_801); + vault.execute_param_update(&ParamKey::MinDeposit); + assert_eq!(vault.min_deposit(), 100); +} + +#[test] +fn test_execute_param_update_large_withdrawal_threshold_after_timelock() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _, _, _) = setup_vault(&env); + + vault.propose_param_update(&ParamKey::LargeWithdrawalThreshold, &5000); + env.ledger().with_mut(|l| l.timestamp += 172_801); + vault.execute_param_update(&ParamKey::LargeWithdrawalThreshold); + assert_eq!(vault.large_withdrawal_threshold(), 5000); +} + +#[test] +fn test_execute_param_update_min_liquidity_buffer_after_timelock() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _, _, _) = setup_vault(&env); + + vault.propose_param_update(&ParamKey::MinLiquidityBuffer, &200); + env.ledger().with_mut(|l| l.timestamp += 172_801); + vault.execute_param_update(&ParamKey::MinLiquidityBuffer); + assert_eq!(vault.min_liquidity_buffer(), 200); +} + +#[test] +fn test_cancel_param_update_removes_pending() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _, _, _) = setup_vault(&env); + + vault.propose_param_update(&ParamKey::FeeBps, &500); + vault.cancel_param_update(&ParamKey::FeeBps); + assert!(vault.pending_param_update(&ParamKey::FeeBps).is_none()); +} + +#[test] +fn test_cancel_param_update_no_pending_panics() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _, _, _) = setup_vault(&env); + + let result = vault.try_cancel_param_update(&ParamKey::FeeBps); + assert_eq!(result, Err(Ok(VaultError::NoPendingParamUpdate))); +} + +#[test] +fn test_execute_param_update_no_pending_panics() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _, _, _) = setup_vault(&env); + + let result = vault.try_execute_param_update(&ParamKey::FeeBps); + assert_eq!(result, Err(Ok(VaultError::NoPendingParamUpdate))); +} + +#[test] +fn test_cancel_then_repropose_param_update() { + let env = Env::default(); + env.mock_all_auths(); + let (vault, _, _, _) = setup_vault(&env); + + vault.propose_param_update(&ParamKey::FeeBps, &500); + vault.cancel_param_update(&ParamKey::FeeBps); + + // Should be able to propose again after cancellation + vault.propose_param_update(&ParamKey::FeeBps, &200); + let pending = vault.pending_param_update(&ParamKey::FeeBps).unwrap(); + assert_eq!(pending.new_value, 200); +}