From 7328071a7140f4c69dcd727b4dc90c746b868659 Mon Sep 17 00:00:00 2001 From: D'Angelo Rodriguez <70290504+dangelo352@users.noreply.github.com> Date: Sun, 21 Jun 2026 20:25:45 -0400 Subject: [PATCH] fix: add marketplace emergency pause --- contracts/EMERGENCY.md | 16 ++- contracts/commitment_marketplace/src/lib.rs | 45 +++++- contracts/commitment_marketplace/src/tests.rs | 131 ++++++++++++++++++ docs/SECURITY_CONSIDERATIONS.md | 2 + 4 files changed, 191 insertions(+), 3 deletions(-) diff --git a/contracts/EMERGENCY.md b/contracts/EMERGENCY.md index 8e6aafae..99440138 100644 --- a/contracts/EMERGENCY.md +++ b/contracts/EMERGENCY.md @@ -28,16 +28,30 @@ The following emergency functions are implemented in the `CommitmentCore` contra - **Access**: Admin only + Emergency mode must be ON. - **Use case**: Fixing state corruption or adjusting parameters during recovery. +The following emergency controls are implemented in the `CommitmentMarketplace` contract: + +1. **`pause(caller: Address)`**: + - **Description**: Pauses marketplace settlement paths. + - **Access**: Admin only. + - **Effect**: + - Disables `list_nft`, `buy_nft`, `accept_offer`, `place_bid`, and `end_auction`. + - Leaves read-only getters available for incident response and off-chain monitoring. + +2. **`unpause(caller: Address)`**: + - **Description**: Re-enables marketplace settlement paths after an incident. + - **Access**: Admin only. + ## Recovery Procedures In the event of an emergency: 1. **Activate Emergency Mode**: Call `set_emergency_mode(true)` immediately to pause all protocol activity. + If the incident affects marketplace settlement, also call `CommitmentMarketplace::pause(admin)`. 2. **Assess Situation**: Identify the cause of the emergency (hack, bug, etc.). 3. **Secure Funds**: If necessary, use `emergency_withdraw` to move assets to a multi-sig or cold storage. 4. **Resolve Issue**: Develop and deploy a fix or a new version of the contract. 5. **Restore State**: Use `emergency_update_commitment` or `emergency_settle` to restore the state for users. -6. **Deactivate Emergency Mode**: Call `set_emergency_mode(false)` once the situation is resolved and it is safe to resume operations. +6. **Deactivate Emergency Mode**: Call `set_emergency_mode(false)` and `CommitmentMarketplace::unpause(admin)` once the situation is resolved and it is safe to resume operations. ## Contact Information diff --git a/contracts/commitment_marketplace/src/lib.rs b/contracts/commitment_marketplace/src/lib.rs index 71a602ce..beb0bb44 100644 --- a/contracts/commitment_marketplace/src/lib.rs +++ b/contracts/commitment_marketplace/src/lib.rs @@ -25,7 +25,7 @@ use soroban_sdk::{ contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env, Symbol, Vec, }; -use shared_utils::math::SafeMath; +use shared_utils::{math::SafeMath, Pausable}; // ============================================================================ // Error Types @@ -80,6 +80,8 @@ pub enum MarketplaceError { TransferFailed = 21, /// Payment token is not allowlisted for marketplace settlement PaymentTokenNotAllowed = 22, + /// Caller is not authorized for admin-only marketplace controls + Unauthorized = 23, } // ============================================================================ @@ -252,6 +254,7 @@ impl CommitmentMarketplace { e.storage() .instance() .set(&DataKey::AllowedPaymentTokens, &allowed_payment_tokens); + e.storage().instance().set(&Pausable::PAUSED_KEY, &false); Ok(()) } @@ -282,6 +285,39 @@ impl CommitmentMarketplace { Ok(()) } + /// Pause marketplace settlement paths during incident response. + /// + /// Admin-only. Read-only getters remain callable while paused. + pub fn pause(e: Env, caller: Address) -> Result<(), MarketplaceError> { + let admin = read_admin(&e)?; + caller.require_auth(); + if caller != admin { + return Err(MarketplaceError::Unauthorized); + } + + Pausable::pause(&e); + Ok(()) + } + + /// Unpause marketplace settlement paths after incident response is complete. + /// + /// Admin-only. + pub fn unpause(e: Env, caller: Address) -> Result<(), MarketplaceError> { + let admin = read_admin(&e)?; + caller.require_auth(); + if caller != admin { + return Err(MarketplaceError::Unauthorized); + } + + Pausable::unpause(&e); + Ok(()) + } + + /// Return true when marketplace settlement paths are paused. + pub fn is_paused(e: Env) -> bool { + Pausable::is_paused(&e) + } + /// Add a token contract to the payment-token allowlist. /// /// # Arguments @@ -384,6 +420,7 @@ impl CommitmentMarketplace { return Err(MarketplaceError::ReentrancyDetected); } e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + Pausable::require_not_paused(&e); // CHECKS seller.require_auth(); @@ -552,6 +589,7 @@ impl CommitmentMarketplace { return Err(MarketplaceError::ReentrancyDetected); } e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + Pausable::require_not_paused(&e); // CHECKS buyer.require_auth(); @@ -839,6 +877,7 @@ impl CommitmentMarketplace { return Err(MarketplaceError::ReentrancyDetected); } e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + Pausable::require_not_paused(&e); // CHECKS seller.require_auth(); @@ -1131,6 +1170,7 @@ impl CommitmentMarketplace { return Err(MarketplaceError::ReentrancyDetected); } e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + Pausable::require_not_paused(&e); // CHECKS bidder.require_auth(); @@ -1230,6 +1270,7 @@ impl CommitmentMarketplace { return Err(MarketplaceError::ReentrancyDetected); } e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + Pausable::require_not_paused(&e); // CHECKS let mut auction: Auction = e @@ -1396,4 +1437,4 @@ impl CommitmentMarketplace { auctions } -} \ No newline at end of file +} diff --git a/contracts/commitment_marketplace/src/tests.rs b/contracts/commitment_marketplace/src/tests.rs index 4163b6a7..9a9ff982 100644 --- a/contracts/commitment_marketplace/src/tests.rs +++ b/contracts/commitment_marketplace/src/tests.rs @@ -116,6 +116,32 @@ fn test_update_fee() { assert_eq!(last_event.0, client.address); } +#[test] +fn test_pause_unpause_admin_controls_marketplace_state() { + let e = Env::default(); + e.mock_all_auths(); + + let (admin, _, client) = setup_marketplace(&e); + + assert!(!client.is_paused()); + client.pause(&admin); + assert!(client.is_paused()); + client.unpause(&admin); + assert!(!client.is_paused()); +} + +#[test] +#[should_panic(expected = "Error(Contract, #23)")] // Unauthorized +fn test_pause_non_admin_rejected() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + let non_admin = Address::generate(&e); + + client.pause(&non_admin); +} + // ============================================================================ // Listing Tests // ============================================================================ @@ -243,6 +269,43 @@ fn test_get_all_listings() { assert_eq!(listings.len(), 3); } +#[test] +#[should_panic(expected = "Contract is paused")] +fn test_list_nft_paused_rejected() { + let e = Env::default(); + e.mock_all_auths(); + + let (admin, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + client.pause(&admin); + client.list_nft(&seller, &1, &1000, &payment_token); +} + +#[test] +fn test_read_only_getters_work_while_paused() { + let e = Env::default(); + e.mock_all_auths(); + + let (admin, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + client.list_nft(&seller, &1, &1000, &payment_token); + client.start_auction(&seller, &2, &1000, &86400, &payment_token); + client.make_offer(&seller, &3, &500, &payment_token); + client.pause(&admin); + + assert!(client.is_paused()); + assert_eq!(client.get_listing(&1).token_id, 1); + assert_eq!(client.get_all_listings().len(), 1); + assert_eq!(client.get_auction(&2).token_id, 2); + assert_eq!(client.get_all_auctions().len(), 1); + assert_eq!(client.get_offers(&3).len(), 1); + assert!(client.is_payment_token_allowed(&payment_token)); +} + // ============================================================================ // Buy Tests (Note: These are simplified - real tests need token contract) // ============================================================================ @@ -294,6 +357,22 @@ fn test_buy_own_listing_fails() { client.buy_nft(&seller, &1); // Seller trying to buy their own listing } +#[test] +#[should_panic(expected = "Contract is paused")] +fn test_buy_nft_paused_rejected() { + let e = Env::default(); + e.mock_all_auths(); + + let (admin, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let buyer = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + client.list_nft(&seller, &1, &1000, &payment_token); + client.pause(&admin); + client.buy_nft(&buyer, &1); +} + // ============================================================================ // Offer System Tests // ============================================================================ @@ -372,6 +451,22 @@ fn test_accept_offer_own_listing_fails() { client.accept_offer(&seller, &1, &seller); // Seller accepting own offer } +#[test] +#[should_panic(expected = "Contract is paused")] +fn test_accept_offer_paused_rejected() { + let e = Env::default(); + e.mock_all_auths(); + + let (admin, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let offerer = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + + client.make_offer(&offerer, &1, &1000, &payment_token); + client.pause(&admin); + client.accept_offer(&seller, &1, &offerer); +} + #[test] @@ -540,6 +635,22 @@ fn test_place_bid_after_auction_ends_fails() { client.place_bid(&bidder, &token_id, &1500); } +#[test] +#[should_panic(expected = "Contract is paused")] +fn test_place_bid_paused_rejected() { + let e = Env::default(); + e.mock_all_auths(); + + let (admin, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let bidder = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + client.start_auction(&seller, &1, &1000, &86400, &payment_token); + client.pause(&admin); + client.place_bid(&bidder, &1, &1500); +} + #[test] fn test_auction_duration_boundary() { let e = Env::default(); @@ -605,6 +716,26 @@ fn test_end_auction_before_time_fails() { client.end_auction(&1); // Try to end immediately } +#[test] +#[should_panic(expected = "Contract is paused")] +fn test_end_auction_paused_rejected() { + let e = Env::default(); + e.mock_all_auths(); + + let (admin, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + client.start_auction(&seller, &1, &1000, &86400, &payment_token); + e.ledger().with_mut(|li| { + li.timestamp = 86400 + 1; + }); + + client.pause(&admin); + client.end_auction(&1); +} + + #[test] #[should_panic(expected = "Error(Contract, #16)")] // AuctionEnded fn test_end_auction_twice_fails() { diff --git a/docs/SECURITY_CONSIDERATIONS.md b/docs/SECURITY_CONSIDERATIONS.md index 2fdea3d9..727f0d92 100644 --- a/docs/SECURITY_CONSIDERATIONS.md +++ b/docs/SECURITY_CONSIDERATIONS.md @@ -3,6 +3,7 @@ ## Access control review - Admin-only functions in allocation_logic and attestation_engine require `require_auth` and compare caller to stored admin. +- commitment_marketplace `pause` and `unpause` are admin-only and gate settlement paths during incident response. - commitment_nft `set_core_contract` enforces admin auth, and `settle` / `mark_inactive` now require authorization from the configured core contract; `mint` still relies on a caller-supplied address and should remain in audit scope. - commitment_core state-changing functions (`create_commitment`, `settle`, `early_exit`, `allocate`, `update_value`) do not call `require_auth` and accept caller-provided addresses. - Attestation recording requires caller authorization (`is_authorized_verifier`) and `require_auth`. @@ -10,6 +11,7 @@ ## Reentrancy protection - commitment_core, commitment_nft, allocation_logic, and attestation_engine use reentrancy guards stored in instance storage. +- commitment_marketplace gates `list_nft`, `buy_nft`, `accept_offer`, `place_bid`, and `end_auction` with Pausable checks before settlement state changes or token transfers. - commitment_core functions with external calls follow checks-effects-interactions and clear the guard before returning. - Reentrancy guard state is reverted on transaction failure; audit should confirm all error paths are safe.