diff --git a/bettapay_common/src/error_codes.rs b/bettapay_common/src/error_codes.rs index 89fa4aca..a193ae28 100644 --- a/bettapay_common/src/error_codes.rs +++ b/bettapay_common/src/error_codes.rs @@ -48,6 +48,10 @@ pub const OPERATION_ALREADY_SCHEDULED: u32 = 12; pub const INVALID_WASM_INTERFACE: u32 = 13; /// The provided multisig threshold is invalid. pub const INVALID_THRESHOLD: u32 = 14; +/// `pause` was called while the contract was already paused. +pub const ALREADY_PAUSED: u32 = 15; +/// `unpause` was called while the contract was already unpaused. +pub const ALREADY_UNPAUSED: u32 = 16; /// Lowest code reserved for `governance_contract`-only errors. pub const GOVERNANCE_RANGE_START: u32 = 200; @@ -71,6 +75,8 @@ pub const SHARED_CODES: &[(&str, u32)] = &[ ("OperationAlreadyScheduled", OPERATION_ALREADY_SCHEDULED), ("InvalidWasmInterface", INVALID_WASM_INTERFACE), ("InvalidThreshold", INVALID_THRESHOLD), + ("AlreadyPaused", ALREADY_PAUSED), + ("AlreadyUnpaused", ALREADY_UNPAUSED), ]; /// Asserts that a contract's full `(name, code)` table is internally diff --git a/bettapay_common/src/storage.rs b/bettapay_common/src/storage.rs index 7b165824..83fe55c3 100644 --- a/bettapay_common/src/storage.rs +++ b/bettapay_common/src/storage.rs @@ -15,6 +15,24 @@ //! reads back identically through `bettapay_common::CommonDataKey::Paused`, //! which is what allows both contracts to share this enum without disturbing //! any existing storage entry. +//! +//! ## Pause helpers +//! +//! The full pause lifecycle — flag storage, event emission, and idempotency +//! semantics — is consolidated here so neither contract re-implements it: +//! +//! | Helper | What it does | +//! |---|---| +//! | [`is_paused`] | Reads the flag; bumps instance TTL | +//! | [`set_paused`] | Writes the flag (raw; no event) | +//! | [`apply_pause`] | Writes `true` **and** emits `paused` event | +//! | [`apply_unpause`] | Writes `false` **and** emits `unpaused` event | +//! +//! Contracts call [`apply_pause`] / [`apply_unpause`] from their `pause` / +//! `unpause` entry-points after verifying admin auth and idempotency (which +//! must panic with contract-specific error types and therefore cannot live +//! here). The `assert_not_paused` guards in each contract remain thin +//! wrappers that call [`is_paused`] and panic with their own `Error::Paused`. use soroban_sdk::{contracttype, Address, Env, String, Vec}; @@ -68,6 +86,27 @@ pub fn set_paused(env: &Env, paused: bool) { .set(&CommonDataKey::Paused, &paused); } +/// Atomically sets the pause flag and emits the canonical `paused` event. +/// +/// This is the single implementation of the pause *action* shared by every +/// BettaPay contract. Callers are still responsible for verifying admin +/// auth and checking idempotency (via [`is_paused`]) before calling this, +/// since those checks produce contract-specific errors that cannot live here. +pub fn apply_pause(env: &Env, admin: &Address) { + set_paused(env, true); + crate::events::emit_paused(env, admin); +} + +/// Atomically clears the pause flag and emits the canonical `unpaused` event. +/// +/// This is the single implementation of the unpause *action* shared by every +/// BettaPay contract. Callers are still responsible for verifying admin +/// auth and checking idempotency (via [`is_paused`]) before calling this. +pub fn apply_unpause(env: &Env, admin: &Address) { + set_paused(env, false); + crate::events::emit_unpaused(env, admin); +} + /// Returns the first entry of a stored multisig admin list. /// /// Both `governance_contract` and `settlement_contract` store their admin diff --git a/governance_contract/src/anchor_no_event_error_tests.rs b/governance_contract/src/anchor_no_event_error_tests.rs index cfcf436b..16a847e2 100644 --- a/governance_contract/src/anchor_no_event_error_tests.rs +++ b/governance_contract/src/anchor_no_event_error_tests.rs @@ -241,7 +241,7 @@ fn change_threshold_emits_no_event_when_insufficient_signatures() { // --------------------------------------------------------------------------- #[test] -#[should_panic(expected = "Error(Contract, #202)")] +#[should_panic(expected = "Error(Contract, #15)")] fn pause_emits_no_event_when_already_paused() { let (env, client, admins) = setup(); client.pause(&admins); @@ -256,7 +256,7 @@ fn pause_emits_no_event_when_already_paused() { } #[test] -#[should_panic(expected = "Error(Contract, #203)")] +#[should_panic(expected = "Error(Contract, #16)")] fn unpause_emits_no_event_when_already_unpaused() { let (env, client, admins) = setup(); diff --git a/governance_contract/src/lib.rs b/governance_contract/src/lib.rs index 82f89b76..42ec4c87 100644 --- a/governance_contract/src/lib.rs +++ b/governance_contract/src/lib.rs @@ -107,21 +107,6 @@ //! | 2 | `NotInitialized` | Admin not yet set | //! | 3 | `Unauthorized` | Caller is not the admin | //! | 4 | `InvalidFeeBps` | Fee value out of range or combined sum > 10 000 bps | -//! | 5 | `AnchorMissing` | Tried to remove an unregistered anchor | -//! | 6 | `Paused` | Contract is paused | -//! | 7 | `InvalidAdmin` | Transfer target is zero-address or current admin | -//! | 8 | `InvalidParamValue` | Supplied system parameter value is negative | -//! | 9 | `InvalidRecoveryAddress` | Recovery address is zero-address or otherwise invalid | -//! | 10 | `RecoveryNotPending` | No recovery operation is currently pending | -//! | 11 | `RecoveryDelayActive` | Recovery delay period has not yet elapsed | -//! | 12 | `AlreadyPaused` | `pause` called while the contract was already paused | -//! | 13 | `AlreadyUnpaused` | `unpause` called while the contract was already unpaused | -//! | 14 | `ExecutionNotReady` | The scheduled operation is not yet ready for execution | -//! | 15 | `OperationNotScheduled` | The operation has not been scheduled | -//! | 16 | `OperationAlreadyScheduled` | The operation has already been scheduled | -//! | 17 | `InvalidWasmInterface` | The deployed WASM does not implement the required interface | -//! | 18 | `InvalidThreshold` | The provided multisig threshold is invalid | -//! | 19 | `SameAdmin` | Transfer target is identical to the current admin set and threshold | //! | 5 | `Paused` | Contract is paused | //! | 6 | `InvalidAdmin` | Transfer target is zero-address or current admin | //! | 7 | `InvalidRecoveryAddress` | Recovery address is zero-address or otherwise invalid | @@ -129,10 +114,10 @@ //! | 9 | `RecoveryDelayActive` | Recovery delay period has not yet elapsed | //! | 13 | `InvalidWasmInterface` | The deployed WASM does not implement the required interface | //! | 14 | `InvalidThreshold` | The provided multisig threshold is invalid | +//! | 15 | `AlreadyPaused` | `pause` called while the contract was already paused | +//! | 16 | `AlreadyUnpaused` | `unpause` called while the contract was already unpaused | //! | 200 | `AnchorMissing` | Tried to remove an unregistered anchor | //! | 201 | `InvalidParamValue` | Supplied system parameter value is invalid or out of bounds | -//! | 202 | `AlreadyPaused` | `pause` called while the contract was already paused | -//! | 203 | `AlreadyUnpaused` | `unpause` called while the contract was already unpaused | //! | 204 | `SameAdmin` | Transfer target is identical to the current admin set and threshold | //! //! ## Event Conventions @@ -286,9 +271,9 @@ pub enum GovernanceError { AnchorMissing = 200, InvalidParamValue = 201, /// `pause` was called while the contract was already paused. - AlreadyPaused = 202, + AlreadyPaused = 15, /// `unpause` was called while the contract was already unpaused. - AlreadyUnpaused = 203, + AlreadyUnpaused = 16, /// The new admin set and threshold are identical to the current ones. SameAdmin = 204, } @@ -309,8 +294,8 @@ const _: () = { assert!(GovernanceError::InvalidThreshold as u32 == error_codes::INVALID_THRESHOLD); assert!(GovernanceError::AnchorMissing as u32 >= error_codes::GOVERNANCE_RANGE_START); assert!(GovernanceError::InvalidParamValue as u32 >= error_codes::GOVERNANCE_RANGE_START); - assert!(GovernanceError::AlreadyPaused as u32 >= error_codes::GOVERNANCE_RANGE_START); - assert!(GovernanceError::AlreadyUnpaused as u32 >= error_codes::GOVERNANCE_RANGE_START); + assert!(GovernanceError::AlreadyPaused as u32 == error_codes::ALREADY_PAUSED); + assert!(GovernanceError::AlreadyUnpaused as u32 == error_codes::ALREADY_UNPAUSED); assert!(GovernanceError::SameAdmin as u32 >= error_codes::GOVERNANCE_RANGE_START); }; @@ -566,8 +551,7 @@ impl GovernanceContract { panic_with_error!(&env, GovernanceError::AlreadyPaused); } let admin = signers.get(0).unwrap(); - storage::set_paused(&env, true); - events::emit_paused(&env, &admin); + storage::apply_pause(&env, &admin); } pub fn unpause(env: Env, signers: Vec
) { @@ -576,8 +560,7 @@ impl GovernanceContract { panic_with_error!(&env, GovernanceError::AlreadyUnpaused); } let admin = signers.get(0).unwrap(); - storage::set_paused(&env, false); - events::emit_unpaused(&env, &admin); + storage::apply_unpause(&env, &admin); } pub fn is_paused(env: Env) -> bool { diff --git a/settlement_contract/src/admin.rs b/settlement_contract/src/admin.rs index d71dbf25..0f4ee4a9 100644 --- a/settlement_contract/src/admin.rs +++ b/settlement_contract/src/admin.rs @@ -235,16 +235,20 @@ impl SettlementContract { pub fn pause(env: Env, signers: Vec
) { verify_admin_auth(&env, &signers, read_threshold(&env)); + if storage::is_paused(&env) { + panic_with_error!(&env, SettlementError::AlreadyPaused); + } let admin = signers.get(0).unwrap(); - storage::set_paused(&env, true); - events::emit_paused(&env, &admin); + storage::apply_pause(&env, &admin); } pub fn unpause(env: Env, signers: Vec
) { verify_admin_auth(&env, &signers, read_threshold(&env)); + if !storage::is_paused(&env) { + panic_with_error!(&env, SettlementError::AlreadyUnpaused); + } let admin = signers.get(0).unwrap(); - storage::set_paused(&env, false); - events::emit_unpaused(&env, &admin); + storage::apply_unpause(&env, &admin); } pub fn is_paused(env: Env) -> bool { diff --git a/settlement_contract/src/errors.rs b/settlement_contract/src/errors.rs index f9e357c9..711236a2 100644 --- a/settlement_contract/src/errors.rs +++ b/settlement_contract/src/errors.rs @@ -39,6 +39,10 @@ pub enum SettlementError { InvalidWasmInterface = 13, /// The provided multisig threshold is invalid. InvalidThreshold = 14, + /// `pause` was called while the contract was already paused. + AlreadyPaused = 15, + /// `unpause` was called while the contract was already unpaused. + AlreadyUnpaused = 16, /// `register_merchant` was called for an address that is already registered. MerchantExists = 300, /// The target merchant address is not registered. Raised by @@ -104,6 +108,8 @@ const _: () = { ); assert!(SettlementError::InvalidWasmInterface as u32 == error_codes::INVALID_WASM_INTERFACE); assert!(SettlementError::InvalidThreshold as u32 == error_codes::INVALID_THRESHOLD); + assert!(SettlementError::AlreadyPaused as u32 == error_codes::ALREADY_PAUSED); + assert!(SettlementError::AlreadyUnpaused as u32 == error_codes::ALREADY_UNPAUSED); assert!(SettlementError::MerchantExists as u32 >= error_codes::SETTLEMENT_RANGE_START); assert!(SettlementError::MerchantMissing as u32 >= error_codes::SETTLEMENT_RANGE_START); assert!( diff --git a/settlement_contract/src/tests/admin_tests.rs b/settlement_contract/src/tests/admin_tests.rs index 2460cbd9..28526a83 100644 --- a/settlement_contract/src/tests/admin_tests.rs +++ b/settlement_contract/src/tests/admin_tests.rs @@ -242,6 +242,54 @@ fn pause_rejected_for_non_admin() { client.pause(&soroban_sdk::vec![&env, non_admin]); } +// --------------------------------------------------------------------------- +// Pause idempotency (mirrors governance — both contracts must behave the same) +// --------------------------------------------------------------------------- + +#[test] +#[should_panic(expected = "Error(Contract, #15)")] +fn pause_rejected_when_already_paused() { + let (_env, client, admins, _merchant) = setup(); + client.pause(&admins); + // Second pause must reject with AlreadyPaused (#15) and emit no extra event. + client.pause(&admins); +} + +#[test] +#[should_panic(expected = "Error(Contract, #16)")] +fn unpause_rejected_when_already_unpaused() { + let (_env, client, admins, _merchant) = setup(); + // Contract starts unpaused; calling unpause immediately must reject with AlreadyUnpaused (#16). + client.unpause(&admins); +} + +#[test] +#[should_panic(expected = "Error(Contract, #15)")] +fn double_pause_emits_no_extra_event() { + let (env, client, admins, _merchant) = setup(); + client.pause(&admins); + let prev = env.events().all().len(); + client.pause(&admins); + assert_eq!( + env.events().all().len(), + prev, + "double pause must not emit events" + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #16)")] +fn unpause_when_not_paused_emits_no_event() { + let (env, client, admins, _merchant) = setup(); + let prev = env.events().all().len(); + client.unpause(&admins); + assert_eq!( + env.events().all().len(), + prev, + "unpause when not paused must not emit events" + ); +} + #[test] #[should_panic(expected = "Error(Contract, #5)")] fn merchant_registration_blocked_when_paused() { diff --git a/settlement_contract/src/tests/conformity_tests.rs b/settlement_contract/src/tests/conformity_tests.rs index cfc45718..ef00d118 100644 --- a/settlement_contract/src/tests/conformity_tests.rs +++ b/settlement_contract/src/tests/conformity_tests.rs @@ -40,18 +40,18 @@ fn governance_codes() -> [(&'static str, u32); 16] { GovernanceError::InvalidWasmInterface as u32, ), ("InvalidThreshold", GovernanceError::InvalidThreshold as u32), + ("AlreadyPaused", GovernanceError::AlreadyPaused as u32), + ("AlreadyUnpaused", GovernanceError::AlreadyUnpaused as u32), ("AnchorMissing", GovernanceError::AnchorMissing as u32), ( "InvalidParamValue", GovernanceError::InvalidParamValue as u32, ), - ("AlreadyPaused", GovernanceError::AlreadyPaused as u32), - ("AlreadyUnpaused", GovernanceError::AlreadyUnpaused as u32), ("SameAdmin", GovernanceError::SameAdmin as u32), ] } -fn settlement_codes() -> [(&'static str, u32); 26] { +fn settlement_codes() -> [(&'static str, u32); 28] { [ ( "AlreadyInitialized", @@ -91,6 +91,8 @@ fn settlement_codes() -> [(&'static str, u32); 26] { SettlementError::InvalidWasmInterface as u32, ), ("InvalidThreshold", SettlementError::InvalidThreshold as u32), + ("AlreadyPaused", SettlementError::AlreadyPaused as u32), + ("AlreadyUnpaused", SettlementError::AlreadyUnpaused as u32), ("MerchantExists", SettlementError::MerchantExists as u32), ("MerchantMissing", SettlementError::MerchantMissing as u32), (