diff --git a/creator-keys/src/emergency_pause.rs b/creator-keys/src/emergency_pause.rs new file mode 100644 index 00000000..40702703 --- /dev/null +++ b/creator-keys/src/emergency_pause.rs @@ -0,0 +1,222 @@ +//! #1000 — Emergency pause for a platform-wide trading halt. +//! +//! The global-pause admin set (configured via `set_global_pause_admins`) can +//! halt every bonding-curve buy and sell in a single transaction by passing at +//! least [`crate::GLOBAL_PAUSE_THRESHOLD`] distinct signers. Lifting the halt +//! is a two-step flow: a resume is queued, and it can only be executed once +//! [`PLATFORM_RESUME_DELAY_SECS`] (24h) has elapsed. A per-key override lets +//! the same admins halt a single key independently of the platform state. + +use crate::events::{ + self, KeyPauseOverrideEvent, PlatformPausedEvent, PlatformResumeQueuedEvent, + PlatformResumedEvent, +}; +use crate::{read_global_pause_admins, ContractError, GLOBAL_PAUSE_THRESHOLD}; +use soroban_sdk::{contracterror, contracttype, Address, Env, Vec}; + +/// Timelock delay between queueing and executing a platform resume (24 hours). +pub const PLATFORM_RESUME_DELAY_SECS: u64 = 86_400; + +/// Errors raised by the emergency-pause entrypoints. +/// +/// Kept separate from [`ContractError`] so the platform-pause flow has its own +/// stable, self-describing error codes. +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum EmergencyPauseError { + /// The admin set is unset or a signer is not a member of it. + Unauthorized = 1, + /// Fewer than the required number of distinct signers were supplied. + InsufficientSigners = 2, + /// The same signer appeared more than once. + DuplicateSigner = 3, + /// `pause_platform` was called while the platform is already paused. + AlreadyPaused = 4, + /// A resume action was attempted while the platform is not paused. + NotPaused = 5, + /// A resume is already queued. + ResumeAlreadyQueued = 6, + /// `resume_platform` was called without a queued resume. + ResumeNotQueued = 7, + /// `resume_platform` was called before the 24h timelock elapsed. + TimelockNotElapsed = 8, + /// Arithmetic overflow while computing the resume timestamp. + Overflow = 9, +} + +#[derive(Clone)] +#[contracttype] +pub enum EmergencyPauseDataKey { + /// Whether the platform-wide trading halt is active. + PlatformPaused, + /// Ledger timestamp at which a queued resume becomes executable. + ResumeEta, + /// Per-key emergency pause override. + KeyPaused(Address), +} + +/// Validates that `signers` are distinct members of the global-pause admin set, +/// meet the threshold, and have all authorised the call. Returns the first +/// signer, reported as the event `actor`. +fn assert_multisig(env: &Env, signers: &Vec
) -> Result { + if signers.len() < GLOBAL_PAUSE_THRESHOLD { + return Err(EmergencyPauseError::InsufficientSigners); + } + let config = read_global_pause_admins(env).map_err(|_| EmergencyPauseError::Unauthorized)?; + + for i in 0..signers.len() { + let signer = signers.get_unchecked(i); + if !config.admins.contains(&signer) { + return Err(EmergencyPauseError::Unauthorized); + } + for j in 0..i { + if signers.get_unchecked(j) == signer { + return Err(EmergencyPauseError::DuplicateSigner); + } + } + signer.require_auth(); + } + + Ok(signers.get_unchecked(0)) +} + +/// Read-only: whether the platform-wide emergency halt is active. +pub fn is_platform_paused(env: &Env) -> bool { + env.storage() + .persistent() + .get(&EmergencyPauseDataKey::PlatformPaused) + .unwrap_or(false) +} + +/// Read-only: whether `key_id` has an active per-key emergency override. +pub fn is_key_paused(env: &Env, key_id: &Address) -> bool { + env.storage() + .persistent() + .get(&EmergencyPauseDataKey::KeyPaused(key_id.clone())) + .unwrap_or(false) +} + +/// Read-only: the timestamp at which a queued resume becomes executable. +pub fn resume_eta(env: &Env) -> Option { + env.storage() + .persistent() + .get(&EmergencyPauseDataKey::ResumeEta) +} + +/// Rejects a buy or sell on `key_id` while the platform halt or the key's +/// override is active. +pub fn assert_trading_allowed(env: &Env, key_id: &Address) -> Result<(), ContractError> { + if is_platform_paused(env) || is_key_paused(env, key_id) { + return Err(ContractError::GlobalTradingHalted); + } + Ok(()) +} + +/// Halts all buys and sells across every key, effective immediately. +pub fn pause_platform(env: &Env, signers: &Vec
) -> Result<(), EmergencyPauseError> { + let actor = assert_multisig(env, signers)?; + if is_platform_paused(env) { + return Err(EmergencyPauseError::AlreadyPaused); + } + + let storage = env.storage().persistent(); + storage.set(&EmergencyPauseDataKey::PlatformPaused, &true); + storage.remove(&EmergencyPauseDataKey::ResumeEta); + + env.events().publish( + events::platform_paused_topics(&actor), + PlatformPausedEvent { + actor, + timestamp: env.ledger().timestamp(), + }, + ); + Ok(()) +} + +/// Queues a platform resume that becomes executable after the 24h timelock. +/// Returns the timestamp at which `resume_platform` may be called. +pub fn queue_platform_resume( + env: &Env, + signers: &Vec
, +) -> Result { + let actor = assert_multisig(env, signers)?; + if !is_platform_paused(env) { + return Err(EmergencyPauseError::NotPaused); + } + if resume_eta(env).is_some() { + return Err(EmergencyPauseError::ResumeAlreadyQueued); + } + + let executable_at = env + .ledger() + .timestamp() + .checked_add(PLATFORM_RESUME_DELAY_SECS) + .ok_or(EmergencyPauseError::Overflow)?; + env.storage() + .persistent() + .set(&EmergencyPauseDataKey::ResumeEta, &executable_at); + + env.events().publish( + events::platform_resume_queued_topics(&actor), + PlatformResumeQueuedEvent { + actor, + executable_at, + }, + ); + Ok(executable_at) +} + +/// Lifts the platform halt once a queued resume's timelock has elapsed. +pub fn resume_platform(env: &Env, signers: &Vec
) -> Result<(), EmergencyPauseError> { + let actor = assert_multisig(env, signers)?; + if !is_platform_paused(env) { + return Err(EmergencyPauseError::NotPaused); + } + let executable_at = resume_eta(env).ok_or(EmergencyPauseError::ResumeNotQueued)?; + let now = env.ledger().timestamp(); + if now < executable_at { + return Err(EmergencyPauseError::TimelockNotElapsed); + } + + let storage = env.storage().persistent(); + storage.set(&EmergencyPauseDataKey::PlatformPaused, &false); + storage.remove(&EmergencyPauseDataKey::ResumeEta); + + env.events().publish( + events::platform_resumed_topics(&actor), + PlatformResumedEvent { + actor, + timestamp: now, + }, + ); + Ok(()) +} + +/// Sets or clears the emergency pause override for a single key. Independent of +/// the platform halt: neither state clears or bypasses the other. +pub fn set_key_pause_override( + env: &Env, + signers: &Vec
, + key_id: &Address, + paused: bool, +) -> Result<(), EmergencyPauseError> { + let actor = assert_multisig(env, signers)?; + + let key = EmergencyPauseDataKey::KeyPaused(key_id.clone()); + if paused { + env.storage().persistent().set(&key, &true); + } else { + env.storage().persistent().remove(&key); + } + + env.events().publish( + events::key_pause_override_topics(key_id), + KeyPauseOverrideEvent { + key_id: key_id.clone(), + paused, + actor, + }, + ); + Ok(()) +} diff --git a/creator-keys/src/events.rs b/creator-keys/src/events.rs index 8e85da00..e2e7856b 100644 --- a/creator-keys/src/events.rs +++ b/creator-keys/src/events.rs @@ -3199,6 +3199,110 @@ pub fn leaderboard_config_updated_topics(admin: &Address) -> (Symbol, Address) { (LEADERBOARD_CONFIG_UPDATED_EVENT_NAME, admin.clone()) } +// --- Emergency platform pause events (#1000) --- + +/// Event name emitted when the platform-wide emergency halt activates. +pub const PLATFORM_PAUSED_EVENT_NAME: Symbol = symbol_short!("plat_pau"); + +/// Event name emitted when a platform resume is queued behind the 24h timelock. +pub const PLATFORM_RESUME_QUEUED_EVENT_NAME: Symbol = symbol_short!("plat_rq"); + +/// Event name emitted when the platform-wide emergency halt is lifted. +pub const PLATFORM_RESUMED_EVENT_NAME: Symbol = symbol_short!("plat_res"); + +/// Event name emitted when a per-key emergency pause override is set or cleared. +pub const KEY_PAUSE_OVERRIDE_EVENT_NAME: Symbol = symbol_short!("key_pau"); + +/// Stable field order for [`PlatformPausedEvent`]. +pub const PLATFORM_PAUSED_EVENT_DATA_FIELDS: [&str; 2] = ["actor", "timestamp"]; + +/// Stable field order for [`PlatformResumeQueuedEvent`]. +pub const PLATFORM_RESUME_QUEUED_EVENT_DATA_FIELDS: [&str; 2] = ["actor", "executable_at"]; + +/// Stable field order for [`PlatformResumedEvent`]. +pub const PLATFORM_RESUMED_EVENT_DATA_FIELDS: [&str; 2] = ["actor", "timestamp"]; + +/// Stable field order for [`KeyPauseOverrideEvent`]. +pub const KEY_PAUSE_OVERRIDE_EVENT_DATA_FIELDS: [&str; 3] = ["key_id", "paused", "actor"]; + +/// Stable platform-paused event payload. +/// +/// Event shape: +/// - topics: `(PLATFORM_PAUSED_EVENT_NAME, actor)` +/// - data: `PlatformPausedEvent` +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct PlatformPausedEvent { + /// First signer of the multisig call. + pub actor: Address, + /// Ledger timestamp at which the halt took effect. + pub timestamp: u64, +} + +/// Stable platform-resume-queued event payload. +/// +/// Event shape: +/// - topics: `(PLATFORM_RESUME_QUEUED_EVENT_NAME, actor)` +/// - data: `PlatformResumeQueuedEvent` +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct PlatformResumeQueuedEvent { + /// First signer of the multisig call. + pub actor: Address, + /// Ledger timestamp from which `resume_platform` may execute. + pub executable_at: u64, +} + +/// Stable platform-resumed event payload. +/// +/// Event shape: +/// - topics: `(PLATFORM_RESUMED_EVENT_NAME, actor)` +/// - data: `PlatformResumedEvent` +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct PlatformResumedEvent { + /// First signer of the multisig call. + pub actor: Address, + /// Ledger timestamp at which the halt was lifted. + pub timestamp: u64, +} + +/// Stable key-pause-override event payload. +/// +/// Event shape: +/// - topics: `(KEY_PAUSE_OVERRIDE_EVENT_NAME, key_id)` +/// - data: `KeyPauseOverrideEvent` +#[derive(Clone, Debug, Eq, PartialEq)] +#[contracttype] +pub struct KeyPauseOverrideEvent { + /// Key whose override changed. + pub key_id: Address, + /// `true` when the key was paused, `false` when the override was cleared. + pub paused: bool, + /// First signer of the multisig call. + pub actor: Address, +} + +/// Shared platform-paused event topics tuple. +pub fn platform_paused_topics(actor: &Address) -> (Symbol, Address) { + (PLATFORM_PAUSED_EVENT_NAME, actor.clone()) +} + +/// Shared platform-resume-queued event topics tuple. +pub fn platform_resume_queued_topics(actor: &Address) -> (Symbol, Address) { + (PLATFORM_RESUME_QUEUED_EVENT_NAME, actor.clone()) +} + +/// Shared platform-resumed event topics tuple. +pub fn platform_resumed_topics(actor: &Address) -> (Symbol, Address) { + (PLATFORM_RESUMED_EVENT_NAME, actor.clone()) +} + +/// Shared key-pause-override event topics tuple. +pub fn key_pause_override_topics(key_id: &Address) -> (Symbol, Address) { + (KEY_PAUSE_OVERRIDE_EVENT_NAME, key_id.clone()) +} + // --- Vault rebalancing --- /// Event name for a completed vault rebalance. diff --git a/creator-keys/src/lib.rs b/creator-keys/src/lib.rs index edbfee66..6966f8ef 100644 --- a/creator-keys/src/lib.rs +++ b/creator-keys/src/lib.rs @@ -14,6 +14,7 @@ pub mod acl_limits_merge_sunset; /// declaration, so `subscribe_key_access` and `is_subscribed` compiled nowhere /// and could not be called. pub mod curve_subscriptions_swaps; +pub mod emergency_pause; pub mod events; pub mod lp_reward; pub mod ratings_royalties_dividends; @@ -5341,6 +5342,7 @@ impl CreatorKeysContract { referrer: Option
, ) -> Result { buyer.require_auth(); + emergency_pause::assert_trading_allowed(&env, &creator)?; assert_global_trading_not_halted(&env)?; assert_key_trading_not_paused(&env, &creator)?; assert_not_paused(&env)?; @@ -5750,6 +5752,7 @@ impl CreatorKeysContract { referrer: Option
, ) -> Result { buyer.require_auth(); + emergency_pause::assert_trading_allowed(&env, &creator)?; assert_global_trading_not_halted(&env)?; assert_key_trading_not_paused(&env, &creator)?; assert_not_paused(&env)?; @@ -6191,6 +6194,7 @@ impl CreatorKeysContract { min_proceeds: Option, ) -> Result { seller.require_auth(); + emergency_pause::assert_trading_allowed(&env, &creator)?; assert_global_trading_not_halted(&env)?; assert_key_trading_not_paused(&env, &creator)?; assert_not_paused(&env)?; @@ -8819,6 +8823,7 @@ impl CreatorKeysContract { for order in orders.iter() { let (creator, quantity, max_price) = order; + emergency_pause::assert_trading_allowed(&env, &creator)?; if quantity == 0 { return Err(ContractError::NotPositiveAmount); @@ -11604,6 +11609,68 @@ impl CreatorKeysContract { Ok(()) } + // ========================================================================= + // #1000 — Emergency platform pause + // ========================================================================= + + /// Halts every bonding-curve buy and sell across all keys in one call. + /// + /// `signers` must hold at least two distinct members of the global-pause + /// admin set, each of whom must authorise the call. Emits `PlatformPaused`. + pub fn pause_platform( + env: Env, + signers: Vec
, + ) -> Result<(), emergency_pause::EmergencyPauseError> { + emergency_pause::pause_platform(&env, &signers) + } + + /// Queues a platform resume, executable after the 24h timelock. Same + /// multisig requirement as [`Self::pause_platform`]. Returns the timestamp + /// at which [`Self::resume_platform`] may be called. + pub fn queue_platform_resume( + env: Env, + signers: Vec
, + ) -> Result { + emergency_pause::queue_platform_resume(&env, &signers) + } + + /// Lifts the platform halt once a queued resume's 24h timelock has elapsed. + /// Same multisig requirement as [`Self::pause_platform`]. Emits + /// `PlatformResumed`. Per-key overrides are left untouched. + pub fn resume_platform( + env: Env, + signers: Vec
, + ) -> Result<(), emergency_pause::EmergencyPauseError> { + emergency_pause::resume_platform(&env, &signers) + } + + /// Read-only view: whether the platform-wide emergency halt is active. + pub fn is_paused(env: Env) -> bool { + emergency_pause::is_platform_paused(&env) + } + + /// Read-only view: the timestamp at which a queued resume becomes executable. + pub fn get_platform_resume_eta(env: Env) -> Option { + emergency_pause::resume_eta(&env) + } + + /// Sets or clears the emergency pause override for a single key. Same + /// multisig requirement as [`Self::pause_platform`]; independent of the + /// platform halt. + pub fn set_key_pause_override( + env: Env, + signers: Vec
, + key_id: Address, + paused: bool, + ) -> Result<(), emergency_pause::EmergencyPauseError> { + emergency_pause::set_key_pause_override(&env, &signers, &key_id, paused) + } + + /// Read-only view: whether `key_id` has an active emergency pause override. + pub fn is_key_paused(env: Env, key_id: Address) -> bool { + emergency_pause::is_key_paused(&env, &key_id) + } + // ========================================================================= // #763 — Vesting schedule // ========================================================================= @@ -12655,6 +12722,7 @@ impl CreatorKeysContract { for order in orders.iter() { let (creator, quantity) = order; + emergency_pause::assert_trading_allowed(&env, &creator)?; if quantity == 0 { return Err(ContractError::NotPositiveAmount); } @@ -12790,6 +12858,7 @@ impl CreatorKeysContract { // Validate each order and check that the caller holds sufficient liquid balance. for i in 0..orders.len() { let (creator, qty) = orders.get(i).unwrap(); + emergency_pause::assert_trading_allowed(&env, &creator)?; if qty == 0 { return Err(ContractError::NotPositiveAmount); } @@ -18152,3 +18221,6 @@ mod test_unique_traders; #[cfg(test)] mod test_lp_reward; + +#[cfg(test)] +mod test_issue_1000; diff --git a/creator-keys/src/test_issue_1000.rs b/creator-keys/src/test_issue_1000.rs new file mode 100644 index 00000000..e3ddcb34 --- /dev/null +++ b/creator-keys/src/test_issue_1000.rs @@ -0,0 +1,382 @@ +//! Tests for issue #1000 (emergency pause for a platform-wide trading halt). + +use crate::emergency_pause::{EmergencyPauseError, PLATFORM_RESUME_DELAY_SECS}; +use crate::events::{self, PlatformPausedEvent, PlatformResumedEvent}; +use crate::{ContractError, CreatorKeysContract, CreatorKeysContractClient, RegisterCreatorParams}; +use soroban_sdk::{ + testutils::{Address as _, Events as _, Ledger as _}, + vec, Address, Env, String, Symbol, TryIntoVal, Vec, +}; + +struct Setup { + env: Env, + client: CreatorKeysContractClient<'static>, + admins: Vec
, + creator_a: Address, + creator_b: Address, + holder: Address, +} + +const START_TS: u64 = 1_000_000; + +fn setup() -> Setup { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(START_TS); + + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let admin = Address::generate(&env); + let treasury = Address::generate(&env); + client.set_protocol_admin(&admin, &admin); + client.set_treasury_address(&admin, &treasury); + client.set_key_price(&admin, &100i128); + client.set_fee_config(&admin, &9000u32, &1000u32); + client.set_protocol_fee_recipient(&admin, &treasury); + + let admins = vec![ + &env, + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + ]; + client.set_global_pause_admins(&admin, &admins); + + let creator_a = Address::generate(&env); + let creator_b = Address::generate(&env); + register(&env, &client, &creator_a, "alice"); + register(&env, &client, &creator_b, "bobby"); + + // Seed a holder on both keys so sells have something to sell. + let holder = Address::generate(&env); + for creator in [&creator_a, &creator_b] { + client.buy_key(creator, &holder, &1000i128, &None); + client.buy_key(creator, &holder, &1000i128, &None); + } + // Step past the flash-loan guard so the holder's sells are otherwise valid. + next_ledger(&env); + + Setup { + env, + client, + admins, + creator_a, + creator_b, + holder, + } +} + +fn register(env: &Env, client: &CreatorKeysContractClient, creator: &Address, handle: &str) { + client.register_creator( + &RegisterCreatorParams { + creator: creator.clone(), + handle: String::from_str(env, handle), + }, + &None, + &None, + &None, + &None, + &None, + &None, + ); +} + +/// Two distinct admins — the minimum multisig quorum. +fn quorum(s: &Setup) -> Vec
{ + vec![&s.env, s.admins.get_unchecked(0), s.admins.get_unchecked(1)] +} + +fn assert_buy_halted(s: &Setup, creator: &Address) { + let buyer = Address::generate(&s.env); + assert_eq!( + s.client.try_buy_key(creator, &buyer, &1000i128, &None), + Err(Ok(ContractError::GlobalTradingHalted)) + ); +} + +fn assert_sell_halted(s: &Setup, creator: &Address) { + assert_eq!( + s.client.try_sell_key(creator, &s.holder, &None), + Err(Ok(ContractError::GlobalTradingHalted)) + ); +} + +fn next_ledger(env: &Env) { + let current = env.ledger().sequence(); + env.ledger().set_sequence_number(current + 1); +} + +fn assert_can_trade(s: &Setup, creator: &Address) { + let buyer = Address::generate(&s.env); + s.client.buy_key(creator, &buyer, &1000i128, &None); + next_ledger(&s.env); + s.client.sell_key(creator, &buyer, &None); +} + +// ─── pause_platform halts all keys ─────────────────────────────────────── + +#[test] +fn pause_platform_halts_buy_and_sell_on_all_keys() { + let s = setup(); + s.client.pause_platform(&quorum(&s)); + + for creator in [&s.creator_a, &s.creator_b] { + assert_buy_halted(&s, creator); + assert_sell_halted(&s, creator); + assert_eq!( + s.client + .try_buy_keys(creator, &s.holder, &1u32, &1000i128, &None), + Err(Ok(ContractError::GlobalTradingHalted)) + ); + } + + let orders = vec![&s.env, (s.creator_a.clone(), 1u32)]; + assert_eq!( + s.client.try_batch_buy(&s.holder, &orders), + Err(Ok(ContractError::GlobalTradingHalted)) + ); + assert_eq!( + s.client.try_batch_sell(&s.holder, &orders), + Err(Ok(ContractError::GlobalTradingHalted)) + ); + let v2_orders = vec![&s.env, (s.creator_b.clone(), 1u32, None::)]; + assert_eq!( + s.client.try_batch_buy_v2(&s.holder, &v2_orders), + Err(Ok(ContractError::GlobalTradingHalted)) + ); +} + +#[test] +fn pause_platform_requires_every_signer_to_authorise() { + let s = setup(); + let signers = quorum(&s); + s.client.pause_platform(&signers); + + let auths = s.env.auths(); + for signer in signers.iter() { + assert!(auths.iter().any(|(addr, _)| *addr == signer)); + } +} + +#[test] +fn pause_platform_rejects_invalid_signer_sets() { + let s = setup(); + let single = vec![&s.env, s.admins.get_unchecked(0)]; + assert_eq!( + s.client.try_pause_platform(&single), + Err(Ok(EmergencyPauseError::InsufficientSigners)) + ); + + let duplicate = vec![&s.env, s.admins.get_unchecked(0), s.admins.get_unchecked(0)]; + assert_eq!( + s.client.try_pause_platform(&duplicate), + Err(Ok(EmergencyPauseError::DuplicateSigner)) + ); + + let outsider = vec![&s.env, s.admins.get_unchecked(0), Address::generate(&s.env)]; + assert_eq!( + s.client.try_pause_platform(&outsider), + Err(Ok(EmergencyPauseError::Unauthorized)) + ); + + assert!(!s.client.is_paused()); + assert_can_trade(&s, &s.creator_a); +} + +#[test] +fn pause_platform_without_admin_set_is_unauthorized() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(CreatorKeysContract, ()); + let client = CreatorKeysContractClient::new(&env, &contract_id); + + let signers = vec![&env, Address::generate(&env), Address::generate(&env)]; + assert_eq!( + client.try_pause_platform(&signers), + Err(Ok(EmergencyPauseError::Unauthorized)) + ); +} + +#[test] +fn pause_platform_twice_is_rejected() { + let s = setup(); + s.client.pause_platform(&quorum(&s)); + assert_eq!( + s.client.try_pause_platform(&quorum(&s)), + Err(Ok(EmergencyPauseError::AlreadyPaused)) + ); +} + +// ─── PlatformPaused event ──────────────────────────────────────────────── + +#[test] +fn pause_platform_emits_event_with_timestamp_and_actor() { + let s = setup(); + let signers = quorum(&s); + s.client.pause_platform(&signers); + + let events = s.env.events().all(); + let (_contract, topics, data) = events.last().unwrap(); + let name: Symbol = topics.get(0).unwrap().try_into_val(&s.env).unwrap(); + let topic_actor: Address = topics.get(1).unwrap().try_into_val(&s.env).unwrap(); + assert_eq!(name, events::PLATFORM_PAUSED_EVENT_NAME); + assert_eq!(topic_actor, signers.get_unchecked(0)); + + let payload: PlatformPausedEvent = data.try_into_val(&s.env).unwrap(); + assert_eq!( + payload, + PlatformPausedEvent { + actor: signers.get_unchecked(0), + timestamp: START_TS, + } + ); +} + +// ─── resume_platform 24h timelock ──────────────────────────────────────── + +#[test] +fn resume_platform_blocked_until_timelock_elapses() { + let s = setup(); + s.client.pause_platform(&quorum(&s)); + + let eta = s.client.queue_platform_resume(&quorum(&s)); + assert_eq!(eta, START_TS + PLATFORM_RESUME_DELAY_SECS); + assert_eq!(s.client.get_platform_resume_eta(), Some(eta)); + + assert_eq!( + s.client.try_resume_platform(&quorum(&s)), + Err(Ok(EmergencyPauseError::TimelockNotElapsed)) + ); + + s.env.ledger().set_timestamp(eta - 1); + assert_eq!( + s.client.try_resume_platform(&quorum(&s)), + Err(Ok(EmergencyPauseError::TimelockNotElapsed)) + ); + assert!(s.client.is_paused()); + assert_buy_halted(&s, &s.creator_a); + + s.env.ledger().set_timestamp(eta); + s.client.resume_platform(&quorum(&s)); + assert!(!s.client.is_paused()); + assert_eq!(s.client.get_platform_resume_eta(), None); + assert_can_trade(&s, &s.creator_a); + assert_can_trade(&s, &s.creator_b); +} + +#[test] +fn resume_platform_emits_event_with_timestamp_and_actor() { + let s = setup(); + s.client.pause_platform(&quorum(&s)); + let eta = s.client.queue_platform_resume(&quorum(&s)); + s.env.ledger().set_timestamp(eta); + + let signers = vec![&s.env, s.admins.get_unchecked(2), s.admins.get_unchecked(1)]; + s.client.resume_platform(&signers); + + let events = s.env.events().all(); + let (_contract, topics, data) = events.last().unwrap(); + let name: Symbol = topics.get(0).unwrap().try_into_val(&s.env).unwrap(); + assert_eq!(name, events::PLATFORM_RESUMED_EVENT_NAME); + let payload: PlatformResumedEvent = data.try_into_val(&s.env).unwrap(); + assert_eq!( + payload, + PlatformResumedEvent { + actor: s.admins.get_unchecked(2), + timestamp: eta, + } + ); +} + +#[test] +fn resume_platform_requires_queue_and_multisig() { + let s = setup(); + assert_eq!( + s.client.try_queue_platform_resume(&quorum(&s)), + Err(Ok(EmergencyPauseError::NotPaused)) + ); + + s.client.pause_platform(&quorum(&s)); + assert_eq!( + s.client.try_resume_platform(&quorum(&s)), + Err(Ok(EmergencyPauseError::ResumeNotQueued)) + ); + + let eta = s.client.queue_platform_resume(&quorum(&s)); + assert_eq!( + s.client.try_queue_platform_resume(&quorum(&s)), + Err(Ok(EmergencyPauseError::ResumeAlreadyQueued)) + ); + + s.env.ledger().set_timestamp(eta); + let single = vec![&s.env, s.admins.get_unchecked(0)]; + assert_eq!( + s.client.try_resume_platform(&single), + Err(Ok(EmergencyPauseError::InsufficientSigners)) + ); + assert!(s.client.is_paused()); +} + +// ─── per-key pause override ────────────────────────────────────────────── + +#[test] +fn key_override_halts_only_that_key_while_platform_live() { + let s = setup(); + s.client + .set_key_pause_override(&quorum(&s), &s.creator_a, &true); + + assert!(!s.client.is_paused()); + assert!(s.client.is_key_paused(&s.creator_a)); + assert!(!s.client.is_key_paused(&s.creator_b)); + assert_buy_halted(&s, &s.creator_a); + assert_sell_halted(&s, &s.creator_a); + assert_can_trade(&s, &s.creator_b); + + s.client + .set_key_pause_override(&quorum(&s), &s.creator_a, &false); + assert!(!s.client.is_key_paused(&s.creator_a)); + assert_can_trade(&s, &s.creator_a); +} + +#[test] +fn key_override_survives_platform_resume() { + let s = setup(); + s.client + .set_key_pause_override(&quorum(&s), &s.creator_a, &true); + s.client.pause_platform(&quorum(&s)); + let eta = s.client.queue_platform_resume(&quorum(&s)); + s.env.ledger().set_timestamp(eta); + s.client.resume_platform(&quorum(&s)); + + assert!(s.client.is_key_paused(&s.creator_a)); + assert_buy_halted(&s, &s.creator_a); + assert_can_trade(&s, &s.creator_b); +} + +#[test] +fn clearing_key_override_does_not_bypass_platform_pause() { + let s = setup(); + s.client + .set_key_pause_override(&quorum(&s), &s.creator_a, &true); + s.client.pause_platform(&quorum(&s)); + s.client + .set_key_pause_override(&quorum(&s), &s.creator_a, &false); + + assert!(!s.client.is_key_paused(&s.creator_a)); + assert!(s.client.is_paused()); + assert_buy_halted(&s, &s.creator_a); + assert_sell_halted(&s, &s.creator_a); +} + +#[test] +fn key_override_requires_multisig() { + let s = setup(); + let single = vec![&s.env, s.admins.get_unchecked(0)]; + assert_eq!( + s.client + .try_set_key_pause_override(&single, &s.creator_a, &true), + Err(Ok(EmergencyPauseError::InsufficientSigners)) + ); + assert!(!s.client.is_key_paused(&s.creator_a)); +} diff --git a/creator-keys/tests/supply_cap_enforcement.rs b/creator-keys/tests/supply_cap_enforcement.rs index 3b1a8a31..9eeeb262 100644 --- a/creator-keys/tests/supply_cap_enforcement.rs +++ b/creator-keys/tests/supply_cap_enforcement.rs @@ -31,8 +31,9 @@ const KEY_PRICE: i128 = 1_000; fn metadata(env: &Env) -> KeyMetadata { KeyMetadata { name: String::from_str(env, "Capped Key"), - bio: String::from_str(env, "supply cap test key"), - avatar_uri: String::from_str(env, "ipfs://avatar"), + symbol: String::from_str(env, "CAP"), + description: String::from_str(env, "supply cap test key"), + image_cid: String::from_str(env, "ipfs://avatar"), } } diff --git a/docs/contract-event-conventions.md b/docs/contract-event-conventions.md index b3b32ff1..7ff69121 100644 --- a/docs/contract-event-conventions.md +++ b/docs/contract-event-conventions.md @@ -82,5 +82,17 @@ Notes for indexers: its escalation budget ran out reports `quorum_reached == false` together with `finalized_by_exhaustion == true`. +### Emergency Platform Pause Events (#1000) + +| Event Name | Topics (Index 0, 1, 2) | Data Fields | Data Type | +| :--- | :--- | :--- | :--- | +| `plat_pau` | `(Symbol("plat_pau"), actor)` | `actor`, `timestamp` | `struct PlatformPausedEvent` | +| `plat_rq` | `(Symbol("plat_rq"), actor)` | `actor`, `executable_at` | `struct PlatformResumeQueuedEvent` | +| `plat_res` | `(Symbol("plat_res"), actor)` | `actor`, `timestamp` | `struct PlatformResumedEvent` | +| `key_pau` | `(Symbol("key_pau"), key_id)` | `key_id`, `paused`, `actor` | `struct KeyPauseOverrideEvent` | + +`actor` is the first signer of the multisig call. `timestamp` and +`executable_at` are ledger timestamps in seconds, not ledger sequence numbers. + ## Data Type Inconsistency While the general preference is for `struct` payloads (like `register`), some high-frequency events like `buy` and `sell` use `tuples` for gas efficiency. Indexers should check the `contracttype` encoding to distinguish between map-based structs and array-based tuples. diff --git a/docs/emergency-pause-runbook.md b/docs/emergency-pause-runbook.md index a866d1e2..f4903454 100644 --- a/docs/emergency-pause-runbook.md +++ b/docs/emergency-pause-runbook.md @@ -190,3 +190,39 @@ rather than deliberate. If you are uncertain whether the fix is sufficient, keep the contract paused and consult additional reviewers before proceeding. + +--- + +## 9. Multisig Platform Trading Halt (#1000) + +Separate from the single-admin `pause`/`unpause` above, the multisig admin set +configured with `set_global_pause_admins` can halt **bonding-curve trading only** +(every buy and sell across all keys) without blocking other entry points. + +Every call below takes `signers: Vec
`: at least **two distinct** +members of the global-pause admin set, each of whom must sign the transaction. +Fewer signers, a repeated signer, or a non-member fails with +`EmergencyPauseError::{InsufficientSigners, DuplicateSigner, Unauthorized}`. + +| Entry point | Effect | +|---|---| +| `pause_platform(signers)` | Halts trading immediately; emits `plat_pau` with `actor` and `timestamp` | +| `queue_platform_resume(signers)` | Starts the 24h timelock; returns the executable timestamp; emits `plat_rq` | +| `resume_platform(signers)` | Lifts the halt once the timelock has elapsed; emits `plat_res` | +| `set_key_pause_override(signers, key_id, paused)` | Halts or releases a single key; emits `key_pau` | +| `is_paused()` / `get_platform_resume_eta()` / `is_key_paused(key_id)` | Read-only views | + +While halted, `buy_key`, `buy_keys`, `buy_key_with_referrer`, +`buy_keys_with_referrer`, `sell_key`, `batch_buy`, `batch_buy_v2` and +`batch_sell` return `ContractError::GlobalTradingHalted`. + +Operational notes: + +- `resume_platform` before the queued timestamp fails with + `TimelockNotElapsed`; use the 24h window to complete the checklist in + section 6. +- The per-key override is independent of the platform halt: resuming the + platform does not release an overridden key, and clearing a key's override + does not let it trade while the platform is halted. +- `is_paused` reports only this platform halt. The legacy protocol pause is + still reported by `get_is_paused`.