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
32 changes: 32 additions & 0 deletions contracts/commitment_marketplace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,38 @@ fn update_fee(

Update marketplace fee (admin only).

#### `upgrade`

```rust
fn upgrade(
e: Env,
caller: Address,
new_wasm_hash: BytesN<32>,
) -> Result<(), MarketplaceError>
```

Upgrade marketplace WASM. `caller` must be the stored admin and `new_wasm_hash` must not be all zeros.

#### `migrate`

```rust
fn migrate(
e: Env,
caller: Address,
from_version: u32,
) -> Result<(), MarketplaceError>
```

Migrate legacy marketplace storage to `CURRENT_VERSION`; rejects mismatched versions and repeated migrations.

#### `get_version`

```rust
fn get_version(e: Env) -> u32
```

Get the current marketplace storage version.

#### `get_admin`

```rust
Expand Down
116 changes: 113 additions & 3 deletions contracts/commitment_marketplace/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@
#![no_std]

use soroban_sdk::{
contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env, Symbol,
Vec,
contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, BytesN, Env,
Symbol, Vec,
};
use shared_utils::math::SafeMath;

/// Current storage schema version for marketplace migrations.
pub const CURRENT_VERSION: u32 = 1;

// ============================================================================
// Error Types
// ============================================================================
Expand Down Expand Up @@ -80,6 +83,14 @@ pub enum MarketplaceError {
TransferFailed = 21,
/// Payment token is not allowlisted for marketplace settlement
PaymentTokenNotAllowed = 22,
/// Invalid WASM hash for upgrade
InvalidWasmHash = 23,
/// Invalid storage version supplied for migration
InvalidVersion = 24,
/// Migration already applied
AlreadyMigrated = 25,
/// Caller is not authorized for admin-only operation
Unauthorized = 26,
}

// ============================================================================
Expand Down Expand Up @@ -148,6 +159,8 @@ pub enum DataKey {
Auction(u32),
/// Active auctions list
ActiveAuctions,
/// Stored marketplace schema version
Version,
/// Reentrancy guard
ReentrancyGuard,
}
Expand All @@ -169,6 +182,15 @@ fn read_admin(e: &Env) -> Result<Address, MarketplaceError> {
.ok_or(MarketplaceError::NotInitialized)
}

fn require_admin(e: &Env, caller: &Address) -> Result<(), MarketplaceError> {
caller.require_auth();
let admin = read_admin(e)?;
if *caller != admin {
return Err(MarketplaceError::Unauthorized);
}
Ok(())
}

fn read_allowed_payment_tokens(e: &Env) -> Vec<Address> {
e.storage()
.instance()
Expand Down Expand Up @@ -200,6 +222,21 @@ fn require_allowed_payment_token(
Ok(())
}

fn read_version(e: &Env) -> u32 {
e.storage()
.instance()
.get::<_, u32>(&DataKey::Version)
.unwrap_or(0)
}

fn require_valid_wasm_hash(e: &Env, wasm_hash: &BytesN<32>) -> Result<(), MarketplaceError> {
let zero = BytesN::from_array(e, &[0; 32]);
if *wasm_hash == zero {
return Err(MarketplaceError::InvalidWasmHash);
}
Ok(())
}

#[contractimpl]
impl CommitmentMarketplace {
// ========================================================================
Expand Down Expand Up @@ -252,6 +289,9 @@ impl CommitmentMarketplace {
e.storage()
.instance()
.set(&DataKey::AllowedPaymentTokens, &allowed_payment_tokens);
e.storage()
.instance()
.set(&DataKey::Version, &CURRENT_VERSION);

Ok(())
}
Expand All @@ -263,6 +303,76 @@ impl CommitmentMarketplace {
read_admin(&e)
}

/// @notice Get current marketplace storage version.
/// @return Current schema version, or 0 for legacy/uninitialized storage.
pub fn get_version(e: Env) -> u32 {
read_version(&e)
}

/// @notice Upgrade the marketplace contract WASM.
/// @param caller Admin address authorizing the upgrade.
/// @param new_wasm_hash Hash of the uploaded replacement WASM.
/// @dev Admin-only. Rejects the all-zero hash before calling Soroban deployer.
/// @error MarketplaceError::Unauthorized if caller is not the stored admin.
/// @error MarketplaceError::InvalidWasmHash if new_wasm_hash is all zeros.
pub fn upgrade(
e: Env,
caller: Address,
new_wasm_hash: BytesN<32>,
) -> Result<(), MarketplaceError> {
require_admin(&e, &caller)?;
require_valid_wasm_hash(&e, &new_wasm_hash)?;
e.deployer().update_current_contract_wasm(new_wasm_hash);
Ok(())
}

/// @notice Migrate legacy marketplace storage to CURRENT_VERSION.
/// @param caller Admin address authorizing the migration.
/// @param from_version Version the caller expects to migrate from.
/// @dev Idempotent: rejects once storage is already at CURRENT_VERSION.
/// @error MarketplaceError::Unauthorized if caller is not the stored admin.
/// @error MarketplaceError::AlreadyMigrated if storage is already current.
/// @error MarketplaceError::InvalidVersion if from_version does not match stored version.
pub fn migrate(e: Env, caller: Address, from_version: u32) -> Result<(), MarketplaceError> {
require_admin(&e, &caller)?;

let stored_version = read_version(&e);
if stored_version == CURRENT_VERSION {
return Err(MarketplaceError::AlreadyMigrated);
}
if from_version != stored_version || from_version > CURRENT_VERSION {
return Err(MarketplaceError::InvalidVersion);
}

if !e.storage().instance().has(&DataKey::ActiveListings) {
e.storage()
.instance()
.set(&DataKey::ActiveListings, &Vec::<u32>::new(&e));
}
if !e.storage().instance().has(&DataKey::ActiveAuctions) {
e.storage()
.instance()
.set(&DataKey::ActiveAuctions, &Vec::<u32>::new(&e));
}
if !e.storage().instance().has(&DataKey::AllowedPaymentTokens) {
e.storage()
.instance()
.set(&DataKey::AllowedPaymentTokens, &Vec::<Address>::new(&e));
}
if !e.storage().instance().has(&DataKey::ReentrancyGuard) {
e.storage()
.instance()
.set(&DataKey::ReentrancyGuard, &false);
}

e.storage()
.instance()
.set(&DataKey::Version, &CURRENT_VERSION);
e.events()
.publish((symbol_short!("Migrated"),), (from_version, CURRENT_VERSION));
Ok(())
}

/// @notice Update the marketplace fee (basis points).
/// @param fee_basis_points New fee in basis points.
/// @dev Only callable by admin.
Expand Down Expand Up @@ -1396,4 +1506,4 @@ impl CommitmentMarketplace {

auctions
}
}
}
81 changes: 80 additions & 1 deletion contracts/commitment_marketplace/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ use crate::*;
use soroban_sdk::{
symbol_short,
testutils::{Address as _, Events, Ledger},
vec, Address, Env, IntoVal,
vec, Address, Bytes, BytesN, Env, IntoVal,
};

// ============================================================================
Expand Down Expand Up @@ -65,6 +65,11 @@ fn setup_allowed_payment_token(e: &Env, client: &CommitmentMarketplaceClient<'_>
payment_token
}

fn upload_wasm(e: &Env) -> BytesN<32> {
let wasm = Bytes::new(e);
e.deployer().upload_contract_wasm(wasm)
}

// ============================================================================
// Initialization Tests
// ============================================================================
Expand All @@ -84,6 +89,80 @@ fn test_initialize_marketplace() {
client.initialize(&admin, &nft_contract, &250, &fee_recipient);

assert_eq!(client.get_admin(), admin);
assert_eq!(client.get_version(), CURRENT_VERSION);
}

#[test]
fn test_migrate_rejects_wrong_from_version_without_mutating_state() {
let e = Env::default();
e.mock_all_auths();

let (admin, _, client) = setup_marketplace(&e);
e.as_contract(&client.address, || {
e.storage().instance().remove(&DataKey::Version);
});

let result = client.try_migrate(&admin, &1);
assert_eq!(result, Err(Ok(MarketplaceError::InvalidVersion)));
assert_eq!(client.get_version(), 0);
}

#[test]
fn test_migrate_initializes_legacy_storage_and_is_idempotent() {
let e = Env::default();
e.mock_all_auths();

let (admin, _, client) = setup_marketplace(&e);
let payment_token = setup_allowed_payment_token(&e, &client);
let seller = Address::generate(&e);
client.list_nft(&seller, &7, &1_000, &payment_token);

e.as_contract(&client.address, || {
e.storage().instance().remove(&DataKey::Version);
e.storage().instance().remove(&DataKey::ReentrancyGuard);
});

client.migrate(&admin, &0);
assert_eq!(client.get_version(), CURRENT_VERSION);
assert!(client.is_payment_token_allowed(&payment_token));
assert_eq!(client.get_all_listings().len(), 1);

let result = client.try_migrate(&admin, &CURRENT_VERSION);
assert_eq!(result, Err(Ok(MarketplaceError::AlreadyMigrated)));
assert_eq!(client.get_version(), CURRENT_VERSION);
}

#[test]
fn test_upgrade_authorization_and_invalid_hash() {
let e = Env::default();
e.mock_all_auths();

let (admin, _, client) = setup_marketplace(&e);
let attacker = Address::generate(&e);
let wasm_hash = upload_wasm(&e);

assert_eq!(
client.try_upgrade(&attacker, &wasm_hash),
Err(Ok(MarketplaceError::Unauthorized))
);

let zero = BytesN::from_array(&e, &[0; 32]);
assert_eq!(
client.try_upgrade(&admin, &zero),
Err(Ok(MarketplaceError::InvalidWasmHash))
);
}

#[test]
fn test_upgrade_accepts_uploaded_wasm_hash() {
let e = Env::default();
e.mock_all_auths();

let (admin, _, client) = setup_marketplace(&e);
let wasm_hash = upload_wasm(&e);

assert_eq!(client.try_upgrade(&admin, &wasm_hash), Ok(Ok(())));
assert_eq!(client.get_version(), CURRENT_VERSION);
}

#[test]
Expand Down
16 changes: 15 additions & 1 deletion docs/MARKETPLACE_LISTING_LIFECYCLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,10 +219,24 @@ Seller ────────────────────────
| 19 | `InvalidDuration` | `start_auction` with `duration_seconds == 0` |
| 20 | `ReentrancyDetected` | Nested call while guard is set |
| 21 | `TransferFailed` | Reserved |
| 22 | `PaymentTokenNotAllowed` | Settlement token is not allowlisted |
| 23 | `InvalidWasmHash` | Upgrade hash is all zeros |
| 24 | `InvalidVersion` | Migration `from_version` does not match stored version |
| 25 | `AlreadyMigrated` | Migration has already reached `CURRENT_VERSION` |
| 26 | `Unauthorized` | Caller is not marketplace admin |

---

## 6. Fee Arithmetic
## 6. Upgrade and Migration

- Fresh deployments write `Version = CURRENT_VERSION` during `initialize`.
- `upgrade(caller, new_wasm_hash)` is admin-only and rejects the all-zero WASM hash before replacing the contract WASM.
- `migrate(caller, from_version)` is admin-only, migrates legacy version `0` storage to `CURRENT_VERSION`, and rejects repeated or mismatched migrations.
- Migration backfills missing active listing/auction indexes, payment-token registry, and reentrancy guard keys without clearing existing marketplace state.

---

## 7. Fee Arithmetic

```
marketplace_fee = (price * fee_basis_points) / 10_000
Expand Down
10 changes: 10 additions & 0 deletions docs/commitment_marketplace.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ This page documents the public entry points, access control, and security notes
|------------------------|----------------------------------------------|-----------------------|----------------------------------------------------------|
| initialize | Set admin, NFT contract, fee, fee recipient | Admin require_auth | Fails if already initialized |
| update_fee | Update marketplace fee | Admin require_auth | Fails if not initialized |
| upgrade | Upgrade marketplace WASM | Caller must be admin | Fails if caller is not admin or hash is invalid |
| migrate | Migrate legacy storage to current version | Caller must be admin | Fails if version is invalid or already migrated |
| get_version | Get marketplace storage version | View | Returns 0 for legacy/uninitialized storage |
| list_nft | List NFT for sale | Seller require_auth | Fails if price <= 0, listing exists, or not initialized |
| cancel_listing | Cancel NFT listing | Seller require_auth | Fails if not found or not seller |
| buy_nft | Buy NFT from listing | Buyer require_auth | Fails if not found, self-buy, or not initialized |
Expand All @@ -30,6 +33,13 @@ This page documents the public entry points, access control, and security notes
- No cross-contract NFT ownership checks or transfers are performed in this implementation (see contract comments).
- All token transfers use Soroban token interface.

## Upgrade and Migration
- `initialize` stores `Version = CURRENT_VERSION` for fresh deployments.
- `upgrade(caller, new_wasm_hash)` requires the stored admin as `caller`, rejects the all-zero WASM hash, and then calls Soroban's current-contract WASM update.
- `migrate(caller, from_version)` is admin-only and migrates legacy version `0` storage to `CURRENT_VERSION`.
- `migrate` rejects mismatched `from_version`, downgrades, and repeated migrations with deterministic errors.
- Migration preserves existing listings, auctions, offers, and payment-token allowlist state while backfilling missing instance keys such as active indexes and the reentrancy guard.

## Reentrancy Guard
- All mutating entry points set/check/clear a `ReentrancyGuard` storage key.
- If the guard is set, the contract returns `MarketplaceError::ReentrancyDetected`.
Expand Down
Loading