Skip to content
Closed
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
9 changes: 9 additions & 0 deletions contracts/revenue_pool/src/emergency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
}
3 changes: 3 additions & 0 deletions contracts/revenue_pool/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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,
}
34 changes: 34 additions & 0 deletions contracts/revenue_pool/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down
57 changes: 52 additions & 5 deletions contracts/revenue_pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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<PendingEmergencyDrain> {
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<PendingEmergencyDrain> = inst.get(&key);
match drain {
Some(d) if env.ledger().timestamp() > d.expires_at => None,
other => other,
}
}
}

Expand Down
100 changes: 100 additions & 0 deletions contracts/revenue_pool/src/test_emergency.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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()))
);
}

// ---------------------------------------------------------------------------
Expand Down
4 changes: 3 additions & 1 deletion contracts/revenue_pool/src/test_error_codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -42,7 +43,7 @@ fn revenue_pool_error_codes_are_stable_and_unique() {
);
}

assert_eq!(seen.len(), 25);
assert_eq!(seen.len(), 26);
}

#[test]
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions docs/ERROR_CODES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down