From 053f5b08bf71ee6e2fc033f225793f974abaf884 Mon Sep 17 00:00:00 2001
From: D'Angelo Rodriguez <70290504+dangelo352@users.noreply.github.com>
Date: Sun, 21 Jun 2026 21:54:01 -0400
Subject: [PATCH] feat: add marketplace upgrade migration path
---
contracts/commitment_marketplace/README.md | 32 +++++
contracts/commitment_marketplace/src/lib.rs | 116 +++++++++++++++++-
contracts/commitment_marketplace/src/tests.rs | 81 +++++++++++-
docs/MARKETPLACE_LISTING_LIFECYCLE.md | 16 ++-
docs/commitment_marketplace.md | 10 ++
5 files changed, 250 insertions(+), 5 deletions(-)
diff --git a/contracts/commitment_marketplace/README.md b/contracts/commitment_marketplace/README.md
index 3538ed39..cb60f102 100644
--- a/contracts/commitment_marketplace/README.md
+++ b/contracts/commitment_marketplace/README.md
@@ -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
diff --git a/contracts/commitment_marketplace/src/lib.rs b/contracts/commitment_marketplace/src/lib.rs
index 71a602ce..a98762b6 100644
--- a/contracts/commitment_marketplace/src/lib.rs
+++ b/contracts/commitment_marketplace/src/lib.rs
@@ -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
// ============================================================================
@@ -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,
}
// ============================================================================
@@ -148,6 +159,8 @@ pub enum DataKey {
Auction(u32),
/// Active auctions list
ActiveAuctions,
+ /// Stored marketplace schema version
+ Version,
/// Reentrancy guard
ReentrancyGuard,
}
@@ -169,6 +182,15 @@ fn read_admin(e: &Env) -> Result
{
.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 {
e.storage()
.instance()
@@ -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 {
// ========================================================================
@@ -252,6 +289,9 @@ impl CommitmentMarketplace {
e.storage()
.instance()
.set(&DataKey::AllowedPaymentTokens, &allowed_payment_tokens);
+ e.storage()
+ .instance()
+ .set(&DataKey::Version, &CURRENT_VERSION);
Ok(())
}
@@ -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::::new(&e));
+ }
+ if !e.storage().instance().has(&DataKey::ActiveAuctions) {
+ e.storage()
+ .instance()
+ .set(&DataKey::ActiveAuctions, &Vec::::new(&e));
+ }
+ if !e.storage().instance().has(&DataKey::AllowedPaymentTokens) {
+ e.storage()
+ .instance()
+ .set(&DataKey::AllowedPaymentTokens, &Vec::::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.
@@ -1396,4 +1506,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..7e83e6b2 100644
--- a/contracts/commitment_marketplace/src/tests.rs
+++ b/contracts/commitment_marketplace/src/tests.rs
@@ -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,
};
// ============================================================================
@@ -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
// ============================================================================
@@ -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]
diff --git a/docs/MARKETPLACE_LISTING_LIFECYCLE.md b/docs/MARKETPLACE_LISTING_LIFECYCLE.md
index ae51ce6d..40748563 100644
--- a/docs/MARKETPLACE_LISTING_LIFECYCLE.md
+++ b/docs/MARKETPLACE_LISTING_LIFECYCLE.md
@@ -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
diff --git a/docs/commitment_marketplace.md b/docs/commitment_marketplace.md
index 7cbb25d0..2c9654cf 100644
--- a/docs/commitment_marketplace.md
+++ b/docs/commitment_marketplace.md
@@ -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 |
@@ -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`.