diff --git a/contracts/predictify-hybrid/src/err.rs b/contracts/predictify-hybrid/src/err.rs index 2a2f6e3d..f68dd38e 100644 --- a/contracts/predictify-hybrid/src/err.rs +++ b/contracts/predictify-hybrid/src/err.rs @@ -288,6 +288,12 @@ pub enum Error { PerLedgerBetCapExceeded = 687, /// Registry is full. RegistryFull = 688, + /// Treasury update timelock has not yet expired. + TreasuryUpdateTimelocked = 689, + /// No pending treasury update found. + NoPendingTreasuryUpdate = 690, + /// A pending treasury update already exists. + PendingTreasuryUpdateExists = 691, } // ===== ERROR CATEGORIZATION AND RECOVERY SYSTEM ===== @@ -1613,6 +1619,9 @@ impl Error { Error::CumulativeExtensionCapHit => "Cumulative extension cap reached; no further extensions allowed", Error::IllegalMarketStateTransition => "Illegal market state transition attempted", Error::OracleQuoteOutlier => "Oracle quote is an outlier relative to the rolling median", + Error::TreasuryUpdateTimelocked => "Treasury update timelock has not yet expired", + Error::NoPendingTreasuryUpdate => "No pending treasury update found", + Error::PendingTreasuryUpdateExists => "A pending treasury update already exists", _ => "An unspecified error occurred.", } } @@ -1724,6 +1733,9 @@ impl Error { Error::CumulativeExtensionCapHit => "CUMULATIVE_EXTENSION_CAP_HIT", Error::IllegalMarketStateTransition => "ILLEGAL_MARKET_STATE_TRANSITION", Error::OracleQuoteOutlier => "ORACLE_QUOTE_OUTLIER", + Error::TreasuryUpdateTimelocked => "TREASURY_UPDATE_TIMELOCKED", + Error::NoPendingTreasuryUpdate => "NO_PENDING_TREASURY_UPDATE", + Error::PendingTreasuryUpdateExists => "PENDING_TREASURY_UPDATE_EXISTS", _ => "UNSPECIFIED_ERROR", } } @@ -1842,6 +1854,9 @@ mod tests { Error::UpgradeChainMismatch, Error::ReplayedOverride, Error::OracleQuoteOutlier, + Error::TreasuryUpdateTimelocked, + Error::NoPendingTreasuryUpdate, + Error::PendingTreasuryUpdateExists, ] } diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index 9946d601..25999b88 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -2294,6 +2294,37 @@ pub struct FeeConfigCancelledEvent { pub timestamp: u64, } +/// Emitted when a treasury update is queued with a governance time-lock. +/// The treasury is not changed until `now >= eta`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TreasuryUpdateQueuedEvent { + pub admin: Address, + pub new_treasury: Address, + pub eta: u64, + pub nonce: u64, + pub timestamp: u64, +} + +/// Emitted when a queued treasury update is successfully applied. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TreasuryUpdateAppliedEvent { + pub admin: Address, + pub treasury: Address, + pub nonce: u64, + pub timestamp: u64, +} + +/// Emitted when a queued treasury update is cancelled by admin. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TreasuryUpdateCancelledEvent { + pub admin: Address, + pub nonce: u64, + pub timestamp: u64, +} + /// Event emitted when a deprecated entrypoint is called. /// /// This event allows indexers and monitoring tools to track usage of legacy @@ -5582,6 +5613,47 @@ impl EventEmitter { .publish((symbol_short!("fee_ccl"), admin.clone()), event); } + /// Emit treasury update queued event when a time-locked treasury change is proposed. + pub fn emit_treasury_update_queued( + env: &Env, + admin: &Address, + new_treasury: &Address, + eta: u64, + ) { + let event = TreasuryUpdateQueuedEvent { + admin: admin.clone(), + new_treasury: new_treasury.clone(), + eta, + nonce: Self::get_and_increment_nonce(env, symbol_short!("tsu_qd").clone()), + timestamp: env.ledger().timestamp(), + }; + env.events() + .publish((symbol_short!("tsu_qd"), admin.clone()), event); + } + + /// Emit treasury update applied event when a queued treasury change takes effect. + pub fn emit_treasury_update_applied(env: &Env, admin: &Address, treasury: &Address) { + let event = TreasuryUpdateAppliedEvent { + admin: admin.clone(), + treasury: treasury.clone(), + nonce: Self::get_and_increment_nonce(env, symbol_short!("tsu_apd").clone()), + timestamp: env.ledger().timestamp(), + }; + env.events() + .publish((symbol_short!("tsu_apd"), admin.clone()), event); + } + + /// Emit treasury update cancelled event when a queued treasury change is cancelled. + pub fn emit_treasury_update_cancelled(env: &Env, admin: &Address) { + let event = TreasuryUpdateCancelledEvent { + admin: admin.clone(), + nonce: Self::get_and_increment_nonce(env, symbol_short!("tsu_ccl").clone()), + timestamp: env.ledger().timestamp(), + }; + env.events() + .publish((symbol_short!("tsu_ccl"), admin.clone()), event); + } + /// Emit cumulative dispute stake cap exceeded event. pub fn emit_dispute_cumulative_stake_cap_exceeded( env: &Env, diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 84e65274..e37aab25 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -1920,6 +1920,14 @@ impl PredictifyHybrid { } /// Set treasury recipient for unclaimed winnings sweeps (admin only). + /// + /// **Deprecated**: This function now queues a timelocked treasury update + /// instead of applying it immediately. Use `queue_treasury_update`, + /// `apply_treasury_update`, and `cancel_treasury_update` for the full + /// queue→apply→cancel lifecycle. + /// + /// The treasury address will only change after the configured timelock + /// period has elapsed (default: 24 hours). pub fn set_treasury(env: Env, admin: Address, treasury: Address) { admin.require_auth(); @@ -1933,8 +1941,65 @@ impl PredictifyHybrid { panic_with_error!(env, Error::Unauthorized); } - recovery::UnclaimedWinningsPolicy::set_treasury(&env, &treasury); - EventEmitter::emit_treasury_updated(&env, &admin, &treasury); + // Route through the timelocked path to prevent instant rotation + match recovery::TreasuryTimelockManager::queue_update(&env, &admin, &treasury) { + Ok(()) => {} + Err(Error::PendingTreasuryUpdateExists) => { + // Cancel the old one and queue the new one + let _ = recovery::TreasuryTimelockManager::cancel_update(&env, &admin); + recovery::TreasuryTimelockManager::queue_update(&env, &admin, &treasury) + .unwrap_or_else(|e| panic_with_error!(env, e)); + } + Err(e) => panic_with_error!(env, e), + } + } + + /// Queue a treasury update for future activation (admin only). + /// + /// The treasury address will only change after the configured timelock + /// period (default: 24 hours) has elapsed. This prevents surprise + /// fee-recipient rotation. + pub fn queue_treasury_update( + env: Env, + admin: Address, + new_treasury: Address, + ) -> Result<(), Error> { + Self::require_primary_admin(&env, &admin)?; + recovery::TreasuryTimelockManager::queue_update(&env, &admin, &new_treasury) + } + + /// Apply the queued treasury update after the timelock has expired. + /// + /// Can be called by the admin once the timelock period has elapsed. + pub fn apply_treasury_update(env: Env, admin: Address) -> Result<(), Error> { + Self::require_primary_admin(&env, &admin)?; + recovery::TreasuryTimelockManager::apply_update(&env, &admin) + } + + /// Cancel a pending treasury update before the timelock expires (admin only). + pub fn cancel_treasury_update(env: Env, admin: Address) -> Result<(), Error> { + Self::require_primary_admin(&env, &admin)?; + recovery::TreasuryTimelockManager::cancel_update(&env, &admin) + } + + /// Get the pending treasury update, if any (read-only query). + pub fn get_pending_treasury_update(env: Env) -> Option { + recovery::TreasuryTimelockManager::get_pending_update(&env) + } + + /// Get the treasury timelock configuration (read-only query). + pub fn get_treasury_timelock_config(env: Env) -> recovery::TreasuryTimelockConfig { + recovery::TreasuryTimelockConfig::get_config(&env) + } + + /// Set the treasury timelock delay (admin only). + pub fn set_treasury_timelock_config( + env: Env, + admin: Address, + timelock_seconds: u64, + ) -> Result<(), Error> { + Self::require_primary_admin(&env, &admin)?; + recovery::TreasuryTimelockConfig::set_config(&env, &admin, timelock_seconds) } /// Sweep unclaimed winning payouts after claim period expiry (admin only). @@ -4820,40 +4885,10 @@ impl PredictifyHybrid { pub fn withdraw_collected_fees(env: Env, admin: Address, amount: i128) -> Result { Self::require_primary_admin(&env, &admin)?; - // Get collected fees from storage (using the same key as FeeTracker) - let fees_key = Symbol::new(&env, "tot_fees"); - let collected_fees: i128 = env.storage().persistent().get(&fees_key).unwrap_or(0); - - if collected_fees == 0 { - return Err(Error::NoFeesToCollect); - } - - // Determine withdrawal amount - let withdrawal_amount = if amount == 0 || amount > collected_fees { - collected_fees - } else { - amount - }; - - // Update collected fees (checked to prevent underflow) - let remaining_fees = collected_fees - .checked_sub(withdrawal_amount) - .ok_or(Error::InvalidInput)?; - env.storage().persistent().set(&fees_key, &remaining_fees); - - // Emit fee withdrawal event - EventEmitter::emit_fee_collected( - &env, - &Symbol::new(&env, "withdrawal"), - &admin, - withdrawal_amount, - &String::from_str(&env, "fee_withdrawal"), - ); - - // In a real implementation, transfer tokens to admin here - // For now, we'll just track the withdrawal - - Ok(withdrawal_amount) + // Route through the timelocked fee withdrawal manager to enforce + // the withdrawal schedule (timelock + cap). This prevents the admin + // from bypassing the schedule by calling this entrypoint directly. + fees::FeeWithdrawalManager::withdraw_fees(&env, &admin, amount) } /// Extends the deadline of an active market by a specified number of days (admin only). diff --git a/contracts/predictify-hybrid/src/recovery.rs b/contracts/predictify-hybrid/src/recovery.rs index 06192e2b..eb8e285c 100644 --- a/contracts/predictify-hybrid/src/recovery.rs +++ b/contracts/predictify-hybrid/src/recovery.rs @@ -574,6 +574,189 @@ impl UnclaimedWinningsPolicy { } } +// ===== TREASURY TIMELOCK ===== + +/// Default timelock delay before a queued treasury change can be applied (24 hours). +pub const DEFAULT_TREASURY_TIMELOCK_SECONDS: u64 = 24 * 60 * 60; + +/// Minimum allowed treasury timelock (1 hour). Prevents setting dangerously short windows. +pub const MIN_TREASURY_TIMELOCK_SECONDS: u64 = 60 * 60; + +/// Maximum allowed treasury timelock (30 days). Prevents excessively long lockouts. +pub const MAX_TREASURY_TIMELOCK_SECONDS: u64 = 30 * 24 * 60 * 60; + +/// Pending treasury update protected by a governance time-lock. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PendingTreasuryUpdate { + /// The proposed new treasury address. + pub new_treasury: Address, + /// Unix timestamp after which the update may be applied. + pub execute_after: u64, + /// Admin who queued this update. + pub proposed_by: Address, +} + +/// Configuration for the treasury timelock delay. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TreasuryTimelockConfig { + /// Delay in seconds between queuing and applying a treasury update. + pub timelock_seconds: u64, +} + +impl TreasuryTimelockConfig { + fn storage_key(env: &Env) -> Symbol { + Symbol::new(env, "tsu_cfg") + } + + /// Returns the stored configuration, or the default if unset. + pub fn get_config(env: &Env) -> TreasuryTimelockConfig { + env.storage() + .persistent() + .get(&Self::storage_key(env)) + .unwrap_or(TreasuryTimelockConfig { + timelock_seconds: DEFAULT_TREASURY_TIMELOCK_SECONDS, + }) + } + + /// Admin-only: update the timelock delay. Must stay within bounds. + pub fn set_config( + env: &Env, + admin: &Address, + timelock_seconds: u64, + ) -> Result<(), Error> { + crate::fees::FeeValidator::validate_admin_permissions(env, admin)?; + + if timelock_seconds < MIN_TREASURY_TIMELOCK_SECONDS + || timelock_seconds > MAX_TREASURY_TIMELOCK_SECONDS + { + return Err(Error::InvalidInput); + } + + let mut config = Self::get_config(env); + config.timelock_seconds = timelock_seconds; + env.storage().persistent().set(&Self::storage_key(env), &config); + Ok(()) + } +} + +/// Timelocked treasury rotation manager. +/// +/// Treasury changes follow a proposal → queue → apply cycle: +/// 1. `queue_update(env, admin, new_treasury)` — stage the change +/// 2. `apply_update(env, admin)` — apply only when `env.ledger().timestamp() >= execute_after` +/// 3. `cancel_update(env, admin)` — cancel the pending update before it takes effect +/// +/// This prevents surprise fee-recipient rotation and gives downstream +/// observers time to react. +pub struct TreasuryTimelockManager; + +impl TreasuryTimelockManager { + fn pending_key(env: &Env) -> Symbol { + Symbol::new(env, "tsu_pend") + } + + /// Queue a treasury update for future activation. + /// + /// The new treasury address is stored in a pending slot. It does NOT + /// take effect until `apply_update` is called and the timelock has expired. + /// Only the contract admin may queue updates. + pub fn queue_update( + env: &Env, + admin: &Address, + new_treasury: &Address, + ) -> Result<(), Error> { + admin.require_auth(); + + crate::fees::FeeValidator::validate_admin_permissions(env, admin)?; + + // Reject if a pending update already exists + if env.storage().persistent().has(&Self::pending_key(env)) { + return Err(Error::PendingTreasuryUpdateExists); + } + + let config = TreasuryTimelockConfig::get_config(env); + let now = env.ledger().timestamp(); + let execute_after = now.saturating_add(config.timelock_seconds); + + let pending = PendingTreasuryUpdate { + new_treasury: new_treasury.clone(), + execute_after, + proposed_by: admin.clone(), + }; + env.storage().persistent().set(&Self::pending_key(env), &pending); + + crate::events::EventEmitter::emit_treasury_update_queued( + env, + admin, + new_treasury, + execute_after, + ); + + Ok(()) + } + + /// Apply the queued treasury update if the timelock has expired. + /// + /// Can be called by anyone (admin required) once the timelock expires. + /// The pending update is cleared after successful application. + pub fn apply_update(env: &Env, admin: &Address) -> Result<(), Error> { + admin.require_auth(); + + let pending: PendingTreasuryUpdate = env + .storage() + .persistent() + .get(&Self::pending_key(env)) + .ok_or(Error::NoPendingTreasuryUpdate)?; + + let now = env.ledger().timestamp(); + if now < pending.execute_after { + return Err(Error::TreasuryUpdateTimelocked); + } + + // Apply: set the new treasury + UnclaimedWinningsPolicy::set_treasury(env, &pending.new_treasury); + + // Clear pending storage + env.storage().persistent().remove(&Self::pending_key(env)); + + crate::events::EventEmitter::emit_treasury_update_applied( + env, + admin, + &pending.new_treasury, + ); + + Ok(()) + } + + /// Cancel a queued treasury update before its timelock expires. + /// + /// Only the contract admin may cancel. Has no effect if no update is pending. + pub fn cancel_update(env: &Env, admin: &Address) -> Result<(), Error> { + admin.require_auth(); + + crate::fees::FeeValidator::validate_admin_permissions(env, admin)?; + + if !env.storage().persistent().has(&Self::pending_key(env)) { + return Err(Error::NoPendingTreasuryUpdate); + } + + env.storage().persistent().remove(&Self::pending_key(env)); + + crate::events::EventEmitter::emit_treasury_update_cancelled(env, admin); + + Ok(()) + } + + /// Read the currently queued treasury update (if any). + /// + /// Returns `None` when no update is pending. + pub fn get_pending_update(env: &Env) -> Option { + env.storage().persistent().get(&Self::pending_key(env)) + } +} + // ===== VALIDATION ===== pub struct RecoveryValidator; impl RecoveryValidator { @@ -1570,5 +1753,256 @@ mod tests { } } +// ===== TREASURY TIMELOCK TESTS ===== + +#[cfg(test)] +mod treasury_timelock_tests { + use super::*; + use soroban_sdk::testutils::{Address as _, EnvTestConfig, Ledger as _}; + use soroban_sdk::{Address, Env}; + + fn test_env() -> Env { + let mut env = Env::default(); + env.set_config(EnvTestConfig { + capture_snapshot_at_drop: false, + }); + env + } + + /// Set up the contract admin in storage so FeeValidator::validate_admin_permissions passes. + fn setup_admin(env: &Env) -> Address { + let admin = Address::generate(env); + env.storage() + .persistent() + .set(&Symbol::new(env, "Admin"), &admin); + admin + } + + #[test] + fn test_queue_treasury_update_stores_pending() { + let env = test_env(); + let admin = setup_admin(&env); + let new_treasury = Address::generate(&env); + + let result = TreasuryTimelockManager::queue_update(&env, &admin, &new_treasury); + assert!(result.is_ok()); + + let pending = TreasuryTimelockManager::get_pending_update(&env); + assert!(pending.is_some()); + let p = pending.unwrap(); + assert_eq!(p.new_treasury, new_treasury); + assert_eq!(p.proposed_by, admin); + // execute_after should be in the future + assert!(p.execute_after > env.ledger().timestamp()); + } + + #[test] + fn test_queue_rejects_non_admin() { + let env = test_env(); + let _admin = setup_admin(&env); + let attacker = Address::generate(&env); + let new_treasury = Address::generate(&env); + + let result = TreasuryTimelockManager::queue_update(&env, &attacker, &new_treasury); + assert_eq!(result, Err(Error::Unauthorized)); + } + + #[test] + fn test_queue_rejects_if_already_pending() { + let env = test_env(); + let admin = setup_admin(&env); + let t1 = Address::generate(&env); + let t2 = Address::generate(&env); + + assert!(TreasuryTimelockManager::queue_update(&env, &admin, &t1).is_ok()); + let result = TreasuryTimelockManager::queue_update(&env, &admin, &t2); + assert_eq!(result, Err(Error::PendingTreasuryUpdateExists)); + } + + #[test] + fn test_apply_rejects_before_timelock() { + let env = test_env(); + let admin = setup_admin(&env); + let new_treasury = Address::generate(&env); + + assert!(TreasuryTimelockManager::queue_update(&env, &admin, &new_treasury).is_ok()); + + // Try to apply immediately — should fail + let result = TreasuryTimelockManager::apply_update(&env, &admin); + assert_eq!(result, Err(Error::TreasuryUpdateTimelocked)); + } + + #[test] + fn test_apply_succeeds_after_timelock() { + let env = test_env(); + let admin = setup_admin(&env); + let new_treasury = Address::generate(&env); + + assert!(TreasuryTimelockManager::queue_update(&env, &admin, &new_treasury).is_ok()); + + let config = TreasuryTimelockConfig::get_config(&env); + env.ledger().set_timestamp(env.ledger().timestamp() + config.timelock_seconds); + + let result = TreasuryTimelockManager::apply_update(&env, &admin); + assert!(result.is_ok()); + + // Verify treasury was updated + let treasury = UnclaimedWinningsPolicy::get_treasury(&env); + assert_eq!(treasury, Some(new_treasury)); + + // Verify pending is cleared + assert!(TreasuryTimelockManager::get_pending_update(&env).is_none()); + } + + #[test] + fn test_cancel_clears_pending() { + let env = test_env(); + let admin = setup_admin(&env); + let new_treasury = Address::generate(&env); + + assert!(TreasuryTimelockManager::queue_update(&env, &admin, &new_treasury).is_ok()); + assert!(TreasuryTimelockManager::get_pending_update(&env).is_some()); + + let result = TreasuryTimelockManager::cancel_update(&env, &admin); + assert!(result.is_ok()); + + assert!(TreasuryTimelockManager::get_pending_update(&env).is_none()); + } + + #[test] + fn test_cancel_fails_when_no_pending() { + let env = test_env(); + let admin = setup_admin(&env); + + let result = TreasuryTimelockManager::cancel_update(&env, &admin); + assert_eq!(result, Err(Error::NoPendingTreasuryUpdate)); + } + + #[test] + fn test_cancel_rejects_non_admin() { + let env = test_env(); + let _admin = setup_admin(&env); + let attacker = Address::generate(&env); + let new_treasury = Address::generate(&env); + + assert!(TreasuryTimelockManager::queue_update(&env, &_admin, &new_treasury).is_ok()); + let result = TreasuryTimelockManager::cancel_update(&env, &attacker); + assert_eq!(result, Err(Error::Unauthorized)); + } + + #[test] + fn test_apply_fails_when_no_pending() { + let env = test_env(); + let admin = setup_admin(&env); + + let result = TreasuryTimelockManager::apply_update(&env, &admin); + assert_eq!(result, Err(Error::NoPendingTreasuryUpdate)); + } + + #[test] + fn test_cancel_then_requeue() { + let env = test_env(); + let admin = setup_admin(&env); + let t1 = Address::generate(&env); + let t2 = Address::generate(&env); + + assert!(TreasuryTimelockManager::queue_update(&env, &admin, &t1).is_ok()); + assert!(TreasuryTimelockManager::cancel_update(&env, &admin).is_ok()); + assert!(TreasuryTimelockManager::queue_update(&env, &admin, &t2).is_ok()); + + let pending = TreasuryTimelockManager::get_pending_update(&env).unwrap(); + assert_eq!(pending.new_treasury, t2); + } + + #[test] + fn test_default_timelock_config() { + let env = test_env(); + let config = TreasuryTimelockConfig::get_config(&env); + assert_eq!(config.timelock_seconds, DEFAULT_TREASURY_TIMELOCK_SECONDS); + } + + #[test] + fn test_set_config_admin_only() { + let env = test_env(); + let _admin = setup_admin(&env); + let non_admin = Address::generate(&env); + + let result = TreasuryTimelockConfig::set_config(&env, &non_admin, 7200); + assert_eq!(result, Err(Error::Unauthorized)); + } + + #[test] + fn test_set_config_rejects_out_of_bounds() { + let env = test_env(); + let admin = setup_admin(&env); + + // Too short + let result = TreasuryTimelockConfig::set_config(&env, &admin, 100); + assert_eq!(result, Err(Error::InvalidInput)); + + // Too long + let result = TreasuryTimelockConfig::set_config( + &env, + &admin, + MAX_TREASURY_TIMELOCK_SECONDS + 1, + ); + assert_eq!(result, Err(Error::InvalidInput)); + } + + #[test] + fn test_set_config_valid() { + let env = test_env(); + let admin = setup_admin(&env); + + let result = TreasuryTimelockConfig::set_config(&env, &admin, 7200); + assert!(result.is_ok()); + + let config = TreasuryTimelockConfig::get_config(&env); + assert_eq!(config.timelock_seconds, 7200); + } + + #[test] + fn test_apply_does_not_affect_treasury_if_timelock_not_met() { + let env = test_env(); + let admin = setup_admin(&env); + let new_treasury = Address::generate(&env); + + // Set a treasury first + UnclaimedWinningsPolicy::set_treasury(&env, &admin); + let original = UnclaimedWinningsPolicy::get_treasury(&env); + + // Queue an update + assert!(TreasuryTimelockManager::queue_update(&env, &admin, &new_treasury).is_ok()); + + // Try to apply before timelock + let _ = TreasuryTimelockManager::apply_update(&env, &admin); + + // Treasury should remain unchanged + assert_eq!(UnclaimedWinningsPolicy::get_treasury(&env), original); + } + + #[test] + fn test_get_pending_returns_none_when_empty() { + let env = test_env(); + assert!(TreasuryTimelockManager::get_pending_update(&env).is_none()); + } + + #[test] + fn test_queue_updates_eta_based_on_config() { + let env = test_env(); + let admin = setup_admin(&env); + let treasury = Address::generate(&env); + + // Set custom timelock + TreasuryTimelockConfig::set_config(&env, &admin, 3600).unwrap(); + + let now = env.ledger().timestamp(); + assert!(TreasuryTimelockManager::queue_update(&env, &admin, &treasury).is_ok()); + + let pending = TreasuryTimelockManager::get_pending_update(&env).unwrap(); + assert_eq!(pending.execute_after, now + 3600); + } +} + // Helper to build composite key prefix + symbol as soroban Symbol // composite_symbol no longer required with new map-based storage approach