Skip to content
Merged
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
6 changes: 6 additions & 0 deletions bettapay_common/src/error_codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions bettapay_common/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions governance_contract/src/anchor_no_event_error_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();

Expand Down
33 changes: 8 additions & 25 deletions governance_contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,32 +107,17 @@
//! | 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 |
//! | 8 | `RecoveryNotPending` | No recovery operation is currently pending |
//! | 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
Expand Down Expand Up @@ -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,
}
Expand All @@ -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);
};

Expand Down Expand Up @@ -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<Address>) {
Expand All @@ -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 {
Expand Down
12 changes: 8 additions & 4 deletions settlement_contract/src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,16 +235,20 @@ impl SettlementContract {

pub fn pause(env: Env, signers: Vec<Address>) {
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<Address>) {
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 {
Expand Down
6 changes: 6 additions & 0 deletions settlement_contract/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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!(
Expand Down
48 changes: 48 additions & 0 deletions settlement_contract/src/tests/admin_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
8 changes: 5 additions & 3 deletions settlement_contract/src/tests/conformity_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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),
(
Expand Down