diff --git a/contracts/revenue_pool/src/emergency.rs b/contracts/revenue_pool/src/emergency.rs index 8ac8db40..b75efe32 100644 --- a/contracts/revenue_pool/src/emergency.rs +++ b/contracts/revenue_pool/src/emergency.rs @@ -39,6 +39,10 @@ use soroban_sdk::{contracttype, Address}; /// a fraudulent or mistaken proposal. pub const EMERGENCY_DRAIN_TIMELOCK_SECONDS: u64 = 86_400; +/// Bounded execution grace window (in seconds) after `execute_after` during +/// which a pending emergency drain remains executable. Set to 86 400 s = 24 hours. +pub const EMERGENCY_DRAIN_GRACE_PERIOD_SECONDS: u64 = 86_400; + /// Storage key used to persist the [`PendingEmergencyDrain`] snapshot in the /// contract's instance storage. Kept `pub(crate)` so `lib.rs` can reference it /// directly without re-exporting the raw string. @@ -62,4 +66,9 @@ pub struct PendingEmergencyDrain { /// /// Equals `proposed_at + EMERGENCY_DRAIN_TIMELOCK_SECONDS`. pub execute_after: u64, + /// Latest ledger timestamp (inclusive) at which `execute_emergency_drain` + /// may succeed. + /// + /// Equals `execute_after + EMERGENCY_DRAIN_GRACE_PERIOD_SECONDS`. + pub expires_at: u64, } diff --git a/contracts/revenue_pool/src/errors.rs b/contracts/revenue_pool/src/errors.rs index be9beccd..c88a82df 100644 --- a/contracts/revenue_pool/src/errors.rs +++ b/contracts/revenue_pool/src/errors.rs @@ -33,6 +33,7 @@ use soroban_sdk::contracterror; /// | 23 | EmergencyPaused | Recovery-only emergency mode is active | /// | 24 | AlreadyEmergencyPaused | Emergency pause was already active | /// | 25 | NotEmergencyPaused | Emergency recovery was requested while inactive | +/// | 26 | EmergencyDrainExpired | Emergency drain execution window has expired | #[contracterror] #[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)] #[repr(u32)] @@ -87,4 +88,6 @@ pub enum RevenuePoolError { AlreadyEmergencyPaused = 24, /// Emergency recovery was requested while inactive (code 25). NotEmergencyPaused = 25, + /// Emergency drain execution window has expired (code 26). + EmergencyDrainExpired = 26, } diff --git a/contracts/revenue_pool/src/events.rs b/contracts/revenue_pool/src/events.rs index 012eff29..3361f2cf 100644 --- a/contracts/revenue_pool/src/events.rs +++ b/contracts/revenue_pool/src/events.rs @@ -247,6 +247,22 @@ pub fn event_emergency_drain_executed(env: &Env) -> Symbol { Symbol::new(env, "emergency_drain_executed") } +/// Returns the Symbol for the `"drain_replaced"` event topic. +/// +/// Emitted when the admin proposes a new emergency drain while an earlier +/// proposal is still pending via [`RevenuePool::propose_emergency_drain`]. +pub fn event_drain_replaced(env: &Env) -> Symbol { + Symbol::new(env, "drain_replaced") +} + +/// Returns the Symbol for the `"emergency_drain_expired"` event topic. +/// +/// Emitted when an expired pending emergency drain is purged via +/// [`RevenuePool::expire_emergency_drain`]. +pub fn event_emergency_drain_expired(env: &Env) -> Symbol { + Symbol::new(env, "emergency_drain_expired") +} + /// Returns the Symbol for the `"emergency_drain_cancelled"` event topic. /// /// Emitted when the admin cancels a pending emergency drain via @@ -487,6 +503,24 @@ mod tests { } /// Snapshot: proves event_emergency_drain_cancelled still maps to exactly the bytes for "emergency_drain_cancelled". + #[test] + fn test_event_drain_replaced_bytes() { + let env = Env::default(); + assert_eq!( + event_drain_replaced(&env), + Symbol::new(&env, "drain_replaced") + ); + } + + #[test] + fn test_event_emergency_drain_expired_bytes() { + let env = Env::default(); + assert_eq!( + event_emergency_drain_expired(&env), + Symbol::new(&env, "emergency_drain_expired") + ); + } + #[test] fn test_event_emergency_drain_cancelled_bytes() { let env = Env::default(); diff --git a/contracts/revenue_pool/src/lib.rs b/contracts/revenue_pool/src/lib.rs index a59e9eb6..9ac9ec20 100644 --- a/contracts/revenue_pool/src/lib.rs +++ b/contracts/revenue_pool/src/lib.rs @@ -1031,16 +1031,27 @@ impl RevenuePool { let execute_after: u64 = proposed_at .checked_add(EMERGENCY_DRAIN_TIMELOCK_SECONDS) .unwrap_or_else(|| env.panic_with_error(RevenuePoolError::Overflow)); + let expires_at: u64 = execute_after + .checked_add(EMERGENCY_DRAIN_GRACE_PERIOD_SECONDS) + .unwrap_or_else(|| env.panic_with_error(RevenuePoolError::Overflow)); let drain = PendingEmergencyDrain { to: treasury, amount, proposed_at, execute_after, + expires_at, }; let inst = env.storage().instance(); - inst.set(&Symbol::new(&env, EMERGENCY_DRAIN_KEY), &drain); + let key = Symbol::new(&env, EMERGENCY_DRAIN_KEY); + if let Some(previous) = inst.get::<_, PendingEmergencyDrain>(&key) { + env.events().publish( + (events::event_drain_replaced(&env), caller.clone()), + (previous, drain.clone()), + ); + } + inst.set(&key, &drain); inst.extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT); env.events().publish( @@ -1081,6 +1092,10 @@ impl RevenuePool { if now < drain.execute_after { env.panic_with_error(RevenuePoolError::TimelockNotExpired); } + if now > drain.expires_at { + inst.remove(&Symbol::new(&env, EMERGENCY_DRAIN_KEY)); + env.panic_with_error(RevenuePoolError::EmergencyDrainExpired); + } let usdc_address: Address = inst .get(&Symbol::new(&env, USDC_KEY)) @@ -1137,15 +1152,47 @@ impl RevenuePool { ); } - /// Return the pending emergency drain proposal, or `None` if none exists. + /// Purge an expired pending emergency drain proposal from storage. + /// + /// May be called by any operator or monitor once `env.ledger().timestamp() > drain.expires_at` + /// to clear the stale proposal and emit an `emergency_drain_expired` event. + /// + /// # Errors + /// * [`RevenuePoolError::NoPendingEmergencyDrain`] - no proposal exists. + /// * [`RevenuePoolError::TimelockNotExpired`] - proposal has not yet expired. + pub fn expire_emergency_drain(env: Env) -> PendingEmergencyDrain { + let inst = env.storage().instance(); + let key = Symbol::new(&env, EMERGENCY_DRAIN_KEY); + let drain: PendingEmergencyDrain = inst + .get(&key) + .unwrap_or_else(|| env.panic_with_error(RevenuePoolError::NoPendingEmergencyDrain)); + let now: u64 = env.ledger().timestamp(); + if now <= drain.expires_at { + env.panic_with_error(RevenuePoolError::TimelockNotExpired); + } + inst.remove(&key); + inst.extend_ttl(LIFETIME_THRESHOLD, BUMP_AMOUNT); + env.events().publish( + (events::event_emergency_drain_expired(&env),), + drain.clone(), + ); + drain + } + + /// Return the pending emergency drain proposal, or `None` if none exists + /// or the proposal's execution window (`expires_at`) has elapsed. /// /// Off-chain monitors and the admin can poll this to verify or cancel a /// pending drain before the timelock expires. pub fn get_pending_emergency_drain(env: Env) -> Option { Self::bump_instance_ttl(&env); - env.storage() - .instance() - .get(&Symbol::new(&env, EMERGENCY_DRAIN_KEY)) + let inst = env.storage().instance(); + let key = Symbol::new(&env, EMERGENCY_DRAIN_KEY); + let drain: Option = inst.get(&key); + match drain { + Some(d) if env.ledger().timestamp() > d.expires_at => None, + other => other, + } } } diff --git a/contracts/revenue_pool/src/test_emergency.rs b/contracts/revenue_pool/src/test_emergency.rs index a3c0f806..ff87df5e 100644 --- a/contracts/revenue_pool/src/test_emergency.rs +++ b/contracts/revenue_pool/src/test_emergency.rs @@ -55,6 +55,10 @@ fn propose_success() { pending.execute_after, 1_700_000_000 + emergency::EMERGENCY_DRAIN_TIMELOCK_SECONDS ); + assert_eq!( + pending.expires_at, + pending.execute_after + emergency::EMERGENCY_DRAIN_GRACE_PERIOD_SECONDS + ); } #[test] @@ -403,6 +407,102 @@ fn reproposal_replaces_existing() { pending.execute_after, 1_700_000_100 + emergency::EMERGENCY_DRAIN_TIMELOCK_SECONDS ); + assert_eq!( + pending.expires_at, + pending.execute_after + emergency::EMERGENCY_DRAIN_GRACE_PERIOD_SECONDS + ); +} + +#[test] +fn reproposal_emits_drain_replaced_event() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_700_000_000); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let other = Address::generate(&env); + let (usdc_address, _, usdc_admin) = create_usdc(&env, &admin); + let (_pool, client) = init_pool(&env, &admin, &usdc_address); + fund_pool(&usdc_admin, &_pool, 10_000); + + client.propose_emergency_drain(&admin, &treasury, &5_000); + env.ledger().set_timestamp(1_700_000_100); + client.propose_emergency_drain(&admin, &other, &3_000); + + let events = env.events().all(); + let replaced_event = events + .iter() + .find(|ev| { + let topic: Symbol = Symbol::try_from_val(&env, &ev.1.get(0).unwrap()).unwrap(); + topic == Symbol::new(&env, "drain_replaced") + }) + .expect("drain_replaced event must be emitted on reproposal"); + let caller: Address = Address::try_from_val(&env, &replaced_event.1.get(1).unwrap()).unwrap(); + assert_eq!(caller, admin); + let (old_drain, new_drain): ( + emergency::PendingEmergencyDrain, + emergency::PendingEmergencyDrain, + ) = <( + emergency::PendingEmergencyDrain, + emergency::PendingEmergencyDrain, + )>::try_from_val(&env, &replaced_event.2) + .unwrap(); + assert_eq!(old_drain.to, treasury); + assert_eq!(old_drain.amount, 5_000); + assert_eq!(new_drain.to, other); + assert_eq!(new_drain.amount, 3_000); +} + +#[test] +fn execute_succeeds_at_exact_expiry_boundary() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_700_000_000); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let (usdc_address, usdc_client, usdc_admin) = create_usdc(&env, &admin); + let (pool, client) = init_pool(&env, &admin, &usdc_address); + fund_pool(&usdc_admin, &pool, 10_000); + + client.propose_emergency_drain(&admin, &treasury, &5_000); + let pending = client.get_pending_emergency_drain().unwrap(); + env.ledger().set_timestamp(pending.expires_at); + + assert_eq!(client.try_execute_emergency_drain(&admin), Ok(Ok(()))); + assert!(client.get_pending_emergency_drain().is_none()); + assert_eq!(usdc_client.balance(&treasury), 5_000); +} + +#[test] +fn execute_after_expiry_fails_and_clears_proposal() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1_700_000_000); + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + let (usdc_address, usdc_client, usdc_admin) = create_usdc(&env, &admin); + let (pool, client) = init_pool(&env, &admin, &usdc_address); + fund_pool(&usdc_admin, &pool, 10_000); + + client.propose_emergency_drain(&admin, &treasury, &5_000); + let pending = client.get_pending_emergency_drain().unwrap(); + env.ledger().set_timestamp(pending.expires_at + 1); + + assert_eq!( + client.try_execute_emergency_drain(&admin), + Err(Ok(RevenuePoolError::EmergencyDrainExpired.into())) + ); + assert!(client.get_pending_emergency_drain().is_none()); + assert_eq!(usdc_client.balance(&treasury), 0); + assert_eq!(usdc_client.balance(&pool), 10_000); + + let expired = client.expire_emergency_drain(); + assert_eq!(expired.to, treasury); + assert_eq!(expired.amount, 5_000); + assert_eq!( + client.try_execute_emergency_drain(&admin), + Err(Ok(RevenuePoolError::NoPendingEmergencyDrain.into())) + ); } // --------------------------------------------------------------------------- diff --git a/contracts/revenue_pool/src/test_error_codes.rs b/contracts/revenue_pool/src/test_error_codes.rs index e2031b0d..160063d1 100644 --- a/contracts/revenue_pool/src/test_error_codes.rs +++ b/contracts/revenue_pool/src/test_error_codes.rs @@ -31,6 +31,7 @@ fn revenue_pool_error_codes_are_stable_and_unique() { (23, RevenuePoolError::EmergencyPaused), (24, RevenuePoolError::AlreadyEmergencyPaused), (25, RevenuePoolError::NotEmergencyPaused), + (26, RevenuePoolError::EmergencyDrainExpired), ]; let mut seen = BTreeSet::new(); @@ -42,7 +43,7 @@ fn revenue_pool_error_codes_are_stable_and_unique() { ); } - assert_eq!(seen.len(), 25); + assert_eq!(seen.len(), 26); } #[test] @@ -74,6 +75,7 @@ fn error_code_docs_list_every_revenue_pool_code() { "| 23 | `EmergencyPaused` | Revenue Pool | Recovery-only emergency mode is active |", "| 24 | `AlreadyEmergencyPaused` | Revenue Pool | Emergency pause was already active |", "| 25 | `NotEmergencyPaused` | Revenue Pool | Emergency recovery was requested while inactive |", + "| 26 | `EmergencyDrainExpired` | Revenue Pool | Emergency drain execution window has expired |", ]; for line in expected_lines { diff --git a/docs/ERROR_CODES.md b/docs/ERROR_CODES.md index d0074e9f..f733d452 100644 --- a/docs/ERROR_CODES.md +++ b/docs/ERROR_CODES.md @@ -150,6 +150,7 @@ must not be reassigned once released. | 23 | `EmergencyPaused` | Revenue Pool | Recovery-only emergency mode is active | | 24 | `AlreadyEmergencyPaused` | Revenue Pool | Emergency pause was already active | | 25 | `NotEmergencyPaused` | Revenue Pool | Emergency recovery was requested while inactive | +| 26 | `EmergencyDrainExpired` | Revenue Pool | Emergency drain execution window has expired | ## Upgrade