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