Skip to content
Closed
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
16 changes: 15 additions & 1 deletion contracts/EMERGENCY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
45 changes: 43 additions & 2 deletions contracts/commitment_marketplace/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}

// ============================================================================
Expand Down Expand Up @@ -252,6 +254,7 @@ impl CommitmentMarketplace {
e.storage()
.instance()
.set(&DataKey::AllowedPaymentTokens, &allowed_payment_tokens);
e.storage().instance().set(&Pausable::PAUSED_KEY, &false);

Ok(())
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1396,4 +1437,4 @@ impl CommitmentMarketplace {

auctions
}
}
}
131 changes: 131 additions & 0 deletions contracts/commitment_marketplace/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down Expand Up @@ -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)
// ============================================================================
Expand Down Expand Up @@ -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
// ============================================================================
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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() {
Expand Down
2 changes: 2 additions & 0 deletions docs/SECURITY_CONSIDERATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@
## 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`.

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

Expand Down
Loading