From ceea70ad0a7f214939b00421e104396eac6e2ede Mon Sep 17 00:00:00 2001 From: Anubhav Singh Date: Sat, 27 Jun 2026 18:35:05 +0530 Subject: [PATCH] feat: time-lock fee-config updates - Add FeeConfigManager::queue_update(env, admin, new_config, eta) to stage updates - Add FeeConfigManager::apply_update(env, admin) to apply when env.ledger().timestamp() >= eta - Add FeeConfigManager::cancel_queued_update(env, admin) to cancel before ETA - Add FeeConfigManager::get_queued_fee_config(env) for transparency - Emit FeeConfigQueuedEvent, FeeConfigAppliedEvent, FeeConfigCancelledEvent - Update FeeManager::update_fee_config to use time-locked queue - Add FeeManager::apply_fee_update and FeeManager::cancel_fee_update wrappers - Update AdminFunctions to accept eta parameter and expose apply/cancel methods - Add 5 tests: queue stores pending, apply before ETA rejected, apply at ETA succeeds, cancel removes pending, past ETA rejected --- contracts/predictify-hybrid/src/admin.rs | 26 +- contracts/predictify-hybrid/src/events.rs | 89 +++++++ contracts/predictify-hybrid/src/fees.rs | 290 ++++++++++++++++++++-- 3 files changed, 374 insertions(+), 31 deletions(-) diff --git a/contracts/predictify-hybrid/src/admin.rs b/contracts/predictify-hybrid/src/admin.rs index 1ac4973b..ec99cce5 100644 --- a/contracts/predictify-hybrid/src/admin.rs +++ b/contracts/predictify-hybrid/src/admin.rs @@ -2334,12 +2334,13 @@ impl AdminFunctions { env: &Env, admin: &Address, new_config: &FeeConfig, - ) -> Result { + eta: u64, + ) -> Result<(), Error> { // Validate admin permissions AdminAccessControl::validate_admin_for_action(env, admin, "update_fees")?; - // Update fee configuration - let updated_config = FeeManager::update_fee_config(env, admin.clone(), new_config.clone())?; + // Queue fee configuration with governance time-lock + FeeManager::update_fee_config(env, admin.clone(), new_config.clone(), eta)?; // Log admin action let mut params = Map::new(env); @@ -2353,7 +2354,24 @@ impl AdminFunctions { ); AdminActionLogger::log_action(env, admin, "update_fees", None, params, true, None)?; - Ok(updated_config) + Ok(()) + } + + /// Apply a previously queued fee configuration update. + /// + /// Succeeds only when the ETA has been reached. Can be called by anyone + /// once the timelock expires. + pub fn apply_fee_update(env: &Env, admin: &Address) -> Result<(), Error> { + FeeManager::apply_fee_update(env, admin.clone())?; + Ok(()) + } + + /// Cancel a pending fee configuration update before its ETA. + /// + /// Only the contract admin may cancel a queued update. + pub fn cancel_fee_update(env: &Env, admin: &Address) -> Result<(), Error> { + FeeManager::cancel_fee_update(env, admin.clone())?; + Ok(()) } /// Updates the core contract configuration (admin only). diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index 178cf428..c4f3a7e0 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -2019,6 +2019,44 @@ pub struct AdminOverrideEvent { pub timestamp: u64, } +/// Emitted when a fee config update is queued with a governance time-lock. +/// The config is not applied until `now >= eta`. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeeConfigQueuedEvent { + pub admin: Address, + pub eta: u64, + pub platform_fee_percentage: i128, + pub creation_fee: i128, + pub min_fee_amount: i128, + pub max_fee_amount: i128, + pub collection_threshold: i128, + pub fees_enabled: bool, + pub timestamp: u64, +} + +/// Emitted when a queued fee config update is successfully applied. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeeConfigAppliedEvent { + pub admin: Address, + pub platform_fee_percentage: i128, + pub creation_fee: i128, + pub min_fee_amount: i128, + pub max_fee_amount: i128, + pub collection_threshold: i128, + pub fees_enabled: bool, + pub timestamp: u64, +} + +/// Emitted when a queued fee config update is cancelled by admin. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FeeConfigCancelledEvent { + pub admin: Address, + pub timestamp: u64, +} + /// Event emission utilities pub struct EventEmitter; @@ -4656,6 +4694,9 @@ mod event_schema_registry_tests { &env, &market_id, &disputer, 50_000_000, None, ); }); + } +} + impl EventEmitter { pub fn emit_threshold_proposed( env: &Env, @@ -4796,4 +4837,52 @@ impl EventEmitter { env.events() .publish((symbol_short!("adm_ovrd"), market_id.clone()), event); } + + /// Emit fee config queued event when a time-locked config update is proposed. + pub fn emit_fee_config_queued( + env: &Env, + admin: &Address, + eta: u64, + config: &crate::fees::FeeConfig, + ) { + let event = FeeConfigQueuedEvent { + admin: admin.clone(), + eta, + platform_fee_percentage: config.platform_fee_percentage, + creation_fee: config.creation_fee, + min_fee_amount: config.min_fee_amount, + max_fee_amount: config.max_fee_amount, + collection_threshold: config.collection_threshold, + fees_enabled: config.fees_enabled, + timestamp: env.ledger().timestamp(), + }; + env.events() + .publish((symbol_short!("fee_qd"), admin.clone()), event); + } + + /// Emit fee config applied event when a queued update becomes effective. + pub fn emit_fee_config_applied(env: &Env, admin: &Address, config: &crate::fees::FeeConfig) { + let event = FeeConfigAppliedEvent { + admin: admin.clone(), + platform_fee_percentage: config.platform_fee_percentage, + creation_fee: config.creation_fee, + min_fee_amount: config.min_fee_amount, + max_fee_amount: config.max_fee_amount, + collection_threshold: config.collection_threshold, + fees_enabled: config.fees_enabled, + timestamp: env.ledger().timestamp(), + }; + env.events() + .publish((symbol_short!("fee_apd"), admin.clone()), event); + } + + /// Emit fee config cancelled event when a queued update is cancelled. + pub fn emit_fee_config_cancelled(env: &Env, admin: &Address) { + let event = FeeConfigCancelledEvent { + admin: admin.clone(), + timestamp: env.ledger().timestamp(), + }; + env.events() + .publish((symbol_short!("fee_ccl"), admin.clone()), event); + } } diff --git a/contracts/predictify-hybrid/src/fees.rs b/contracts/predictify-hybrid/src/fees.rs index 45c9c1a1..45a6cd41 100644 --- a/contracts/predictify-hybrid/src/fees.rs +++ b/contracts/predictify-hybrid/src/fees.rs @@ -812,35 +812,33 @@ impl FeeManager { FeeAnalytics::calculate_analytics(env) } - /// Update fee configuration (admin only) + /// Queue a fee configuration update with a governance time-lock. + /// + /// The config is not applied immediately — use `apply_fee_update` after + /// the ETA to activate the update. This prevents surprise fee changes. pub fn update_fee_config( env: &Env, admin: Address, new_config: FeeConfig, - ) -> Result { - // Require authentication from the admin - admin.require_auth(); - - // Validate admin permissions - FeeValidator::validate_admin_permissions(env, &admin)?; - - // Validate new configuration - FeeValidator::validate_fee_config(&new_config)?; - - // Store new configuration - FeeConfigManager::store_fee_config(env, &new_config)?; - - // Record configuration change - FeeTracker::record_config_change(env, &admin, &new_config)?; + eta: u64, + ) -> Result<(), Error> { + FeeConfigManager::queue_update(env, &admin, &new_config, eta)?; + Ok(()) + } - crate::audit_trail::AuditTrailManager::append_record( - env, - crate::audit_trail::AuditAction::FeeConfigUpdated, - admin.clone(), - Map::new(env), - ); + /// Apply a previously queued fee configuration update. + /// + /// Succeeds only when the ETA has been reached. Can be called by anyone + /// once the timelock expires. + pub fn apply_fee_update(env: &Env, admin: Address) -> Result<(), Error> { + FeeConfigManager::apply_update(env, &admin)?; + Ok(()) + } - Ok(new_config) + /// Cancel a pending fee configuration update. + pub fn cancel_fee_update(env: &Env, admin: Address) -> Result<(), Error> { + FeeConfigManager::cancel_queued_update(env, &admin)?; + Ok(()) } /// Get current fee configuration @@ -1749,18 +1747,30 @@ impl FeeWithdrawalManager { // ===== FEE CONFIG MANAGER ===== -/// Fee configuration management +/// Storage key for the queued (pending) fee configuration. +const PENDING_FEE_CONFIG_KEY: Symbol = symbol_short!("pfee_cfg"); +/// Storage key for the ETA (Effective Time of Activation) of the queued config. +const PENDING_FEE_CONFIG_ETA_KEY: Symbol = symbol_short!("pfee_eta"); + +/// Fee configuration management with governance time-lock. +/// +/// Fee config updates follow a proposal → queue → apply cycle: +/// 1. `queue_update(env, admin, new_config, eta)` — stage the update with an ETA +/// 2. `apply_update(env, admin)` — apply only when `env.ledger().timestamp() >= eta` +/// 3. `cancel_queued_update(env, admin)` — cancel the pending update before ETA +/// +/// This prevents surprise fee changes and gives users advance notice. pub struct FeeConfigManager; impl FeeConfigManager { - /// Store fee configuration + /// Store fee configuration (used for internal resets — bypasses time-lock). pub fn store_fee_config(env: &Env, config: &FeeConfig) -> Result<(), Error> { let config_key = symbol_short!("fee_cfg"); env.storage().persistent().set(&config_key, config); Ok(()) } - /// Get fee configuration + /// Get the active fee configuration. pub fn get_fee_config(env: &Env) -> Result { let config_key = symbol_short!("fee_cfg"); Ok(env @@ -1777,7 +1787,7 @@ impl FeeConfigManager { })) } - /// Reset fee configuration to defaults + /// Reset fee configuration to defaults. pub fn reset_to_defaults(env: &Env) -> Result { let default_config = FeeConfig { platform_fee_percentage: PLATFORM_FEE_PERCENTAGE, @@ -1791,6 +1801,111 @@ impl FeeConfigManager { Self::store_fee_config(env, &default_config)?; Ok(default_config) } + + // ----------------------------------------------------------------------- + // Governance time-lock: proposal → queue → apply after delay + // ----------------------------------------------------------------------- + + /// Queue a fee configuration update for future activation. + /// + /// The new config is stored in a pending slot alongside the ETA. It does NOT + /// take effect until `apply_update` is called and `env.ledger().timestamp() >= eta`. + /// + /// Only the contract admin may queue updates. + pub fn queue_update( + env: &Env, + admin: &Address, + new_config: &FeeConfig, + eta: u64, + ) -> Result<(), Error> { + admin.require_auth(); + + FeeValidator::validate_admin_permissions(env, admin)?; + FeeValidator::validate_fee_config(new_config)?; + + let now = env.ledger().timestamp(); + if eta <= now { + return Err(Error::InvalidInput); + } + + env.storage() + .persistent() + .set(&PENDING_FEE_CONFIG_KEY, new_config); + env.storage() + .persistent() + .set(&PENDING_FEE_CONFIG_ETA_KEY, &eta); + + crate::events::EventEmitter::emit_fee_config_queued(env, admin, eta, new_config); + + Ok(()) + } + + /// Apply the queued fee configuration if the ETA has been reached. + /// + /// Can be called by anyone once the ETA arrives. The pending config is + /// cleared after successful application. + pub fn apply_update(env: &Env, admin: &Address) -> Result<(), Error> { + admin.require_auth(); + + let eta: u64 = env + .storage() + .persistent() + .get(&PENDING_FEE_CONFIG_ETA_KEY) + .ok_or(Error::ConfigNotFound)?; + + let now = env.ledger().timestamp(); + if now < eta { + return Err(Error::InvalidInput); + } + + let pending: FeeConfig = env + .storage() + .persistent() + .get(&PENDING_FEE_CONFIG_KEY) + .ok_or(Error::ConfigNotFound)?; + + // Apply: overwrite the active config + Self::store_fee_config(env, &pending)?; + + // Clear pending storage + env.storage().persistent().remove(&PENDING_FEE_CONFIG_KEY); + env.storage().persistent().remove(&PENDING_FEE_CONFIG_ETA_KEY); + + crate::events::EventEmitter::emit_fee_config_applied(env, admin, &pending); + + Ok(()) + } + + /// Cancel a queued fee configuration update before its ETA. + /// + /// Only the contract admin may cancel. Has no effect if no update is pending. + pub fn cancel_queued_update(env: &Env, admin: &Address) -> Result<(), Error> { + admin.require_auth(); + + FeeValidator::validate_admin_permissions(env, admin)?; + + env.storage().persistent().remove(&PENDING_FEE_CONFIG_KEY); + env.storage().persistent().remove(&PENDING_FEE_CONFIG_ETA_KEY); + + crate::events::EventEmitter::emit_fee_config_cancelled(env, admin); + + Ok(()) + } + + /// Read the currently queued fee config and its ETA (if any). + /// + /// Returns `None` when no update is pending. + pub fn get_queued_fee_config(env: &Env) -> Option<(FeeConfig, u64)> { + let eta: u64 = env + .storage() + .persistent() + .get(&PENDING_FEE_CONFIG_ETA_KEY)?; + let config: FeeConfig = env + .storage() + .persistent() + .get(&PENDING_FEE_CONFIG_KEY)?; + Some((config, eta)) + } } // ===== FEE ANALYTICS ===== @@ -2347,4 +2462,125 @@ mod tests { assert!(fee <= market.total_staked); assert!(fee >= 0); } + + // ------------------------------------------------------------------- + // Governance time-lock tests + // ------------------------------------------------------------------- + + fn setup_with_contract() -> (Env, Address) { + let env = Env::default(); + env.mock_all_auths(); + let admin = Address::generate(&env); + let contract_id = env.register(crate::PredictifyHybrid, ()); + env.as_contract(&contract_id, || { + env.storage() + .persistent() + .set(&Symbol::new(&env, "Admin"), &admin); + FeeConfigManager::reset_to_defaults(&env).unwrap(); + }); + (env, admin) + } + + #[test] + fn test_queue_update_stores_pending_config() { + let (env, admin) = setup_with_contract(); + let new_config = FeeConfig { + platform_fee_percentage: 250, + creation_fee: 5_000_000, + min_fee_amount: 500_000, + max_fee_amount: 2_000_000_000, + collection_threshold: 200_000_000, + fees_enabled: true, + }; + let now = env.ledger().timestamp(); + let eta = now + 86_400; + + let contract_id = env.register(crate::PredictifyHybrid, ()); + env.as_contract(&contract_id, || { + let result = FeeConfigManager::queue_update(&env, &admin, &new_config, eta); + assert!(result.is_ok()); + }); + } + + #[test] + fn test_apply_update_before_eta_fails() { + let (env, admin) = setup_with_contract(); + let new_config = testing::create_test_fee_config(); + let now = env.ledger().timestamp(); + let eta = now + 86_400; + + let contract_id = env.register(crate::PredictifyHybrid, ()); + env.as_contract(&contract_id, || { + FeeConfigManager::queue_update(&env, &admin, &new_config, eta).unwrap(); + let result = FeeConfigManager::apply_update(&env, &admin); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), Error::InvalidInput); + }); + } + + #[test] + fn test_apply_update_at_eta_succeeds() { + let (env, admin) = setup_with_contract(); + let new_config = FeeConfig { + platform_fee_percentage: 250, + creation_fee: 5_000_000, + min_fee_amount: 500_000, + max_fee_amount: 2_000_000_000, + collection_threshold: 200_000_000, + fees_enabled: true, + }; + let now = env.ledger().timestamp(); + let eta = now + 1; + + let contract_id = env.register(crate::PredictifyHybrid, ()); + env.as_contract(&contract_id, || { + FeeConfigManager::queue_update(&env, &admin, &new_config, eta).unwrap(); + env.ledger().set_timestamp(eta); + + let result = FeeConfigManager::apply_update(&env, &admin); + assert!(result.is_ok()); + + let active = FeeConfigManager::get_fee_config(&env).unwrap(); + assert_eq!(active.platform_fee_percentage, 250); + assert_eq!(active.creation_fee, 5_000_000); + + let stale = FeeConfigManager::get_queued_fee_config(&env); + assert!(stale.is_none()); + }); + } + + #[test] + fn test_cancel_queued_update_removes_pending() { + let (env, admin) = setup_with_contract(); + let new_config = testing::create_test_fee_config(); + let now = env.ledger().timestamp(); + let eta = now + 86_400; + + let contract_id = env.register(crate::PredictifyHybrid, ()); + env.as_contract(&contract_id, || { + FeeConfigManager::queue_update(&env, &admin, &new_config, eta).unwrap(); + let before = FeeConfigManager::get_queued_fee_config(&env); + assert!(before.is_some()); + + FeeConfigManager::cancel_queued_update(&env, &admin).unwrap(); + + let after = FeeConfigManager::get_queued_fee_config(&env); + assert!(after.is_none()); + }); + } + + #[test] + fn test_queue_update_with_past_eta_fails() { + let (env, admin) = setup_with_contract(); + let new_config = testing::create_test_fee_config(); + let now = env.ledger().timestamp(); + let past_eta = now.saturating_sub(1); + + let contract_id = env.register(crate::PredictifyHybrid, ()); + env.as_contract(&contract_id, || { + let result = FeeConfigManager::queue_update(&env, &admin, &new_config, past_eta); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), Error::InvalidInput); + }); + } }