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`.