diff --git a/contracts/commitment_marketplace/README.md b/contracts/commitment_marketplace/README.md index e47aafa..1e7a2ac 100644 --- a/contracts/commitment_marketplace/README.md +++ b/contracts/commitment_marketplace/README.md @@ -1,595 +1,604 @@ -# Commitment NFT Marketplace - -A comprehensive secondary market for Commitment NFTs on Soroban/Stellar, featuring fixed-price listings, offers, and auctions with secure escrow. - -## Features - -### 🏪 Fixed-Price Listings - -- List NFTs at a fixed price -- Cancel listings anytime -- Automatic fee calculation -- Secure escrow mechanism - -### 💰 Offer System - -- Make offers below listing price -- Multiple offers per NFT -- Accept/reject offers -- Automatic offer cancellation on sale - -### 🔨 Auction System - -- Time-based auctions -- Automatic bid refunds -- Dutch auction support (future) -- Secure escrow for bids - -### 🔐 Security Features - -- Reentrancy protection on all state-changing functions -- Checks-Effects-Interactions pattern -- Access control on sensitive operations -- Comprehensive input validation - -### 💸 Fee Structure - -- Configurable marketplace fees (basis points) -- Transparent fee calculation -- Admin-controlled fee updates -- Fee recipient management - -## Quick Start - -### Installation - -```bash -# Clone or navigate to your project -cd your-project - -# Run setup script -chmod +x setup_marketplace.sh -./setup_marketplace.sh - -# Copy source files -cp commitment_marketplace.rs contracts/commitment_marketplace/src/lib.rs -cp marketplace_tests.rs contracts/commitment_marketplace/src/tests.rs -``` - -### Build - -```bash -cd contracts/commitment_marketplace -cargo build --target wasm32-unknown-unknown --release -``` - -### Test - -```bash -cargo test -``` - -### Deploy - -```bash -# Deploy to testnet -soroban contract deploy \ - --wasm target/wasm32-unknown-unknown/release/commitment_marketplace.wasm \ - --source \ - --network testnet - -# Initialize -soroban contract invoke \ - --id \ - --source \ - --network testnet \ - -- initialize \ - --admin \ - --nft_contract \ - --fee_basis_points 250 \ - --fee_recipient -``` - -## Usage - -### List an NFT - -```rust -marketplace.list_nft( - seller_address, - token_id, - price, - payment_token_address -) -``` - -```bash -soroban contract invoke \ - --id $MARKETPLACE \ - --source $SELLER \ - -- list_nft \ - --seller $SELLER_ADDR \ - --token_id 1 \ - --price 1000000000 \ - --payment_token $TOKEN_ADDR -``` - -### Buy an NFT - -```rust -marketplace.buy_nft( - buyer_address, - token_id -) -``` - -```bash -# First approve marketplace to spend payment tokens -soroban contract invoke \ - --id $PAYMENT_TOKEN \ - --source $BUYER \ - -- approve \ - --from $BUYER_ADDR \ - --spender $MARKETPLACE \ - --amount 1000000000 - -# Then buy -soroban contract invoke \ - --id $MARKETPLACE \ - --source $BUYER \ - -- buy_nft \ - --buyer $BUYER_ADDR \ - --token_id 1 -``` - -### Make an Offer - -```rust -marketplace.make_offer( - offerer_address, - token_id, - amount, - payment_token_address -) -``` - -### Start an Auction - -```rust -marketplace.start_auction( - seller_address, - token_id, - starting_price, - duration_seconds, - payment_token_address -) -``` - -```bash -soroban contract invoke \ - --id $MARKETPLACE \ - --source $SELLER \ - -- start_auction \ - --seller $SELLER_ADDR \ - --token_id 1 \ - --starting_price 500000000 \ - --duration_seconds 86400 \ - --payment_token $TOKEN_ADDR -``` - -### Place a Bid - -```rust -marketplace.place_bid( - bidder_address, - token_id, - bid_amount -) -``` - -### End Auction - -```rust -marketplace.end_auction(token_id) -``` - -## API Reference - -### Initialization - -#### `initialize` - -```rust -fn initialize( - e: Env, - admin: Address, - nft_contract: Address, - fee_basis_points: u32, - fee_recipient: Address, -) -> Result<(), MarketplaceError> -``` - -Initialize the marketplace with admin, NFT contract address, fee structure, and fee recipient. - -### Listing Management - -#### `list_nft` - -```rust -fn list_nft( - e: Env, - seller: Address, - token_id: u32, - price: i128, - payment_token: Address, -) -> Result<(), MarketplaceError> -``` - -List an NFT for fixed-price sale. - -#### `cancel_listing` - -```rust -fn cancel_listing( - e: Env, - seller: Address, - token_id: u32, -) -> Result<(), MarketplaceError> -``` - -Cancel an active listing (seller only). - -#### `buy_nft` - -```rust -fn buy_nft( - e: Env, - buyer: Address, - token_id: u32, -) -> Result<(), MarketplaceError> -``` - -Purchase a listed NFT. - -#### `get_listing` - -```rust -fn get_listing( - e: Env, - token_id: u32, -) -> Result -``` - -Get listing details for a specific token. - -#### `get_all_listings` - -```rust -fn get_all_listings(e: Env) -> Vec-``` - -Get all active listings. - -### Offer System - -#### `make_offer` - -```rust -fn make_offer( - e: Env, - offerer: Address, - token_id: u32, - amount: i128, - payment_token: Address, -) -> Result<(), MarketplaceError> -``` - -Make an offer on an NFT. - -#### `accept_offer` - -```rust -fn accept_offer( - e: Env, - seller: Address, - token_id: u32, - offerer: Address, -) -> Result<(), MarketplaceError> -``` - -Accept a specific offer (seller/owner only). - -#### `cancel_offer` - -```rust -fn cancel_offer( - e: Env, - offerer: Address, - token_id: u32, -) -> Result<(), MarketplaceError> -``` - -Cancel your own offer. - -#### `get_offers` - -```rust -fn get_offers(e: Env, token_id: u32) -> Vec -``` - -Get all offers for a specific token. - -### Auction System - -#### `start_auction` - -```rust -fn start_auction( - e: Env, - seller: Address, - token_id: u32, - starting_price: i128, - duration_seconds: u64, - payment_token: Address, -) -> Result<(), MarketplaceError> -``` - -Start a time-based auction. - -#### `place_bid` - -```rust -fn place_bid( - e: Env, - bidder: Address, - token_id: u32, - bid_amount: i128, -) -> Result<(), MarketplaceError> -``` - -Place a bid on an active auction. - -#### `end_auction` - -```rust -fn end_auction(e: Env, token_id: u32) -> Result<(), MarketplaceError> -``` - -End an auction after expiry time. - -#### `get_auction` - -```rust -fn get_auction( - e: Env, - token_id: u32, -) -> Result -``` - -Get auction details. - -#### `get_all_auctions` - -```rust -fn get_all_auctions(e: Env) -> Vec -``` - -Get all active auctions. - -### Admin Functions - -#### `update_fee` - -```rust -fn update_fee( - e: Env, - fee_basis_points: u32, -) -> Result<(), MarketplaceError> -``` - -Update marketplace fee (admin only). - -#### `get_admin` - -```rust -fn get_admin(e: Env) -> Result -``` - -Get admin address. - -#### Two-step administrator rotation - -```rust -fn nominate_admin(e: Env, new_admin: Address) -> Result<(), MarketplaceError> -fn cancel_admin_transfer(e: Env) -> Result<(), MarketplaceError> -fn accept_admin_transfer(e: Env, new_admin: Address) -> Result<(), MarketplaceError> -fn get_pending_admin(e: Env) -> Option
-``` - -Only the current administrator can nominate or cancel a handover. A nomination -replaces any previous pending nominee, while the current administrator retains -all authority until the nominated address authenticates an acceptance. Successful -nomination, cancellation, and acceptance emit `AdminNom`, `AdminCan`, and -`AdminAcc` events. A failed or replayed acceptance cannot change authority. - -#### `add_payment_token` / `remove_payment_token` - -```rust -fn add_payment_token(e: Env, payment_token: Address) -> Result<(), MarketplaceError> -fn remove_payment_token(e: Env, payment_token: Address) -> Result<(), MarketplaceError> -``` - -Manage the payment-token allowlist. Both operations require the current admin's -authorization and are idempotent. An allowlisted token is required before a -listing, offer, auction, or settlement can use it. Removing a token does not -rewrite existing listings or commitments; it prevents new use and settlement -until the token is restored. Successful changes emit `TokenAdd` or `TokenRem` -events containing the token and administrator for auditability. - -## Data Structures - -### Listing - -```rust -pub struct Listing { - pub token_id: u32, - pub seller: Address, - pub price: i128, - pub payment_token: Address, - pub listed_at: u64, -} -``` - -### Offer - -```rust -pub struct Offer { - pub token_id: u32, - pub offerer: Address, - pub amount: i128, - pub payment_token: Address, - pub created_at: u64, -} -``` - -### Auction - -```rust -pub struct Auction { - pub token_id: u32, - pub seller: Address, - pub starting_price: i128, - pub current_bid: i128, - pub highest_bidder: Option
, - pub payment_token: Address, - pub started_at: u64, - pub ends_at: u64, - pub ended: bool, -} -``` - -## Error Codes - -| Code | Error | Description | -| ---- | ------------------- | ----------------------------- | -| 1 | NotInitialized | Marketplace not initialized | -| 2 | AlreadyInitialized | Already initialized | -| 3 | ListingNotFound | Listing doesn't exist | -| 4 | NotSeller | Caller is not the seller | -| 5 | NFTNotActive | NFT is not active | -| 6 | InvalidPrice | Price must be > 0 | -| 7 | ListingExists | Listing already exists | -| 8 | CannotBuyOwnListing | Seller cannot buy own listing | -| 9 | InsufficientPayment | Payment amount too low | -| 10 | NFTContractError | NFT contract call failed | -| 11 | OfferNotFound | Offer doesn't exist | -| 12 | InvalidOfferAmount | Offer amount must be > 0 | -| 13 | OfferExists | Offer already exists | -| 14 | NotOfferMaker | Not the offer creator | -| 15 | AuctionNotFound | Auction doesn't exist | -| 16 | AuctionEnded | Auction already ended | -| 17 | AuctionNotEnded | Auction still active | -| 18 | BidTooLow | Bid below current price | -| 19 | InvalidDuration | Duration must be > 0 | -| 20 | ReentrancyDetected | Reentrancy attack prevented | -| 21 | TransferFailed | Token transfer failed | - -## Events - -### Listing Events - -- `ListNFT(token_id)` → `(seller, price, payment_token)` -- `ListCncl(token_id)` → `seller` -- `NFTSold(token_id)` → `(seller, buyer, price)` - -### Offer Events - -- `OfferMade(token_id)` → `(offerer, amount, payment_token)` -- `OffAccpt(token_id)` → `(seller, offerer, amount)` -- `OfferCanc(token_id)` → `offerer` - -### Auction Events - -- `AucStart(token_id)` → `(seller, starting_price, ends_at)` -- `BidPlaced(token_id)` → `(bidder, bid_amount)` -- `AucEnd(token_id)` → `(winner, final_bid)` -- `AucNoBid(token_id)` → `seller` - -## Testing - -### Run All Tests - -```bash -cargo test -``` - -### Run Specific Test - -```bash -cargo test test_list_nft -``` - -### Run with Benchmarks - -```bash -cargo test --features benchmark -``` - -### Test Coverage - -- ✅ Initialization -- ✅ Listing management -- ✅ Buy flow -- ✅ Offer system -- ✅ Auction system -- ✅ Access control -- ✅ Edge cases -- ✅ Reentrancy protection - -## Security - -### Reentrancy Protection - -All state-changing functions implement: - -1. Reentrancy guard check -2. State updates before external calls -3. Guard cleanup in all paths - -### Access Control - -- Seller-only: `cancel_listing`, `accept_offer` -- Admin-only: `update_fee` -- Offerer-only: `cancel_offer` - -### Input Validation - -- Price > 0 -- Duration > 0 -- Token existence checks -- Ownership verification - -## Gas Optimization - -- Batch storage operations where possible -- Efficient vector operations -- Minimal external calls -- Optimized data structures - -## Contributing - -See [IMPLEMENTATION_GUIDE.md](IMPLEMENTATION_GUIDE.md) for development guidelines. - -## License - -MIT - -## Support - -- 📚 Documentation: See IMPLEMENTATION_GUIDE.md -- 🐛 Issues: [GitHub Issues] -- 💬 Discord: [Soroban Discord](https://discord.gg/soroban) - ---- - -Built with ❤️ for the Soroban ecosystem +# Commitment NFT Marketplace + +A comprehensive secondary market for Commitment NFTs on Soroban/Stellar, featuring fixed-price listings, offers, and auctions with secure escrow. + +## Features + +### 🏪 Fixed-Price Listings + +- List NFTs at a fixed price +- Cancel listings anytime +- Automatic fee calculation +- Secure escrow mechanism + +### 💰 Offer System + +- Make offers below listing price with an explicit lifetime (`duration` in ledger seconds) +- Offers expire at an exclusive ledger-time boundary: valid only while `timestamp < expires_at`, matching the auction `ends_at` convention +- Expired offers cannot be accepted; the offerer may cancel them or replace them with a fresh offer +- Multiple offers per NFT +- Accept/reject offers +- Automatic offer cancellation on sale + +### 🔨 Auction System + +- Time-based auctions +- Automatic bid refunds +- Dutch auction support (future) +- Secure escrow for bids + +### 🔐 Security Features + +- Reentrancy protection on all state-changing functions +- Checks-Effects-Interactions pattern +- Access control on sensitive operations +- Comprehensive input validation + +### 💸 Fee Structure + +- Configurable marketplace fees (basis points) +- Transparent fee calculation +- Admin-controlled fee updates +- Fee recipient management + +## Quick Start + +### Installation + +```bash +# Clone or navigate to your project +cd your-project + +# Run setup script +chmod +x setup_marketplace.sh +./setup_marketplace.sh + +# Copy source files +cp commitment_marketplace.rs contracts/commitment_marketplace/src/lib.rs +cp marketplace_tests.rs contracts/commitment_marketplace/src/tests.rs +``` + +### Build + +```bash +cd contracts/commitment_marketplace +cargo build --target wasm32-unknown-unknown --release +``` + +### Test + +```bash +cargo test +``` + +### Deploy + +```bash +# Deploy to testnet +soroban contract deploy \ + --wasm target/wasm32-unknown-unknown/release/commitment_marketplace.wasm \ + --source \ + --network testnet + +# Initialize +soroban contract invoke \ + --id \ + --source \ + --network testnet \ + -- initialize \ + --admin \ + --nft_contract \ + --fee_basis_points 250 \ + --fee_recipient +``` + +## Usage + +### List an NFT + +```rust +marketplace.list_nft( + seller_address, + token_id, + price, + payment_token_address +) +``` + +```bash +soroban contract invoke \ + --id $MARKETPLACE \ + --source $SELLER \ + -- list_nft \ + --seller $SELLER_ADDR \ + --token_id 1 \ + --price 1000000000 \ + --payment_token $TOKEN_ADDR +``` + +### Buy an NFT + +```rust +marketplace.buy_nft( + buyer_address, + token_id +) +``` + +```bash +# First approve marketplace to spend payment tokens +soroban contract invoke \ + --id $PAYMENT_TOKEN \ + --source $BUYER \ + -- approve \ + --from $BUYER_ADDR \ + --spender $MARKETPLACE \ + --amount 1000000000 + +# Then buy +soroban contract invoke \ + --id $MARKETPLACE \ + --source $BUYER \ + -- buy_nft \ + --buyer $BUYER_ADDR \ + --token_id 1 +``` + +### Make an Offer + +```rust +marketplace.make_offer( + offerer_address, + token_id, + amount, + payment_token_address, + duration_seconds // offer lifetime; expires at created_at + duration_seconds +) +``` + +### Start an Auction + +```rust +marketplace.start_auction( + seller_address, + token_id, + starting_price, + duration_seconds, + payment_token_address +) +``` + +```bash +soroban contract invoke \ + --id $MARKETPLACE \ + --source $SELLER \ + -- start_auction \ + --seller $SELLER_ADDR \ + --token_id 1 \ + --starting_price 500000000 \ + --duration_seconds 86400 \ + --payment_token $TOKEN_ADDR +``` + +### Place a Bid + +```rust +marketplace.place_bid( + bidder_address, + token_id, + bid_amount +) +``` + +### End Auction + +```rust +marketplace.end_auction(token_id) +``` + +## API Reference + +### Initialization + +#### `initialize` + +```rust +fn initialize( + e: Env, + admin: Address, + nft_contract: Address, + fee_basis_points: u32, + fee_recipient: Address, +) -> Result<(), MarketplaceError> +``` + +Initialize the marketplace with admin, NFT contract address, fee structure, and fee recipient. + +### Listing Management + +#### `list_nft` + +```rust +fn list_nft( + e: Env, + seller: Address, + token_id: u32, + price: i128, + payment_token: Address, +) -> Result<(), MarketplaceError> +``` + +List an NFT for fixed-price sale. + +#### `cancel_listing` + +```rust +fn cancel_listing( + e: Env, + seller: Address, + token_id: u32, +) -> Result<(), MarketplaceError> +``` + +Cancel an active listing (seller only). + +#### `buy_nft` + +```rust +fn buy_nft( + e: Env, + buyer: Address, + token_id: u32, +) -> Result<(), MarketplaceError> +``` + +Purchase a listed NFT. + +#### `get_listing` + +```rust +fn get_listing( + e: Env, + token_id: u32, +) -> Result +``` + +Get listing details for a specific token. + +#### `get_all_listings` + +```rust +fn get_all_listings(e: Env) -> Vec+``` + +Get all active listings. + +### Offer System + +#### `make_offer` + +```rust +fn make_offer( + e: Env, + offerer: Address, + token_id: u32, + amount: i128, + payment_token: Address, + duration: u64, +) -> Result<(), MarketplaceError> +``` + +Make an offer on an NFT that expires `duration` ledger seconds after creation. +The expiry boundary is exclusive: an offer is valid only while +`ledger.timestamp() < expires_at`. `duration == 0` or an overflowing +`created_at + duration` fails with `InvalidDuration`; accepting an offer at or +past `expires_at` fails with `OfferExpired`. Re-offering after expiry replaces +the stale offer; while an offer is live a duplicate fails with `OfferExists`. + +#### `accept_offer` + +```rust +fn accept_offer( + e: Env, + seller: Address, + token_id: u32, + offerer: Address, +) -> Result<(), MarketplaceError> +``` + +Accept a specific offer (seller/owner only). + +#### `cancel_offer` + +```rust +fn cancel_offer( + e: Env, + offerer: Address, + token_id: u32, +) -> Result<(), MarketplaceError> +``` + +Cancel your own offer. + +#### `get_offers` + +```rust +fn get_offers(e: Env, token_id: u32) -> Vec +``` + +Get all offers for a specific token. + +### Auction System + +#### `start_auction` + +```rust +fn start_auction( + e: Env, + seller: Address, + token_id: u32, + starting_price: i128, + duration_seconds: u64, + payment_token: Address, +) -> Result<(), MarketplaceError> +``` + +Start a time-based auction. + +#### `place_bid` + +```rust +fn place_bid( + e: Env, + bidder: Address, + token_id: u32, + bid_amount: i128, +) -> Result<(), MarketplaceError> +``` + +Place a bid on an active auction. + +#### `end_auction` + +```rust +fn end_auction(e: Env, token_id: u32) -> Result<(), MarketplaceError> +``` + +End an auction after expiry time. + +#### `get_auction` + +```rust +fn get_auction( + e: Env, + token_id: u32, +) -> Result +``` + +Get auction details. + +#### `get_all_auctions` + +```rust +fn get_all_auctions(e: Env) -> Vec +``` + +Get all active auctions. + +### Admin Functions + +#### `update_fee` + +```rust +fn update_fee( + e: Env, + fee_basis_points: u32, +) -> Result<(), MarketplaceError> +``` + +Update marketplace fee (admin only). + +#### `get_admin` + +```rust +fn get_admin(e: Env) -> Result +``` + +Get admin address. + +#### Two-step administrator rotation + +```rust +fn nominate_admin(e: Env, new_admin: Address) -> Result<(), MarketplaceError> +fn cancel_admin_transfer(e: Env) -> Result<(), MarketplaceError> +fn accept_admin_transfer(e: Env, new_admin: Address) -> Result<(), MarketplaceError> +fn get_pending_admin(e: Env) -> Option
+``` + +Only the current administrator can nominate or cancel a handover. A nomination +replaces any previous pending nominee, while the current administrator retains +all authority until the nominated address authenticates an acceptance. Successful +nomination, cancellation, and acceptance emit `AdminNom`, `AdminCan`, and +`AdminAcc` events. A failed or replayed acceptance cannot change authority. + +#### `add_payment_token` / `remove_payment_token` + +```rust +fn add_payment_token(e: Env, payment_token: Address) -> Result<(), MarketplaceError> +fn remove_payment_token(e: Env, payment_token: Address) -> Result<(), MarketplaceError> +``` + +Manage the payment-token allowlist. Both operations require the current admin's +authorization and are idempotent. An allowlisted token is required before a +listing, offer, auction, or settlement can use it. Removing a token does not +rewrite existing listings or commitments; it prevents new use and settlement +until the token is restored. Successful changes emit `TokenAdd` or `TokenRem` +events containing the token and administrator for auditability. + +## Data Structures + +### Listing + +```rust +pub struct Listing { + pub token_id: u32, + pub seller: Address, + pub price: i128, + pub payment_token: Address, + pub listed_at: u64, +} +``` + +### Offer + +```rust +pub struct Offer { + pub token_id: u32, + pub offerer: Address, + pub amount: i128, + pub payment_token: Address, + pub created_at: u64, +} +``` + +### Auction + +```rust +pub struct Auction { + pub token_id: u32, + pub seller: Address, + pub starting_price: i128, + pub current_bid: i128, + pub highest_bidder: Option
, + pub payment_token: Address, + pub started_at: u64, + pub ends_at: u64, + pub ended: bool, +} +``` + +## Error Codes + +| Code | Error | Description | +| ---- | ------------------- | ----------------------------- | +| 1 | NotInitialized | Marketplace not initialized | +| 2 | AlreadyInitialized | Already initialized | +| 3 | ListingNotFound | Listing doesn't exist | +| 4 | NotSeller | Caller is not the seller | +| 5 | NFTNotActive | NFT is not active | +| 6 | InvalidPrice | Price must be > 0 | +| 7 | ListingExists | Listing already exists | +| 8 | CannotBuyOwnListing | Seller cannot buy own listing | +| 9 | InsufficientPayment | Payment amount too low | +| 10 | NFTContractError | NFT contract call failed | +| 11 | OfferNotFound | Offer doesn't exist | +| 12 | InvalidOfferAmount | Offer amount must be > 0 | +| 13 | OfferExists | Offer already exists | +| 14 | NotOfferMaker | Not the offer creator | +| 15 | AuctionNotFound | Auction doesn't exist | +| 16 | AuctionEnded | Auction already ended | +| 17 | AuctionNotEnded | Auction still active | +| 18 | BidTooLow | Bid below current price | +| 19 | InvalidDuration | Duration must be > 0 | +| 20 | ReentrancyDetected | Reentrancy attack prevented | +| 21 | TransferFailed | Token transfer failed | + +## Events + +### Listing Events + +- `ListNFT(token_id)` → `(seller, price, payment_token)` +- `ListCncl(token_id)` → `seller` +- `NFTSold(token_id)` → `(seller, buyer, price)` + +### Offer Events + +- `OfferMade(token_id)` → `(offerer, amount, payment_token)` +- `OffAccpt(token_id)` → `(seller, offerer, amount)` +- `OfferCanc(token_id)` → `offerer` + +### Auction Events + +- `AucStart(token_id)` → `(seller, starting_price, ends_at)` +- `BidPlaced(token_id)` → `(bidder, bid_amount)` +- `AucEnd(token_id)` → `(winner, final_bid)` +- `AucNoBid(token_id)` → `seller` + +## Testing + +### Run All Tests + +```bash +cargo test +``` + +### Run Specific Test + +```bash +cargo test test_list_nft +``` + +### Run with Benchmarks + +```bash +cargo test --features benchmark +``` + +### Test Coverage + +- ✅ Initialization +- ✅ Listing management +- ✅ Buy flow +- ✅ Offer system +- ✅ Auction system +- ✅ Access control +- ✅ Edge cases +- ✅ Reentrancy protection + +## Security + +### Reentrancy Protection + +All state-changing functions implement: + +1. Reentrancy guard check +2. State updates before external calls +3. Guard cleanup in all paths + +### Access Control + +- Seller-only: `cancel_listing`, `accept_offer` +- Admin-only: `update_fee` +- Offerer-only: `cancel_offer` + +### Input Validation + +- Price > 0 +- Duration > 0 +- Token existence checks +- Ownership verification + +## Gas Optimization + +- Batch storage operations where possible +- Efficient vector operations +- Minimal external calls +- Optimized data structures + +## Contributing + +See [IMPLEMENTATION_GUIDE.md](IMPLEMENTATION_GUIDE.md) for development guidelines. + +## License + +MIT + +## Support + +- 📚 Documentation: See IMPLEMENTATION_GUIDE.md +- 🐛 Issues: [GitHub Issues] +- 💬 Discord: [Soroban Discord](https://discord.gg/soroban) + +--- + +Built with ❤️ for the Soroban ecosystem diff --git a/contracts/commitment_marketplace/src/lib.rs b/contracts/commitment_marketplace/src/lib.rs index 84f8a67..7e73c2f 100644 --- a/contracts/commitment_marketplace/src/lib.rs +++ b/contracts/commitment_marketplace/src/lib.rs @@ -1,1668 +1,1735 @@ -//! # Commitment Marketplace Contract -//! -//! Soroban smart contract for NFT marketplace operations (listings, offers, auctions) with reentrancy guard and fee logic. -//! -//! ## Security -//! - All state-changing entry points require authentication (`require_auth`). -//! - Reentrancy guard is enforced on all external-call entry points. -//! - Arithmetic is performed using checked math; see individual functions for overflow/underflow notes. -//! -//! ## Errors -//! - See [`MarketplaceError`] for all error codes. -//! -//! ## Storage -//! -//! - See [`DataKey`] for all storage keys mutated by each entry point. -//! -//! ## Audit Notes -//! - No cross-contract NFT ownership checks are performed in this implementation (see comments in code). -//! - All token transfers use Soroban token interface. - -#![no_std] - -use soroban_sdk::{ - contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env, Symbol, - Vec, -}; -use shared_utils::fee_invariants; - -// ============================================================================ -// Error Types -// ============================================================================ - -/// Marketplace errors -#[contracterror] -#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] -#[repr(u32)] -pub enum MarketplaceError { - /// Marketplace not initialized - NotInitialized = 1, - /// Already initialized - AlreadyInitialized = 2, - /// Listing not found - ListingNotFound = 3, - /// Not the seller - NotSeller = 4, - /// NFT not active - NFTNotActive = 5, - /// Invalid price (must be > 0) - InvalidPrice = 6, - /// Listing already exists for this token - ListingExists = 7, - /// Buyer cannot be seller - CannotBuyOwnListing = 8, - /// Insufficient payment - InsufficientPayment = 9, - /// NFT contract call failed - NFTContractError = 10, - /// Offer not found - OfferNotFound = 11, - /// Invalid offer amount - InvalidOfferAmount = 12, - /// Offer already exists - OfferExists = 13, - /// Not offer maker - NotOfferMaker = 14, - /// Auction not found - AuctionNotFound = 15, - /// Auction already ended - AuctionEnded = 16, - /// Auction not ended yet - AuctionNotEnded = 17, - /// Bid too low - BidTooLow = 18, - /// Invalid duration - InvalidDuration = 19, - /// Reentrancy detected - ReentrancyDetected = 20, - /// Transfer failed - TransferFailed = 21, - /// Payment token is not allowlisted for marketplace settlement - PaymentTokenNotAllowed = 22, - /// Configured fee cannot be represented as a safe accounting split. - InvalidFeeConfiguration = 34, - /// No administrator handover is currently pending - NoPendingAdmin = 31, - /// Caller is not the nominated administrator - NotPendingAdmin = 32, - /// The current administrator cannot nominate itself - CannotNominateCurrentAdmin = 33, - /// Royalty basis points exceed the marketplace policy maximum - RoyaltyTooHigh = 27, - /// Seller, fee, and royalty payouts cannot be represented safely - PayoutOverflow = 28, - /// The configured fee and royalty cannot be paid from the sale amount - PayoutExceedsSale = 29, - /// Caller is not authorized to change royalty configuration - UnauthorizedRoyaltyUpdate = 30, - /// Marketplace is paused; new/risky mutations are disabled - Paused = 23, - /// Marketplace is already paused - AlreadyPaused = 24, - /// Marketplace is not paused - NotPaused = 25, - /// Caller is not the marketplace administrator - Unauthorized = 26, -} - -// ============================================================================ -// Data Types -// ============================================================================ - -/// Listing information -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Listing { - pub token_id: u32, - pub seller: Address, - pub price: i128, - pub payment_token: Address, - pub listed_at: u64, -} - -/// Offer information -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Offer { - pub token_id: u32, - pub offerer: Address, - pub amount: i128, - pub payment_token: Address, - pub created_at: u64, -} - -/// Auction information -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Auction { - pub token_id: u32, - pub seller: Address, - pub starting_price: i128, - pub current_bid: i128, - pub highest_bidder: Option
, - pub payment_token: Address, - pub started_at: u64, - pub ends_at: u64, - pub ended: bool, -} - -/// Royalty configuration applied to a token sale. -#[contracttype] -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct RoyaltyConfig { - pub recipient: Address, - pub basis_points: u32, -} - -/// Maximum royalty policy: 10% of the sale price. -pub const MAX_ROYALTY_BASIS_POINTS: u32 = 1_000; - -/// Storage keys -#[contracttype] -pub enum DataKey { - /// Admin address - Admin, - /// Address nominated to become admin - PendingAdmin, - /// NFT contract address - NFTContract, - /// Marketplace fee percentage (basis points, e.g., 250 = 2.5%) - MarketplaceFee, - /// Fee recipient address - FeeRecipient, - /// Allowlisted payment token flag - AllowedPaymentToken(Address), - /// All allowlisted payment token addresses - AllowedPaymentTokens, - /// Listing data (token_id -> Listing) - Listing(u32), - /// All active listings - ActiveListings, - /// Offers for a token (token_id -> Vec) - Offers(u32), - /// Auction data (token_id -> Auction) - Auction(u32), - /// Active auctions list - ActiveAuctions, - /// Reentrancy guard - ReentrancyGuard, - /// Royalty configuration for a token ID - Royalty(u32), - /// Emergency pause state - Paused, -} - -#[cfg(test)] -mod tests; - -// ============================================================================ -// Contract Implementation -// ============================================================================ - -#[contract] -pub struct CommitmentMarketplace; - -fn read_admin(e: &Env) -> Result { - e.storage() - .instance() - .get(&DataKey::Admin) - .ok_or(MarketplaceError::NotInitialized) -} - -fn is_paused(e: &Env) -> bool { - e.storage() - .instance() - .get(&DataKey::Paused) - .unwrap_or(false) -} - -fn require_not_paused(e: &Env) -> Result<(), MarketplaceError> { - if is_paused(e) { - return Err(MarketplaceError::Paused); - } - Ok(()) -} - -fn read_allowed_payment_tokens(e: &Env) -> Vec
{ - e.storage() - .instance() - .get(&DataKey::AllowedPaymentTokens) - .unwrap_or(Vec::new(e)) -} - -fn write_allowed_payment_tokens(e: &Env, tokens: &Vec
) { - e.storage() - .instance() - .set(&DataKey::AllowedPaymentTokens, tokens); -} - -fn is_allowed_payment_token(e: &Env, payment_token: &Address) -> bool { - e.storage() - .persistent() - .get(&DataKey::AllowedPaymentToken(payment_token.clone())) - .unwrap_or(false) -} - -fn require_allowed_payment_token(e: &Env, payment_token: &Address) -> Result<(), MarketplaceError> { - if !is_allowed_payment_token(e, payment_token) { - return Err(MarketplaceError::PaymentTokenNotAllowed); - } - - Ok(()) -} - -fn royalty_for(e: &Env, token_id: u32) -> Option { - e.storage().persistent().get(&DataKey::Royalty(token_id)) -} - -fn calculate_sale_payouts( - e: &Env, - token_id: u32, - sale_amount: i128, -) -> Result<(i128, i128, i128, Option
), MarketplaceError> { - let fee_basis_points: u32 = e - .storage() - .instance() - .get(&DataKey::MarketplaceFee) - .unwrap_or(0); - let royalty = royalty_for(e, token_id); - let royalty_bps = royalty - .as_ref() - .map(|config| config.basis_points) - .unwrap_or(0); - if fee_basis_points > 10_000 { - return Err(MarketplaceError::PayoutExceedsSale); - } - if royalty_bps > MAX_ROYALTY_BASIS_POINTS { - return Err(MarketplaceError::RoyaltyTooHigh); - } - let fee_split = fee_invariants::split_bps(sale_amount, fee_basis_points).map_err(|error| { - match error { - fee_invariants::FeeError::InvalidRate => MarketplaceError::PayoutExceedsSale, - _ => MarketplaceError::PayoutOverflow, - } - })?; - let royalty_split = fee_invariants::split_bps(sale_amount, royalty_bps).map_err(|error| { - match error { - fee_invariants::FeeError::InvalidRate => MarketplaceError::RoyaltyTooHigh, - _ => MarketplaceError::PayoutOverflow, - } - })?; - let fee = fee_split.fee; - let royalty_amount = royalty_split.fee; - let deductions = fee - .checked_add(royalty_amount) - .ok_or(MarketplaceError::PayoutOverflow)?; - let seller_proceeds = sale_amount - .checked_sub(deductions) - .ok_or(MarketplaceError::PayoutOverflow)?; - if seller_proceeds < 0 { - return Err(MarketplaceError::PayoutExceedsSale); - } - Ok(( - seller_proceeds, - fee, - royalty_amount, - royalty.map(|config| config.recipient), - )) -} - -#[contractimpl] -impl CommitmentMarketplace { - // ======================================================================== - // Initialization - // ======================================================================== - - /// @notice Initialize the marketplace contract. - /// @param admin Admin address (must sign the transaction). - /// @param nft_contract Address of the CommitmentNFT contract. - /// @param fee_basis_points Marketplace fee in basis points (e.g., 250 = 2.5%). - /// @param fee_recipient Address to receive marketplace fees. - /// @dev Only callable once. Sets up admin, NFT contract, fee, and fee recipient. - /// @error MarketplaceError::AlreadyInitialized if already initialized. - /// @security Only callable by `admin` (require_auth). - pub fn initialize( - e: Env, - admin: Address, - nft_contract: Address, - fee_basis_points: u32, - fee_recipient: Address, - ) -> Result<(), MarketplaceError> { - if e.storage().instance().has(&DataKey::Admin) { - return Err(MarketplaceError::AlreadyInitialized); - } - if fee_basis_points > 10_000 { - return Err(MarketplaceError::PayoutExceedsSale); - } - - admin.require_auth(); - - e.storage().instance().set(&DataKey::Admin, &admin); - e.storage() - .instance() - .set(&DataKey::NFTContract, &nft_contract); - e.storage() - .instance() - .set(&DataKey::MarketplaceFee, &fee_basis_points); - e.storage() - .instance() - .set(&DataKey::FeeRecipient, &fee_recipient); - - let active_listings: Vec = Vec::new(&e); - e.storage() - .instance() - .set(&DataKey::ActiveListings, &active_listings); - - let active_auctions: Vec = Vec::new(&e); - e.storage() - .instance() - .set(&DataKey::ActiveAuctions, &active_auctions); - - let allowed_payment_tokens: Vec
= Vec::new(&e); - e.storage() - .instance() - .set(&DataKey::AllowedPaymentTokens, &allowed_payment_tokens); - e.storage().instance().set(&DataKey::Paused, &false); - - Ok(()) - } - - /// @notice Get the admin address for the marketplace. - /// @return admin Address of the admin. - /// @error MarketplaceError::NotInitialized if not initialized. - pub fn get_admin(e: Env) -> Result { - read_admin(&e) - } - - /// Nominate an administrator. The current administrator remains in - /// control until the nominee explicitly accepts the handover. - pub fn nominate_admin(e: Env, new_admin: Address) -> Result<(), MarketplaceError> { - let admin = read_admin(&e)?; - admin.require_auth(); - if new_admin == admin { - return Err(MarketplaceError::CannotNominateCurrentAdmin); - } - - e.storage() - .instance() - .set(&DataKey::PendingAdmin, &new_admin); - e.events() - .publish((symbol_short!("AdminNom"),), (admin, new_admin)); - Ok(()) - } - - /// Cancel a pending administrator handover. - pub fn cancel_admin_transfer(e: Env) -> Result<(), MarketplaceError> { - let admin = read_admin(&e)?; - admin.require_auth(); - if e.storage() - .instance() - .get::<_, Address>(&DataKey::PendingAdmin) - .is_none() - { - return Err(MarketplaceError::NoPendingAdmin); - } - - e.storage().instance().remove(&DataKey::PendingAdmin); - e.events().publish((symbol_short!("AdminCan"),), admin); - Ok(()) - } - - /// Accept the pending administrator role. Only the nominated address can - /// call this entry point, and authority changes atomically at acceptance. - pub fn accept_admin_transfer(e: Env, new_admin: Address) -> Result<(), MarketplaceError> { - let pending = e - .storage() - .instance() - .get::<_, Address>(&DataKey::PendingAdmin) - .ok_or(MarketplaceError::NoPendingAdmin)?; - if pending != new_admin { - return Err(MarketplaceError::NotPendingAdmin); - } - new_admin.require_auth(); - - let old_admin = read_admin(&e)?; - e.storage().instance().set(&DataKey::Admin, &new_admin); - e.storage().instance().remove(&DataKey::PendingAdmin); - e.events() - .publish((symbol_short!("AdminAcc"),), (old_admin, new_admin)); - Ok(()) - } - - /// Return the pending administrator, if a handover is awaiting acceptance. - pub fn get_pending_admin(e: Env) -> Option
{ - e.storage().instance().get(&DataKey::PendingAdmin) - } - - /// Pause risky marketplace mutations while preserving cancellation and - /// auction-ending recovery paths for already-created commitments. - pub fn pause(e: Env, caller: Address) -> Result<(), MarketplaceError> { - let admin = read_admin(&e)?; - caller.require_auth(); - if caller != admin { - return Err(MarketplaceError::Unauthorized); - } - if is_paused(&e) { - return Err(MarketplaceError::AlreadyPaused); - } - e.storage().instance().set(&DataKey::Paused, &true); - e.events() - .publish((symbol_short!("Pause"),), e.ledger().timestamp()); - Ok(()) - } - - /// Resume normal marketplace mutations after an emergency review. - pub fn unpause(e: Env, caller: Address) -> Result<(), MarketplaceError> { - let admin = read_admin(&e)?; - caller.require_auth(); - if caller != admin { - return Err(MarketplaceError::Unauthorized); - } - if !is_paused(&e) { - return Err(MarketplaceError::NotPaused); - } - e.storage().instance().set(&DataKey::Paused, &false); - e.events() - .publish((symbol_short!("Unpause"),), e.ledger().timestamp()); - Ok(()) - } - - /// Return whether the marketplace is in emergency pause mode. - pub fn is_paused(e: Env) -> bool { - is_paused(&e) - } - - /// @notice Update the marketplace fee (basis points). - /// @param fee_basis_points New fee in basis points. - /// @dev Only callable by admin. - /// @error MarketplaceError::NotInitialized if not initialized. - /// @security Only callable by `admin` (require_auth). - pub fn update_fee(e: Env, fee_basis_points: u32) -> Result<(), MarketplaceError> { - let admin: Address = Self::get_admin(e.clone())?; - admin.require_auth(); - if fee_basis_points > 10_000 { - return Err(MarketplaceError::PayoutExceedsSale); - } - require_not_paused(&e)?; - - e.storage() - .instance() - .set(&DataKey::MarketplaceFee, &fee_basis_points); - - e.events() - .publish((Symbol::new(&e, "FeeUpdated"),), fee_basis_points); - - Ok(()) - } - - /// Set or replace royalty configuration for an active listing. - /// Only the listing seller may authorize its recipient and basis points. - pub fn set_royalty( - e: Env, - seller: Address, - token_id: u32, - recipient: Address, - basis_points: u32, - ) -> Result<(), MarketplaceError> { - seller.require_auth(); - if basis_points > MAX_ROYALTY_BASIS_POINTS { - return Err(MarketplaceError::RoyaltyTooHigh); - } - let listing: Listing = e - .storage() - .persistent() - .get(&DataKey::Listing(token_id)) - .ok_or(MarketplaceError::ListingNotFound)?; - if listing.seller != seller { - return Err(MarketplaceError::UnauthorizedRoyaltyUpdate); - } - e.storage().persistent().set( - &DataKey::Royalty(token_id), - &RoyaltyConfig { - recipient, - basis_points, - }, - ); - e.events().publish( - (symbol_short!("RoyaltySet"), token_id), - (recipient, basis_points), - ); - Ok(()) - } - - /// Return the configured royalty for a token, if one exists. - pub fn get_royalty(e: Env, token_id: u32) -> Option { - royalty_for(&e, token_id) - } - - /// Add a token contract to the payment-token allowlist. - /// - /// # Arguments - /// * `payment_token` - Soroban token contract address approved for marketplace payments - /// - /// # Errors - /// * `MarketplaceError::NotInitialized` if the marketplace has not been initialized - /// - /// # Security Notes - /// Admin-only. All outbound token transfers in the marketplace trust this list. - pub fn add_payment_token(e: Env, payment_token: Address) -> Result<(), MarketplaceError> { - let admin = read_admin(&e)?; - admin.require_auth(); - require_not_paused(&e)?; - - if is_allowed_payment_token(&e, &payment_token) { - return Ok(()); - } - - e.storage() - .persistent() - .set(&DataKey::AllowedPaymentToken(payment_token.clone()), &true); - - let mut allowed_payment_tokens = read_allowed_payment_tokens(&e); - allowed_payment_tokens.push_back(payment_token.clone()); - write_allowed_payment_tokens(&e, &allowed_payment_tokens); - - e.events() - .publish((symbol_short!("TokenAdd"), payment_token.clone()), admin); - - Ok(()) - } - - /// Remove a token contract from the payment-token allowlist. - /// - /// # Arguments - /// * `payment_token` - Soroban token contract address to delist - /// - /// # Errors - /// * `MarketplaceError::NotInitialized` if the marketplace has not been initialized - /// - /// # Security Notes - /// Admin-only. Removing a token prevents new usage and also blocks settlement - /// for existing listings, offers, or auctions referencing the removed token - /// until it is allowlisted again. - pub fn remove_payment_token(e: Env, payment_token: Address) -> Result<(), MarketplaceError> { - let admin = read_admin(&e)?; - admin.require_auth(); - require_not_paused(&e)?; - - e.storage() - .persistent() - .remove(&DataKey::AllowedPaymentToken(payment_token.clone())); - - let mut allowed_payment_tokens = read_allowed_payment_tokens(&e); - if let Some(index) = allowed_payment_tokens - .iter() - .position(|token| token == payment_token) - { - allowed_payment_tokens.remove(index as u32); - write_allowed_payment_tokens(&e, &allowed_payment_tokens); - - e.events() - .publish((symbol_short!("TokenRem"), payment_token.clone()), admin); - } - - Ok(()) - } - - /// Return true when a payment token is currently allowlisted. - pub fn is_payment_token_allowed(e: Env, payment_token: Address) -> bool { - is_allowed_payment_token(&e, &payment_token) - } - - /// Return the full set of allowlisted payment tokens. - pub fn get_allowed_payment_tokens(e: Env) -> Vec
{ - read_allowed_payment_tokens(&e) - } - - // ======================================================================== - // Listing Management - // ======================================================================== - - /// @notice List an NFT for sale on the marketplace. - /// @param seller Seller's address (must be NFT owner and sign the transaction). - /// @param token_id NFT token ID to list. - /// @param price Sale price (must be > 0). - /// @param payment_token Token contract address for payment. - /// @dev Reentrancy guard enforced. No cross-contract NFT ownership check in this implementation. - /// @error MarketplaceError::InvalidPrice if price <= 0. - /// @error MarketplaceError::ListingExists if listing already exists. - /// @error MarketplaceError::NotInitialized if contract not initialized. - /// @security Only callable by `seller` (require_auth). - pub fn list_nft( - e: Env, - seller: Address, - token_id: u32, - price: i128, - payment_token: Address, - ) -> Result<(), MarketplaceError> { - require_not_paused(&e)?; - // Reentrancy protection - let guard: bool = e - .storage() - .instance() - .get(&DataKey::ReentrancyGuard) - .unwrap_or(false); - if guard { - return Err(MarketplaceError::ReentrancyDetected); - } - e.storage().instance().set(&DataKey::ReentrancyGuard, &true); - - // CHECKS - seller.require_auth(); - - if price <= 0 { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::InvalidPrice); - } - - if let Err(err) = require_allowed_payment_token(&e, &payment_token) { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(err); - } - - // Check if listing already exists - if e.storage().persistent().has(&DataKey::Listing(token_id)) { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::ListingExists); - } - - // Verify seller owns the NFT (external call - after checks) - let _nft_contract: Address = e - .storage() - .instance() - .get(&DataKey::NFTContract) - .ok_or_else(|| { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - MarketplaceError::NotInitialized - })?; - - // Note: This would require the NFT contract client - // For now, we assume the caller has verified ownership - // In production, you'd call: nft_contract.owner_of(&token_id) - - // EFFECTS - let listing = Listing { - token_id, - seller: seller.clone(), - price, - payment_token: payment_token.clone(), - listed_at: e.ledger().timestamp(), - }; - - e.storage() - .persistent() - .set(&DataKey::Listing(token_id), &listing); - - // Add to active listings - let mut active_listings: Vec = e - .storage() - .instance() - .get(&DataKey::ActiveListings) - .unwrap_or(Vec::new(&e)); - active_listings.push_back(token_id); - e.storage() - .instance() - .set(&DataKey::ActiveListings, &active_listings); - - // Clear reentrancy guard - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - - // Emit event - e.events().publish( - (symbol_short!("ListNFT"), token_id), - (seller, price, payment_token), - ); - - Ok(()) - } - - /// @notice Cancel an active NFT listing. - /// @param seller Seller's address (must sign the transaction). - /// @param token_id NFT token ID to cancel listing for. - /// @dev Reentrancy guard enforced. Checks-effects-interactions pattern. - /// @error MarketplaceError::ListingNotFound if listing does not exist. - /// @error MarketplaceError::NotSeller if caller is not the seller. - /// @security Only callable by `seller` (require_auth). - pub fn cancel_listing(e: Env, seller: Address, token_id: u32) -> Result<(), MarketplaceError> { - // Reentrancy protection - let guard: bool = e - .storage() - .instance() - .get(&DataKey::ReentrancyGuard) - .unwrap_or(false); - if guard { - return Err(MarketplaceError::ReentrancyDetected); - } - e.storage().instance().set(&DataKey::ReentrancyGuard, &true); - - // CHECKS - seller.require_auth(); - - let listing: Listing = e - .storage() - .persistent() - .get(&DataKey::Listing(token_id)) - .ok_or_else(|| { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - MarketplaceError::ListingNotFound - })?; - - if listing.seller != seller { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::NotSeller); - } - - // EFFECTS - // Remove listing - e.storage().persistent().remove(&DataKey::Listing(token_id)); - e.storage().persistent().remove(&DataKey::Royalty(token_id)); - - // Remove from active listings - let mut active_listings: Vec = e - .storage() - .instance() - .get(&DataKey::ActiveListings) - .unwrap_or(Vec::new(&e)); - if let Some(index) = active_listings.iter().position(|id| id == token_id) { - active_listings.remove(index as u32); - } - e.storage() - .instance() - .set(&DataKey::ActiveListings, &active_listings); - - // Clear reentrancy guard - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - - // Emit event - e.events() - .publish((symbol_short!("ListCncl"), token_id), seller); - - Ok(()) - } - - /// @notice Buy an NFT from an active listing. - /// @param buyer Buyer's address (must sign the transaction). - /// @param token_id NFT token ID to buy. - /// @dev Reentrancy guard enforced. Handles token transfers. No cross-contract NFT transfer in this implementation. - /// @error MarketplaceError::ListingNotFound if listing does not exist. - /// @error MarketplaceError::CannotBuyOwnListing if buyer is seller. - /// @error MarketplaceError::NotInitialized if contract not initialized. - /// @security Only callable by `buyer` (require_auth). - pub fn buy_nft(e: Env, buyer: Address, token_id: u32) -> Result<(), MarketplaceError> { - require_not_paused(&e)?; - // Reentrancy protection - let guard: bool = e - .storage() - .instance() - .get(&DataKey::ReentrancyGuard) - .unwrap_or(false); - if guard { - return Err(MarketplaceError::ReentrancyDetected); - } - e.storage().instance().set(&DataKey::ReentrancyGuard, &true); - - // CHECKS - buyer.require_auth(); - - let listing: Listing = e - .storage() - .persistent() - .get(&DataKey::Listing(token_id)) - .ok_or_else(|| { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - MarketplaceError::ListingNotFound - })?; - - if listing.seller == buyer { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::CannotBuyOwnListing); - } - - if let Err(err) = require_allowed_payment_token(&e, &listing.payment_token) { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(err); - } - - let fee_recipient: Address = e - .storage() - .instance() - .get(&DataKey::FeeRecipient) - .ok_or_else(|| { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - MarketplaceError::NotInitialized - })?; - - let _nft_contract: Address = e - .storage() - .instance() - .get(&DataKey::NFTContract) - .ok_or_else(|| { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - MarketplaceError::NotInitialized - })?; - - // Calculate seller, fee, and royalty amounts before changing state. - let (seller_proceeds, marketplace_fee, royalty_amount, royalty_recipient) = - calculate_sale_payouts(&e, token_id, listing.price)?; - - // EFFECTS - // Remove listing first (prevent reentrancy) - e.storage().persistent().remove(&DataKey::Listing(token_id)); - e.storage().persistent().remove(&DataKey::Royalty(token_id)); - - // Remove from active listings - let mut active_listings: Vec = e - .storage() - .instance() - .get(&DataKey::ActiveListings) - .unwrap_or(Vec::new(&e)); - if let Some(index) = active_listings.iter().position(|id| id == token_id) { - active_listings.remove(index as u32); - } - e.storage() - .instance() - .set(&DataKey::ActiveListings, &active_listings); - - // INTERACTIONS - External calls AFTER state changes - // Transfer payment token from buyer to seller - let payment_token_client = token::Client::new(&e, &listing.payment_token); - payment_token_client.transfer(&buyer, &listing.seller, &seller_proceeds); - - // Transfer marketplace fee if applicable - if marketplace_fee > 0 { - payment_token_client.transfer(&buyer, &fee_recipient, &marketplace_fee); - } - - if royalty_amount > 0 { - if let Some(recipient) = royalty_recipient { - payment_token_client.transfer(&buyer, &recipient, &royalty_amount); - } - } - - // Transfer NFT from seller to buyer - // Note: In production, you'd use the NFT contract client: - // let nft_client = CommitmentNFTContractClient::new(&e, &nft_contract); - // nft_client.transfer(&listing.seller, &buyer, &token_id); - // For this implementation, we assume the transfer happens correctly - - // Clear reentrancy guard - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - - // Emit event - e.events().publish( - (symbol_short!("NFTSold"), token_id), - (listing.seller, buyer, listing.price), - ); - - Ok(()) - } - - /// @notice Get details of a specific NFT listing. - /// @param token_id NFT token ID. - /// @return Listing struct. - /// @error MarketplaceError::ListingNotFound if listing does not exist. - pub fn get_listing(e: Env, token_id: u32) -> Result { - e.storage() - .persistent() - .get(&DataKey::Listing(token_id)) - .ok_or(MarketplaceError::ListingNotFound) - } - - /// @notice Get all active NFT listings. - /// @return Vec of all active listings. - pub fn get_all_listings(e: Env) -> Vec { - let active_listings: Vec = e - .storage() - .instance() - .get(&DataKey::ActiveListings) - .unwrap_or(Vec::new(&e)); - - let mut listings: Vec = Vec::new(&e); - - for token_id in active_listings.iter() { - if let Some(listing) = e - .storage() - .persistent() - .get::<_, Listing>(&DataKey::Listing(token_id)) - { - listings.push_back(listing); - } - } - - listings - } - - // ======================================================================== - // Offer System - // ======================================================================== - - /// @notice Make an offer on an NFT. - /// @param offerer Offer maker's address (must sign the transaction). - /// @param token_id NFT token ID to make offer on. - /// @param amount Offer amount (must be > 0). - /// @param payment_token Token contract address for payment. - /// @dev Reentrancy guard enforced. - /// @error MarketplaceError::InvalidOfferAmount if amount <= 0. - /// @error MarketplaceError::OfferExists if offerer already has an offer. - /// @security Only callable by `offerer` (require_auth). - pub fn make_offer( - e: Env, - offerer: Address, - token_id: u32, - amount: i128, - payment_token: Address, - ) -> Result<(), MarketplaceError> { - require_not_paused(&e)?; - // Reentrancy protection - let guard: bool = e - .storage() - .instance() - .get(&DataKey::ReentrancyGuard) - .unwrap_or(false); - if guard { - return Err(MarketplaceError::ReentrancyDetected); - } - e.storage().instance().set(&DataKey::ReentrancyGuard, &true); - - // CHECKS - offerer.require_auth(); - - if amount <= 0 { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::InvalidOfferAmount); - } - - if let Err(err) = require_allowed_payment_token(&e, &payment_token) { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(err); - } - - if let Some(listing) = e - .storage() - .persistent() - .get::<_, Listing>(&DataKey::Listing(token_id)) - { - if listing.seller == offerer { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::CannotBuyOwnListing); - } - } - - if let Some(auction) = e - .storage() - .persistent() - .get::<_, Auction>(&DataKey::Auction(token_id)) - { - if auction.seller == offerer { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::CannotBuyOwnListing); - } - } - - // EFFECTS - let offer = Offer { - token_id, - offerer: offerer.clone(), - amount, - payment_token: payment_token.clone(), - created_at: e.ledger().timestamp(), - }; - - let mut offers: Vec = e - .storage() - .persistent() - .get(&DataKey::Offers(token_id)) - .unwrap_or(Vec::new(&e)); - - // Check if offerer already has an offer - for existing_offer in offers.iter() { - if existing_offer.offerer == offerer { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::OfferExists); - } - } - - offers.push_back(offer); - e.storage() - .persistent() - .set(&DataKey::Offers(token_id), &offers); - - // Clear reentrancy guard - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - - // Emit event - e.events().publish( - (symbol_short!("OfferMade"), token_id), - (offerer, amount, payment_token), - ); - - Ok(()) - } - - /// @notice Accept an offer on an NFT. - /// @param seller Seller's address (must sign the transaction). - /// @param token_id NFT token ID. - /// @param offerer Address of the offer maker. - /// @dev Reentrancy guard enforced. Handles token transfers. No cross-contract NFT transfer in this implementation. - /// @error MarketplaceError::OfferNotFound if offer does not exist. - /// @error MarketplaceError::NotInitialized if contract not initialized. - /// @security Only callable by `seller` (require_auth). - pub fn accept_offer( - e: Env, - seller: Address, - token_id: u32, - offerer: Address, - ) -> Result<(), MarketplaceError> { - require_not_paused(&e)?; - // Reentrancy protection - let guard: bool = e - .storage() - .instance() - .get(&DataKey::ReentrancyGuard) - .unwrap_or(false); - if guard { - return Err(MarketplaceError::ReentrancyDetected); - } - e.storage().instance().set(&DataKey::ReentrancyGuard, &true); - - // CHECKS - seller.require_auth(); - - if seller == offerer { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::CannotBuyOwnListing); - } - - let offers: Vec = e - .storage() - .persistent() - .get(&DataKey::Offers(token_id)) - .ok_or_else(|| { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - MarketplaceError::OfferNotFound - })?; - - // Find the offer - let offer_index = offers - .iter() - .position(|o| o.offerer == offerer) - .ok_or_else(|| { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - MarketplaceError::OfferNotFound - })?; - - let offer = offers.get(offer_index as u32).unwrap(); - - let fee_recipient: Address = e - .storage() - .instance() - .get(&DataKey::FeeRecipient) - .ok_or_else(|| { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - MarketplaceError::NotInitialized - })?; - - if let Err(err) = require_allowed_payment_token(&e, &offer.payment_token) { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(err); - } - - // Calculate fee, royalty, and seller proceeds before changing state. - let (seller_proceeds, marketplace_fee, royalty_amount, royalty_recipient) = - calculate_sale_payouts(&e, token_id, offer.amount)?; - - // EFFECTS - // Remove all offers for this token - e.storage().persistent().remove(&DataKey::Offers(token_id)); - - // Remove listing if exists - if e.storage().persistent().has(&DataKey::Listing(token_id)) { - e.storage().persistent().remove(&DataKey::Listing(token_id)); - e.storage().persistent().remove(&DataKey::Royalty(token_id)); - - let mut active_listings: Vec = e - .storage() - .instance() - .get(&DataKey::ActiveListings) - .unwrap_or(Vec::new(&e)); - if let Some(index) = active_listings.iter().position(|id| id == token_id) { - active_listings.remove(index as u32); - } - e.storage() - .instance() - .set(&DataKey::ActiveListings, &active_listings); - } - - // INTERACTIONS - // Transfer payment - let payment_token_client = token::Client::new(&e, &offer.payment_token); - payment_token_client.transfer(&offerer, &seller, &seller_proceeds); - - if marketplace_fee > 0 { - payment_token_client.transfer(&offerer, &fee_recipient, &marketplace_fee); - } - - if royalty_amount > 0 { - if let Some(recipient) = royalty_recipient { - payment_token_client.transfer(&offerer, &recipient, &royalty_amount); - } - } - - // Transfer NFT - // Note: Use NFT contract client in production - - // Clear reentrancy guard - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - - // Emit event - e.events().publish( - (symbol_short!("OffAccpt"), token_id), - (seller, offerer, offer.amount), - ); - - Ok(()) - } - - /// @notice Cancel an offer made on an NFT. - /// @param offerer Offer maker's address (must sign the transaction). - /// @param token_id NFT token ID. - /// @error MarketplaceError::OfferNotFound if offer does not exist. - /// @security Only callable by `offerer` (require_auth). - pub fn cancel_offer(e: Env, offerer: Address, token_id: u32) -> Result<(), MarketplaceError> { - offerer.require_auth(); - - let mut offers: Vec = e - .storage() - .persistent() - .get(&DataKey::Offers(token_id)) - .ok_or(MarketplaceError::OfferNotFound)?; - - let offer_index = offers - .iter() - .position(|o| o.offerer == offerer) - .ok_or(MarketplaceError::OfferNotFound)?; - - offers.remove(offer_index as u32); - - if offers.is_empty() { - e.storage().persistent().remove(&DataKey::Offers(token_id)); - } else { - e.storage() - .persistent() - .set(&DataKey::Offers(token_id), &offers); - } - - e.events() - .publish((symbol_short!("OfferCanc"), token_id), offerer); - - Ok(()) - } - - /// @notice Get all offers for a specific NFT token. - /// @param token_id NFT token ID. - /// @return Vec of all offers for the token. - pub fn get_offers(e: Env, token_id: u32) -> Vec { - e.storage() - .persistent() - .get(&DataKey::Offers(token_id)) - .unwrap_or(Vec::new(&e)) - } - - // ======================================================================== - // Auction System - // ======================================================================== - - /// @notice Start an auction for an NFT. - /// @param seller Seller's address (must sign the transaction). - /// @param token_id NFT token ID. - /// @param starting_price Starting price for the auction (must be > 0). - /// @param duration_seconds Duration of the auction in seconds (must be > 0). - /// @param payment_token Token contract address for payment. - /// @dev Reentrancy guard enforced. - /// @error MarketplaceError::InvalidPrice if starting price <= 0. - /// @error MarketplaceError::InvalidDuration if duration is 0. - /// @error MarketplaceError::ListingExists if auction already exists for token. - /// @security Only callable by `seller` (require_auth). - pub fn start_auction( - e: Env, - seller: Address, - token_id: u32, - starting_price: i128, - duration_seconds: u64, - payment_token: Address, - ) -> Result<(), MarketplaceError> { - require_not_paused(&e)?; - // Reentrancy protection - let guard: bool = e - .storage() - .instance() - .get(&DataKey::ReentrancyGuard) - .unwrap_or(false); - if guard { - return Err(MarketplaceError::ReentrancyDetected); - } - e.storage().instance().set(&DataKey::ReentrancyGuard, &true); - - // CHECKS - seller.require_auth(); - - if starting_price <= 0 { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::InvalidPrice); - } - - if duration_seconds == 0 { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::InvalidDuration); - } - - if let Err(err) = require_allowed_payment_token(&e, &payment_token) { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(err); - } - - if e.storage().persistent().has(&DataKey::Auction(token_id)) { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::ListingExists); - } - - // EFFECTS - let started_at = e.ledger().timestamp(); - let ends_at = started_at.checked_add(duration_seconds).ok_or_else(|| { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - MarketplaceError::InvalidDuration - })?; - - let auction = Auction { - token_id, - seller: seller.clone(), - starting_price, - current_bid: starting_price, - highest_bidder: None, - payment_token: payment_token.clone(), - started_at, - ends_at, - ended: false, - }; - - e.storage() - .persistent() - .set(&DataKey::Auction(token_id), &auction); - - let mut active_auctions: Vec = e - .storage() - .instance() - .get(&DataKey::ActiveAuctions) - .unwrap_or(Vec::new(&e)); - active_auctions.push_back(token_id); - e.storage() - .instance() - .set(&DataKey::ActiveAuctions, &active_auctions); - - // Clear reentrancy guard - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - - // Emit event - e.events().publish( - (symbol_short!("AucStart"), token_id), - (seller, starting_price, ends_at), - ); - - Ok(()) - } - - /// @notice Place a bid on an active auction. - /// @param bidder Bidder's address (must sign the transaction). - /// @param token_id NFT token ID. - /// @param bid_amount Amount of the bid (must be > current bid). - /// @dev Reentrancy guard enforced. Handles token transfers for bid refunds. - /// @error MarketplaceError::AuctionEnded if auction has ended. - /// @error MarketplaceError::BidTooLow if bid is not higher than current bid. - /// @error MarketplaceError::CannotBuyOwnListing if seller tries to bid. - /// @security Only callable by `bidder` (require_auth). - pub fn place_bid( - e: Env, - bidder: Address, - token_id: u32, - bid_amount: i128, - ) -> Result<(), MarketplaceError> { - require_not_paused(&e)?; - // Reentrancy protection - let guard: bool = e - .storage() - .instance() - .get(&DataKey::ReentrancyGuard) - .unwrap_or(false); - if guard { - return Err(MarketplaceError::ReentrancyDetected); - } - e.storage().instance().set(&DataKey::ReentrancyGuard, &true); - - // CHECKS - bidder.require_auth(); - - let mut auction: Auction = e - .storage() - .persistent() - .get(&DataKey::Auction(token_id)) - .ok_or_else(|| { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - MarketplaceError::AuctionNotFound - })?; - - let current_time = e.ledger().timestamp(); - if current_time >= auction.ends_at { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::AuctionEnded); - } - - if bid_amount <= auction.current_bid { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::BidTooLow); - } - - if auction.seller == bidder { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::CannotBuyOwnListing); - } - - if let Err(err) = require_allowed_payment_token(&e, &auction.payment_token) { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(err); - } - - // EFFECTS - let previous_bidder = auction.highest_bidder.clone(); - let previous_bid = auction.current_bid; - - auction.current_bid = bid_amount; - auction.highest_bidder = Some(bidder.clone()); - - e.storage() - .persistent() - .set(&DataKey::Auction(token_id), &auction); - - // INTERACTIONS - let payment_token_client = token::Client::new(&e, &auction.payment_token); - - // Transfer new bid from bidder to contract (escrow) - payment_token_client.transfer(&bidder, &e.current_contract_address(), &bid_amount); - - // Refund previous bidder if exists - if let Some(prev_bidder) = previous_bidder { - payment_token_client.transfer( - &e.current_contract_address(), - &prev_bidder, - &previous_bid, - ); - } - - // Clear reentrancy guard - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - - // Emit event - e.events() - .publish((symbol_short!("BidPlaced"), token_id), (bidder, bid_amount)); - - Ok(()) - } - - /// @notice End an auction and settle payment/NFT transfer. - /// @param token_id NFT token ID. - /// @dev Reentrancy guard enforced. Handles final settlement. Anyone can call after auction ends. - /// @error MarketplaceError::AuctionNotFound if auction does not exist. - /// @error MarketplaceError::AuctionNotEnded if auction has not ended yet. - /// @error MarketplaceError::AuctionEnded if auction already ended. - pub fn end_auction(e: Env, token_id: u32) -> Result<(), MarketplaceError> { - // Reentrancy protection - let guard: bool = e - .storage() - .instance() - .get(&DataKey::ReentrancyGuard) - .unwrap_or(false); - if guard { - return Err(MarketplaceError::ReentrancyDetected); - } - e.storage().instance().set(&DataKey::ReentrancyGuard, &true); - - // CHECKS - let mut auction: Auction = e - .storage() - .persistent() - .get(&DataKey::Auction(token_id)) - .ok_or_else(|| { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - MarketplaceError::AuctionNotFound - })?; - - let current_time = e.ledger().timestamp(); - if current_time < auction.ends_at { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::AuctionNotEnded); - } - - if auction.ended { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(MarketplaceError::AuctionEnded); - } - - let fee_recipient: Address = e - .storage() - .instance() - .get(&DataKey::FeeRecipient) - .ok_or_else(|| { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - MarketplaceError::NotInitialized - })?; - - if auction.highest_bidder.is_some() { - if let Err(err) = require_allowed_payment_token(&e, &auction.payment_token) { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - return Err(err); - } - } - - // EFFECTS - auction.ended = true; - e.storage() - .persistent() - .set(&DataKey::Auction(token_id), &auction); - - // Remove from active auctions - let mut active_auctions: Vec = e - .storage() - .instance() - .get(&DataKey::ActiveAuctions) - .unwrap_or(Vec::new(&e)); - if let Some(index) = active_auctions.iter().position(|id| id == token_id) { - active_auctions.remove(index as u32); - } - e.storage() - .instance() - .set(&DataKey::ActiveAuctions, &active_auctions); - - // INTERACTIONS - if let Some(winner) = auction.highest_bidder { - let (seller_proceeds, marketplace_fee, royalty_amount, royalty_recipient) = - calculate_sale_payouts(&e, token_id, auction.current_bid)?; - - let payment_token_client = token::Client::new(&e, &auction.payment_token); - - // Transfer payment from escrow to seller - payment_token_client.transfer( - &e.current_contract_address(), - &auction.seller, - &seller_proceeds, - ); - - // Transfer fee - if marketplace_fee > 0 { - payment_token_client.transfer( - &e.current_contract_address(), - &fee_recipient, - &marketplace_fee, - ); - } - - if royalty_amount > 0 { - if let Some(recipient) = royalty_recipient { - payment_token_client.transfer( - &e.current_contract_address(), - &recipient, - &royalty_amount, - ); - } - } - - e.storage().persistent().remove(&DataKey::Royalty(token_id)); - - // Transfer NFT to winner - // Note: Use NFT contract client in production - - // Clear reentrancy guard - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - - // Emit event - e.events().publish( - (symbol_short!("AucEnd"), token_id), - (winner, auction.current_bid), - ); - } else { - // No bids - return NFT to seller - e.storage().persistent().remove(&DataKey::Royalty(token_id)); - - // Clear reentrancy guard - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &false); - - e.events() - .publish((symbol_short!("AucNoBid"), token_id), auction.seller); - } - - Ok(()) - } - - /// @notice Get details of a specific auction. - /// @param token_id NFT token ID. - /// @return Auction struct. - /// @error MarketplaceError::AuctionNotFound if auction does not exist. - pub fn get_auction(e: Env, token_id: u32) -> Result { - e.storage() - .persistent() - .get(&DataKey::Auction(token_id)) - .ok_or(MarketplaceError::AuctionNotFound) - } - - /// @notice Get all active auctions. - /// @return Vec of all active auctions. - pub fn get_all_auctions(e: Env) -> Vec { - let active_auctions: Vec = e - .storage() - .instance() - .get(&DataKey::ActiveAuctions) - .unwrap_or(Vec::new(&e)); - - let mut auctions: Vec = Vec::new(&e); - - for token_id in active_auctions.iter() { - if let Some(auction) = e - .storage() - .persistent() - .get::<_, Auction>(&DataKey::Auction(token_id)) - { - auctions.push_back(auction); - } - } - - auctions - } -} +//! # Commitment Marketplace Contract +//! +//! Soroban smart contract for NFT marketplace operations (listings, offers, auctions) with reentrancy guard and fee logic. +//! +//! ## Security +//! - All state-changing entry points require authentication (`require_auth`). +//! - Reentrancy guard is enforced on all external-call entry points. +//! - Arithmetic is performed using checked math; see individual functions for overflow/underflow notes. +//! +//! ## Errors +//! - See [`MarketplaceError`] for all error codes. +//! +//! ## Storage +//! +//! - See [`DataKey`] for all storage keys mutated by each entry point. +//! +//! ## Audit Notes +//! - No cross-contract NFT ownership checks are performed in this implementation (see comments in code). +//! - All token transfers use Soroban token interface. + +#![no_std] + +use soroban_sdk::{ + contract, contracterror, contractimpl, contracttype, symbol_short, token, Address, Env, Symbol, + Vec, +}; +use shared_utils::fee_invariants; + +// ============================================================================ +// Error Types +// ============================================================================ + +/// Marketplace errors +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum MarketplaceError { + /// Marketplace not initialized + NotInitialized = 1, + /// Already initialized + AlreadyInitialized = 2, + /// Listing not found + ListingNotFound = 3, + /// Not the seller + NotSeller = 4, + /// NFT not active + NFTNotActive = 5, + /// Invalid price (must be > 0) + InvalidPrice = 6, + /// Listing already exists for this token + ListingExists = 7, + /// Buyer cannot be seller + CannotBuyOwnListing = 8, + /// Insufficient payment + InsufficientPayment = 9, + /// NFT contract call failed + NFTContractError = 10, + /// Offer not found + OfferNotFound = 11, + /// Invalid offer amount + InvalidOfferAmount = 12, + /// Offer already exists + OfferExists = 13, + /// Not offer maker + NotOfferMaker = 14, + /// Auction not found + AuctionNotFound = 15, + /// Auction already ended + AuctionEnded = 16, + /// Auction not ended yet + AuctionNotEnded = 17, + /// Bid too low + BidTooLow = 18, + /// Invalid duration + InvalidDuration = 19, + /// Reentrancy detected + ReentrancyDetected = 20, + /// Transfer failed + TransferFailed = 21, + /// Payment token is not allowlisted for marketplace settlement + PaymentTokenNotAllowed = 22, + /// Configured fee cannot be represented as a safe accounting split. + InvalidFeeConfiguration = 34, + /// No administrator handover is currently pending + NoPendingAdmin = 31, + /// Caller is not the nominated administrator + NotPendingAdmin = 32, + /// The current administrator cannot nominate itself + CannotNominateCurrentAdmin = 33, + /// Royalty basis points exceed the marketplace policy maximum + RoyaltyTooHigh = 27, + /// Seller, fee, and royalty payouts cannot be represented safely + PayoutOverflow = 28, + /// The configured fee and royalty cannot be paid from the sale amount + PayoutExceedsSale = 29, + /// Caller is not authorized to change royalty configuration + UnauthorizedRoyaltyUpdate = 30, + /// Marketplace is paused; new/risky mutations are disabled + Paused = 23, + /// Marketplace is already paused + AlreadyPaused = 24, + /// Marketplace is not paused + NotPaused = 25, + /// Caller is not the marketplace administrator + Unauthorized = 26, + /// Offer has expired: ledger timestamp is at or past `expires_at` + OfferExpired = 35, +} + +// ============================================================================ +// Data Types +// ============================================================================ + +/// Listing information +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Listing { + pub token_id: u32, + pub seller: Address, + pub price: i128, + pub payment_token: Address, + pub listed_at: u64, +} + +/// Offer information +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Offer { + pub token_id: u32, + pub offerer: Address, + pub amount: i128, + pub payment_token: Address, + /// Ledger timestamp when the offer was created. + pub created_at: u64, + /// Ledger timestamp at which the offer expires. The boundary is exclusive: + /// the offer is valid only while `ledger.timestamp() < expires_at` — the + /// same convention used by `Auction::ends_at`. See `is_offer_expired`. + pub expires_at: u64, +} + +/// Auction information +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Auction { + pub token_id: u32, + pub seller: Address, + pub starting_price: i128, + pub current_bid: i128, + pub highest_bidder: Option
, + pub payment_token: Address, + pub started_at: u64, + pub ends_at: u64, + pub ended: bool, +} + +/// Royalty configuration applied to a token sale. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RoyaltyConfig { + pub recipient: Address, + pub basis_points: u32, +} + +/// Maximum royalty policy: 10% of the sale price. +pub const MAX_ROYALTY_BASIS_POINTS: u32 = 1_000; + +/// Storage keys +#[contracttype] +pub enum DataKey { + /// Admin address + Admin, + /// Address nominated to become admin + PendingAdmin, + /// NFT contract address + NFTContract, + /// Marketplace fee percentage (basis points, e.g., 250 = 2.5%) + MarketplaceFee, + /// Fee recipient address + FeeRecipient, + /// Allowlisted payment token flag + AllowedPaymentToken(Address), + /// All allowlisted payment token addresses + AllowedPaymentTokens, + /// Listing data (token_id -> Listing) + Listing(u32), + /// All active listings + ActiveListings, + /// Offers for a token (token_id -> Vec) + Offers(u32), + /// Auction data (token_id -> Auction) + Auction(u32), + /// Active auctions list + ActiveAuctions, + /// Reentrancy guard + ReentrancyGuard, + /// Royalty configuration for a token ID + Royalty(u32), + /// Emergency pause state + Paused, +} + +#[cfg(test)] +mod tests; + +// ============================================================================ +// Contract Implementation +// ============================================================================ + +#[contract] +pub struct CommitmentMarketplace; + +fn read_admin(e: &Env) -> Result { + e.storage() + .instance() + .get(&DataKey::Admin) + .ok_or(MarketplaceError::NotInitialized) +} + +fn is_paused(e: &Env) -> bool { + e.storage() + .instance() + .get(&DataKey::Paused) + .unwrap_or(false) +} + +fn require_not_paused(e: &Env) -> Result<(), MarketplaceError> { + if is_paused(e) { + return Err(MarketplaceError::Paused); + } + Ok(()) +} + +fn read_allowed_payment_tokens(e: &Env) -> Vec
{ + e.storage() + .instance() + .get(&DataKey::AllowedPaymentTokens) + .unwrap_or(Vec::new(e)) +} + +fn write_allowed_payment_tokens(e: &Env, tokens: &Vec
) { + e.storage() + .instance() + .set(&DataKey::AllowedPaymentTokens, tokens); +} + +fn is_allowed_payment_token(e: &Env, payment_token: &Address) -> bool { + e.storage() + .persistent() + .get(&DataKey::AllowedPaymentToken(payment_token.clone())) + .unwrap_or(false) +} + +fn require_allowed_payment_token(e: &Env, payment_token: &Address) -> Result<(), MarketplaceError> { + if !is_allowed_payment_token(e, payment_token) { + return Err(MarketplaceError::PaymentTokenNotAllowed); + } + + Ok(()) +} + +fn royalty_for(e: &Env, token_id: u32) -> Option { + e.storage().persistent().get(&DataKey::Royalty(token_id)) +} + +/// Offer expiration policy: the expiry boundary is exclusive. An offer is +/// valid while `now < expires_at`; at the exact expiry instant +/// (`now == expires_at`) the offer is already expired. This is the same +/// convention `place_bid` uses for `Auction::ends_at`, so all offer +/// lifecycle comparisons route through this single predicate. +fn is_offer_expired(now: u64, expires_at: u64) -> bool { + now >= expires_at +} + +fn calculate_sale_payouts( + e: &Env, + token_id: u32, + sale_amount: i128, +) -> Result<(i128, i128, i128, Option
), MarketplaceError> { + let fee_basis_points: u32 = e + .storage() + .instance() + .get(&DataKey::MarketplaceFee) + .unwrap_or(0); + let royalty = royalty_for(e, token_id); + let royalty_bps = royalty + .as_ref() + .map(|config| config.basis_points) + .unwrap_or(0); + if fee_basis_points > 10_000 { + return Err(MarketplaceError::PayoutExceedsSale); + } + if royalty_bps > MAX_ROYALTY_BASIS_POINTS { + return Err(MarketplaceError::RoyaltyTooHigh); + } + let fee_split = fee_invariants::split_bps(sale_amount, fee_basis_points).map_err(|error| { + match error { + fee_invariants::FeeError::InvalidRate => MarketplaceError::PayoutExceedsSale, + _ => MarketplaceError::PayoutOverflow, + } + })?; + let royalty_split = fee_invariants::split_bps(sale_amount, royalty_bps).map_err(|error| { + match error { + fee_invariants::FeeError::InvalidRate => MarketplaceError::RoyaltyTooHigh, + _ => MarketplaceError::PayoutOverflow, + } + })?; + let fee = fee_split.fee; + let royalty_amount = royalty_split.fee; + let deductions = fee + .checked_add(royalty_amount) + .ok_or(MarketplaceError::PayoutOverflow)?; + let seller_proceeds = sale_amount + .checked_sub(deductions) + .ok_or(MarketplaceError::PayoutOverflow)?; + if seller_proceeds < 0 { + return Err(MarketplaceError::PayoutExceedsSale); + } + Ok(( + seller_proceeds, + fee, + royalty_amount, + royalty.map(|config| config.recipient), + )) +} + +#[contractimpl] +impl CommitmentMarketplace { + // ======================================================================== + // Initialization + // ======================================================================== + + /// @notice Initialize the marketplace contract. + /// @param admin Admin address (must sign the transaction). + /// @param nft_contract Address of the CommitmentNFT contract. + /// @param fee_basis_points Marketplace fee in basis points (e.g., 250 = 2.5%). + /// @param fee_recipient Address to receive marketplace fees. + /// @dev Only callable once. Sets up admin, NFT contract, fee, and fee recipient. + /// @error MarketplaceError::AlreadyInitialized if already initialized. + /// @security Only callable by `admin` (require_auth). + pub fn initialize( + e: Env, + admin: Address, + nft_contract: Address, + fee_basis_points: u32, + fee_recipient: Address, + ) -> Result<(), MarketplaceError> { + if e.storage().instance().has(&DataKey::Admin) { + return Err(MarketplaceError::AlreadyInitialized); + } + if fee_basis_points > 10_000 { + return Err(MarketplaceError::PayoutExceedsSale); + } + + admin.require_auth(); + + e.storage().instance().set(&DataKey::Admin, &admin); + e.storage() + .instance() + .set(&DataKey::NFTContract, &nft_contract); + e.storage() + .instance() + .set(&DataKey::MarketplaceFee, &fee_basis_points); + e.storage() + .instance() + .set(&DataKey::FeeRecipient, &fee_recipient); + + let active_listings: Vec = Vec::new(&e); + e.storage() + .instance() + .set(&DataKey::ActiveListings, &active_listings); + + let active_auctions: Vec = Vec::new(&e); + e.storage() + .instance() + .set(&DataKey::ActiveAuctions, &active_auctions); + + let allowed_payment_tokens: Vec
= Vec::new(&e); + e.storage() + .instance() + .set(&DataKey::AllowedPaymentTokens, &allowed_payment_tokens); + e.storage().instance().set(&DataKey::Paused, &false); + + Ok(()) + } + + /// @notice Get the admin address for the marketplace. + /// @return admin Address of the admin. + /// @error MarketplaceError::NotInitialized if not initialized. + pub fn get_admin(e: Env) -> Result { + read_admin(&e) + } + + /// Nominate an administrator. The current administrator remains in + /// control until the nominee explicitly accepts the handover. + pub fn nominate_admin(e: Env, new_admin: Address) -> Result<(), MarketplaceError> { + let admin = read_admin(&e)?; + admin.require_auth(); + if new_admin == admin { + return Err(MarketplaceError::CannotNominateCurrentAdmin); + } + + e.storage() + .instance() + .set(&DataKey::PendingAdmin, &new_admin); + e.events() + .publish((symbol_short!("AdminNom"),), (admin, new_admin)); + Ok(()) + } + + /// Cancel a pending administrator handover. + pub fn cancel_admin_transfer(e: Env) -> Result<(), MarketplaceError> { + let admin = read_admin(&e)?; + admin.require_auth(); + if e.storage() + .instance() + .get::<_, Address>(&DataKey::PendingAdmin) + .is_none() + { + return Err(MarketplaceError::NoPendingAdmin); + } + + e.storage().instance().remove(&DataKey::PendingAdmin); + e.events().publish((symbol_short!("AdminCan"),), admin); + Ok(()) + } + + /// Accept the pending administrator role. Only the nominated address can + /// call this entry point, and authority changes atomically at acceptance. + pub fn accept_admin_transfer(e: Env, new_admin: Address) -> Result<(), MarketplaceError> { + let pending = e + .storage() + .instance() + .get::<_, Address>(&DataKey::PendingAdmin) + .ok_or(MarketplaceError::NoPendingAdmin)?; + if pending != new_admin { + return Err(MarketplaceError::NotPendingAdmin); + } + new_admin.require_auth(); + + let old_admin = read_admin(&e)?; + e.storage().instance().set(&DataKey::Admin, &new_admin); + e.storage().instance().remove(&DataKey::PendingAdmin); + e.events() + .publish((symbol_short!("AdminAcc"),), (old_admin, new_admin)); + Ok(()) + } + + /// Return the pending administrator, if a handover is awaiting acceptance. + pub fn get_pending_admin(e: Env) -> Option
{ + e.storage().instance().get(&DataKey::PendingAdmin) + } + + /// Pause risky marketplace mutations while preserving cancellation and + /// auction-ending recovery paths for already-created commitments. + pub fn pause(e: Env, caller: Address) -> Result<(), MarketplaceError> { + let admin = read_admin(&e)?; + caller.require_auth(); + if caller != admin { + return Err(MarketplaceError::Unauthorized); + } + if is_paused(&e) { + return Err(MarketplaceError::AlreadyPaused); + } + e.storage().instance().set(&DataKey::Paused, &true); + e.events() + .publish((symbol_short!("Pause"),), e.ledger().timestamp()); + Ok(()) + } + + /// Resume normal marketplace mutations after an emergency review. + pub fn unpause(e: Env, caller: Address) -> Result<(), MarketplaceError> { + let admin = read_admin(&e)?; + caller.require_auth(); + if caller != admin { + return Err(MarketplaceError::Unauthorized); + } + if !is_paused(&e) { + return Err(MarketplaceError::NotPaused); + } + e.storage().instance().set(&DataKey::Paused, &false); + e.events() + .publish((symbol_short!("Unpause"),), e.ledger().timestamp()); + Ok(()) + } + + /// Return whether the marketplace is in emergency pause mode. + pub fn is_paused(e: Env) -> bool { + is_paused(&e) + } + + /// @notice Update the marketplace fee (basis points). + /// @param fee_basis_points New fee in basis points. + /// @dev Only callable by admin. + /// @error MarketplaceError::NotInitialized if not initialized. + /// @security Only callable by `admin` (require_auth). + pub fn update_fee(e: Env, fee_basis_points: u32) -> Result<(), MarketplaceError> { + let admin: Address = Self::get_admin(e.clone())?; + admin.require_auth(); + if fee_basis_points > 10_000 { + return Err(MarketplaceError::PayoutExceedsSale); + } + require_not_paused(&e)?; + + e.storage() + .instance() + .set(&DataKey::MarketplaceFee, &fee_basis_points); + + e.events() + .publish((Symbol::new(&e, "FeeUpdated"),), fee_basis_points); + + Ok(()) + } + + /// Set or replace royalty configuration for an active listing. + /// Only the listing seller may authorize its recipient and basis points. + pub fn set_royalty( + e: Env, + seller: Address, + token_id: u32, + recipient: Address, + basis_points: u32, + ) -> Result<(), MarketplaceError> { + seller.require_auth(); + if basis_points > MAX_ROYALTY_BASIS_POINTS { + return Err(MarketplaceError::RoyaltyTooHigh); + } + let listing: Listing = e + .storage() + .persistent() + .get(&DataKey::Listing(token_id)) + .ok_or(MarketplaceError::ListingNotFound)?; + if listing.seller != seller { + return Err(MarketplaceError::UnauthorizedRoyaltyUpdate); + } + e.storage().persistent().set( + &DataKey::Royalty(token_id), + &RoyaltyConfig { + recipient, + basis_points, + }, + ); + e.events().publish( + (symbol_short!("RoyaltySet"), token_id), + (recipient, basis_points), + ); + Ok(()) + } + + /// Return the configured royalty for a token, if one exists. + pub fn get_royalty(e: Env, token_id: u32) -> Option { + royalty_for(&e, token_id) + } + + /// Add a token contract to the payment-token allowlist. + /// + /// # Arguments + /// * `payment_token` - Soroban token contract address approved for marketplace payments + /// + /// # Errors + /// * `MarketplaceError::NotInitialized` if the marketplace has not been initialized + /// + /// # Security Notes + /// Admin-only. All outbound token transfers in the marketplace trust this list. + pub fn add_payment_token(e: Env, payment_token: Address) -> Result<(), MarketplaceError> { + let admin = read_admin(&e)?; + admin.require_auth(); + require_not_paused(&e)?; + + if is_allowed_payment_token(&e, &payment_token) { + return Ok(()); + } + + e.storage() + .persistent() + .set(&DataKey::AllowedPaymentToken(payment_token.clone()), &true); + + let mut allowed_payment_tokens = read_allowed_payment_tokens(&e); + allowed_payment_tokens.push_back(payment_token.clone()); + write_allowed_payment_tokens(&e, &allowed_payment_tokens); + + e.events() + .publish((symbol_short!("TokenAdd"), payment_token.clone()), admin); + + Ok(()) + } + + /// Remove a token contract from the payment-token allowlist. + /// + /// # Arguments + /// * `payment_token` - Soroban token contract address to delist + /// + /// # Errors + /// * `MarketplaceError::NotInitialized` if the marketplace has not been initialized + /// + /// # Security Notes + /// Admin-only. Removing a token prevents new usage and also blocks settlement + /// for existing listings, offers, or auctions referencing the removed token + /// until it is allowlisted again. + pub fn remove_payment_token(e: Env, payment_token: Address) -> Result<(), MarketplaceError> { + let admin = read_admin(&e)?; + admin.require_auth(); + require_not_paused(&e)?; + + e.storage() + .persistent() + .remove(&DataKey::AllowedPaymentToken(payment_token.clone())); + + let mut allowed_payment_tokens = read_allowed_payment_tokens(&e); + if let Some(index) = allowed_payment_tokens + .iter() + .position(|token| token == payment_token) + { + allowed_payment_tokens.remove(index as u32); + write_allowed_payment_tokens(&e, &allowed_payment_tokens); + + e.events() + .publish((symbol_short!("TokenRem"), payment_token.clone()), admin); + } + + Ok(()) + } + + /// Return true when a payment token is currently allowlisted. + pub fn is_payment_token_allowed(e: Env, payment_token: Address) -> bool { + is_allowed_payment_token(&e, &payment_token) + } + + /// Return the full set of allowlisted payment tokens. + pub fn get_allowed_payment_tokens(e: Env) -> Vec
{ + read_allowed_payment_tokens(&e) + } + + // ======================================================================== + // Listing Management + // ======================================================================== + + /// @notice List an NFT for sale on the marketplace. + /// @param seller Seller's address (must be NFT owner and sign the transaction). + /// @param token_id NFT token ID to list. + /// @param price Sale price (must be > 0). + /// @param payment_token Token contract address for payment. + /// @dev Reentrancy guard enforced. No cross-contract NFT ownership check in this implementation. + /// @error MarketplaceError::InvalidPrice if price <= 0. + /// @error MarketplaceError::ListingExists if listing already exists. + /// @error MarketplaceError::NotInitialized if contract not initialized. + /// @security Only callable by `seller` (require_auth). + pub fn list_nft( + e: Env, + seller: Address, + token_id: u32, + price: i128, + payment_token: Address, + ) -> Result<(), MarketplaceError> { + require_not_paused(&e)?; + // Reentrancy protection + let guard: bool = e + .storage() + .instance() + .get(&DataKey::ReentrancyGuard) + .unwrap_or(false); + if guard { + return Err(MarketplaceError::ReentrancyDetected); + } + e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + + // CHECKS + seller.require_auth(); + + if price <= 0 { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::InvalidPrice); + } + + if let Err(err) = require_allowed_payment_token(&e, &payment_token) { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(err); + } + + // Check if listing already exists + if e.storage().persistent().has(&DataKey::Listing(token_id)) { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::ListingExists); + } + + // Verify seller owns the NFT (external call - after checks) + let _nft_contract: Address = e + .storage() + .instance() + .get(&DataKey::NFTContract) + .ok_or_else(|| { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + MarketplaceError::NotInitialized + })?; + + // Note: This would require the NFT contract client + // For now, we assume the caller has verified ownership + // In production, you'd call: nft_contract.owner_of(&token_id) + + // EFFECTS + let listing = Listing { + token_id, + seller: seller.clone(), + price, + payment_token: payment_token.clone(), + listed_at: e.ledger().timestamp(), + }; + + e.storage() + .persistent() + .set(&DataKey::Listing(token_id), &listing); + + // Add to active listings + let mut active_listings: Vec = e + .storage() + .instance() + .get(&DataKey::ActiveListings) + .unwrap_or(Vec::new(&e)); + active_listings.push_back(token_id); + e.storage() + .instance() + .set(&DataKey::ActiveListings, &active_listings); + + // Clear reentrancy guard + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + + // Emit event + e.events().publish( + (symbol_short!("ListNFT"), token_id), + (seller, price, payment_token), + ); + + Ok(()) + } + + /// @notice Cancel an active NFT listing. + /// @param seller Seller's address (must sign the transaction). + /// @param token_id NFT token ID to cancel listing for. + /// @dev Reentrancy guard enforced. Checks-effects-interactions pattern. + /// @error MarketplaceError::ListingNotFound if listing does not exist. + /// @error MarketplaceError::NotSeller if caller is not the seller. + /// @security Only callable by `seller` (require_auth). + pub fn cancel_listing(e: Env, seller: Address, token_id: u32) -> Result<(), MarketplaceError> { + // Reentrancy protection + let guard: bool = e + .storage() + .instance() + .get(&DataKey::ReentrancyGuard) + .unwrap_or(false); + if guard { + return Err(MarketplaceError::ReentrancyDetected); + } + e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + + // CHECKS + seller.require_auth(); + + let listing: Listing = e + .storage() + .persistent() + .get(&DataKey::Listing(token_id)) + .ok_or_else(|| { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + MarketplaceError::ListingNotFound + })?; + + if listing.seller != seller { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::NotSeller); + } + + // EFFECTS + // Remove listing + e.storage().persistent().remove(&DataKey::Listing(token_id)); + e.storage().persistent().remove(&DataKey::Royalty(token_id)); + + // Remove from active listings + let mut active_listings: Vec = e + .storage() + .instance() + .get(&DataKey::ActiveListings) + .unwrap_or(Vec::new(&e)); + if let Some(index) = active_listings.iter().position(|id| id == token_id) { + active_listings.remove(index as u32); + } + e.storage() + .instance() + .set(&DataKey::ActiveListings, &active_listings); + + // Clear reentrancy guard + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + + // Emit event + e.events() + .publish((symbol_short!("ListCncl"), token_id), seller); + + Ok(()) + } + + /// @notice Buy an NFT from an active listing. + /// @param buyer Buyer's address (must sign the transaction). + /// @param token_id NFT token ID to buy. + /// @dev Reentrancy guard enforced. Handles token transfers. No cross-contract NFT transfer in this implementation. + /// @error MarketplaceError::ListingNotFound if listing does not exist. + /// @error MarketplaceError::CannotBuyOwnListing if buyer is seller. + /// @error MarketplaceError::NotInitialized if contract not initialized. + /// @security Only callable by `buyer` (require_auth). + pub fn buy_nft(e: Env, buyer: Address, token_id: u32) -> Result<(), MarketplaceError> { + require_not_paused(&e)?; + // Reentrancy protection + let guard: bool = e + .storage() + .instance() + .get(&DataKey::ReentrancyGuard) + .unwrap_or(false); + if guard { + return Err(MarketplaceError::ReentrancyDetected); + } + e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + + // CHECKS + buyer.require_auth(); + + let listing: Listing = e + .storage() + .persistent() + .get(&DataKey::Listing(token_id)) + .ok_or_else(|| { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + MarketplaceError::ListingNotFound + })?; + + if listing.seller == buyer { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::CannotBuyOwnListing); + } + + if let Err(err) = require_allowed_payment_token(&e, &listing.payment_token) { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(err); + } + + let fee_recipient: Address = e + .storage() + .instance() + .get(&DataKey::FeeRecipient) + .ok_or_else(|| { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + MarketplaceError::NotInitialized + })?; + + let _nft_contract: Address = e + .storage() + .instance() + .get(&DataKey::NFTContract) + .ok_or_else(|| { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + MarketplaceError::NotInitialized + })?; + + // Calculate seller, fee, and royalty amounts before changing state. + let (seller_proceeds, marketplace_fee, royalty_amount, royalty_recipient) = + calculate_sale_payouts(&e, token_id, listing.price)?; + + // EFFECTS + // Remove listing first (prevent reentrancy) + e.storage().persistent().remove(&DataKey::Listing(token_id)); + e.storage().persistent().remove(&DataKey::Royalty(token_id)); + + // Remove from active listings + let mut active_listings: Vec = e + .storage() + .instance() + .get(&DataKey::ActiveListings) + .unwrap_or(Vec::new(&e)); + if let Some(index) = active_listings.iter().position(|id| id == token_id) { + active_listings.remove(index as u32); + } + e.storage() + .instance() + .set(&DataKey::ActiveListings, &active_listings); + + // INTERACTIONS - External calls AFTER state changes + // Transfer payment token from buyer to seller + let payment_token_client = token::Client::new(&e, &listing.payment_token); + payment_token_client.transfer(&buyer, &listing.seller, &seller_proceeds); + + // Transfer marketplace fee if applicable + if marketplace_fee > 0 { + payment_token_client.transfer(&buyer, &fee_recipient, &marketplace_fee); + } + + if royalty_amount > 0 { + if let Some(recipient) = royalty_recipient { + payment_token_client.transfer(&buyer, &recipient, &royalty_amount); + } + } + + // Transfer NFT from seller to buyer + // Note: In production, you'd use the NFT contract client: + // let nft_client = CommitmentNFTContractClient::new(&e, &nft_contract); + // nft_client.transfer(&listing.seller, &buyer, &token_id); + // For this implementation, we assume the transfer happens correctly + + // Clear reentrancy guard + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + + // Emit event + e.events().publish( + (symbol_short!("NFTSold"), token_id), + (listing.seller, buyer, listing.price), + ); + + Ok(()) + } + + /// @notice Get details of a specific NFT listing. + /// @param token_id NFT token ID. + /// @return Listing struct. + /// @error MarketplaceError::ListingNotFound if listing does not exist. + pub fn get_listing(e: Env, token_id: u32) -> Result { + e.storage() + .persistent() + .get(&DataKey::Listing(token_id)) + .ok_or(MarketplaceError::ListingNotFound) + } + + /// @notice Get all active NFT listings. + /// @return Vec of all active listings. + pub fn get_all_listings(e: Env) -> Vec { + let active_listings: Vec = e + .storage() + .instance() + .get(&DataKey::ActiveListings) + .unwrap_or(Vec::new(&e)); + + let mut listings: Vec = Vec::new(&e); + + for token_id in active_listings.iter() { + if let Some(listing) = e + .storage() + .persistent() + .get::<_, Listing>(&DataKey::Listing(token_id)) + { + listings.push_back(listing); + } + } + + listings + } + + // ======================================================================== + // Offer System + // ======================================================================== + + /// @notice Make an offer on an NFT. The offer expires at + /// `ledger.timestamp() + duration` (checked arithmetic); the boundary is + /// exclusive — valid only while `ledger.timestamp() < expires_at`. + /// @param offerer Offer maker's address (must sign the transaction). + /// @param token_id NFT token ID to make offer on. + /// @param amount Offer amount (must be > 0). + /// @param payment_token Token contract address for payment. + /// @param duration Offer lifetime in ledger seconds; must be > 0. An + /// overflow of `created_at + duration` is rejected with InvalidDuration. + /// @dev Reentrancy guard enforced. If `offerer` already has a live offer + /// for this token the call fails; if that stored offer is expired the new + /// offer replaces it in place (the documented update path). + /// @error MarketplaceError::InvalidOfferAmount if amount <= 0. + /// @error MarketplaceError::InvalidDuration if duration == 0 or + /// `created_at + duration` overflows. + /// @error MarketplaceError::OfferExists if offerer already has a + /// non-expired offer. + /// @security Only callable by `offerer` (require_auth). + pub fn make_offer( + e: Env, + offerer: Address, + token_id: u32, + amount: i128, + payment_token: Address, + duration: u64, + ) -> Result<(), MarketplaceError> { + require_not_paused(&e)?; + // Reentrancy protection + let guard: bool = e + .storage() + .instance() + .get(&DataKey::ReentrancyGuard) + .unwrap_or(false); + if guard { + return Err(MarketplaceError::ReentrancyDetected); + } + e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + + // CHECKS + offerer.require_auth(); + + if amount <= 0 { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::InvalidOfferAmount); + } + + if duration == 0 { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::InvalidDuration); + } + + if let Err(err) = require_allowed_payment_token(&e, &payment_token) { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(err); + } + + if let Some(listing) = e + .storage() + .persistent() + .get::<_, Listing>(&DataKey::Listing(token_id)) + { + if listing.seller == offerer { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::CannotBuyOwnListing); + } + } + + if let Some(auction) = e + .storage() + .persistent() + .get::<_, Auction>(&DataKey::Auction(token_id)) + { + if auction.seller == offerer { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::CannotBuyOwnListing); + } + } + + // EFFECTS + let created_at = e.ledger().timestamp(); + let expires_at = created_at.checked_add(duration).ok_or_else(|| { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + MarketplaceError::InvalidDuration + })?; + + let offer = Offer { + token_id, + offerer: offerer.clone(), + amount, + payment_token: payment_token.clone(), + created_at, + expires_at, + }; + + let mut offers: Vec = e + .storage() + .persistent() + .get(&DataKey::Offers(token_id)) + .unwrap_or(Vec::new(&e)); + + // A live offer from the same offerer cannot be mutated; an expired one + // is replaced by this fresh offer (the documented update path). + let mut replace_at: Option = None; + for (i, existing_offer) in offers.iter().enumerate() { + if existing_offer.offerer == offerer { + if is_offer_expired(created_at, existing_offer.expires_at) { + replace_at = Some(i as u32); + break; + } + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::OfferExists); + } + } + + match replace_at { + Some(i) => offers.set(i, offer), + None => offers.push_back(offer), + } + e.storage() + .persistent() + .set(&DataKey::Offers(token_id), &offers); + + // Clear reentrancy guard + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + + // Emit event with the effective expiry + e.events().publish( + (symbol_short!("OfferMade"), token_id), + (offerer, amount, payment_token, expires_at), + ); + + Ok(()) + } + + /// @notice Accept an offer on an NFT. + /// @param seller Seller's address (must sign the transaction). + /// @param token_id NFT token ID. + /// @param offerer Address of the offer maker. + /// @dev Reentrancy guard enforced. Handles token transfers. No cross-contract NFT transfer in this implementation. + /// @error MarketplaceError::OfferNotFound if offer does not exist. + /// @error MarketplaceError::OfferExpired if the offer's exclusive expiry + /// boundary has been reached (`ledger.timestamp() >= expires_at`). + /// @error MarketplaceError::NotInitialized if contract not initialized. + /// @security Only callable by `seller` (require_auth). + pub fn accept_offer( + e: Env, + seller: Address, + token_id: u32, + offerer: Address, + ) -> Result<(), MarketplaceError> { + require_not_paused(&e)?; + // Reentrancy protection + let guard: bool = e + .storage() + .instance() + .get(&DataKey::ReentrancyGuard) + .unwrap_or(false); + if guard { + return Err(MarketplaceError::ReentrancyDetected); + } + e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + + // CHECKS + seller.require_auth(); + + if seller == offerer { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::CannotBuyOwnListing); + } + + let offers: Vec = e + .storage() + .persistent() + .get(&DataKey::Offers(token_id)) + .ok_or_else(|| { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + MarketplaceError::OfferNotFound + })?; + + // Find the offer + let offer_index = offers + .iter() + .position(|o| o.offerer == offerer) + .ok_or_else(|| { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + MarketplaceError::OfferNotFound + })?; + + let offer = offers.get(offer_index as u32).unwrap(); + + // An offer at or past its expiry boundary cannot be accepted. The + // stored offer is left in place: the offerer may cancel it or replace + // it via a fresh make_offer. + if is_offer_expired(e.ledger().timestamp(), offer.expires_at) { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::OfferExpired); + } + + let fee_recipient: Address = e + .storage() + .instance() + .get(&DataKey::FeeRecipient) + .ok_or_else(|| { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + MarketplaceError::NotInitialized + })?; + + if let Err(err) = require_allowed_payment_token(&e, &offer.payment_token) { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(err); + } + + // Calculate fee, royalty, and seller proceeds before changing state. + let (seller_proceeds, marketplace_fee, royalty_amount, royalty_recipient) = + calculate_sale_payouts(&e, token_id, offer.amount)?; + + // EFFECTS + // Remove all offers for this token + e.storage().persistent().remove(&DataKey::Offers(token_id)); + + // Remove listing if exists + if e.storage().persistent().has(&DataKey::Listing(token_id)) { + e.storage().persistent().remove(&DataKey::Listing(token_id)); + e.storage().persistent().remove(&DataKey::Royalty(token_id)); + + let mut active_listings: Vec = e + .storage() + .instance() + .get(&DataKey::ActiveListings) + .unwrap_or(Vec::new(&e)); + if let Some(index) = active_listings.iter().position(|id| id == token_id) { + active_listings.remove(index as u32); + } + e.storage() + .instance() + .set(&DataKey::ActiveListings, &active_listings); + } + + // INTERACTIONS + // Transfer payment + let payment_token_client = token::Client::new(&e, &offer.payment_token); + payment_token_client.transfer(&offerer, &seller, &seller_proceeds); + + if marketplace_fee > 0 { + payment_token_client.transfer(&offerer, &fee_recipient, &marketplace_fee); + } + + if royalty_amount > 0 { + if let Some(recipient) = royalty_recipient { + payment_token_client.transfer(&offerer, &recipient, &royalty_amount); + } + } + + // Transfer NFT + // Note: Use NFT contract client in production + + // Clear reentrancy guard + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + + // Emit event + e.events().publish( + (symbol_short!("OffAccpt"), token_id), + (seller, offerer, offer.amount), + ); + + Ok(()) + } + + /// @notice Cancel an offer made on an NFT. Cancellation is always safe: + /// it is allowed before, at, or after the offer's expiry boundary and is + /// also the manual cleanup path for expired offers. + /// @param offerer Offer maker's address (must sign the transaction). + /// @param token_id NFT token ID. + /// @error MarketplaceError::OfferNotFound if offer does not exist. + /// @security Only callable by `offerer` (require_auth). + pub fn cancel_offer(e: Env, offerer: Address, token_id: u32) -> Result<(), MarketplaceError> { + offerer.require_auth(); + + let mut offers: Vec = e + .storage() + .persistent() + .get(&DataKey::Offers(token_id)) + .ok_or(MarketplaceError::OfferNotFound)?; + + let offer_index = offers + .iter() + .position(|o| o.offerer == offerer) + .ok_or(MarketplaceError::OfferNotFound)?; + + offers.remove(offer_index as u32); + + if offers.is_empty() { + e.storage().persistent().remove(&DataKey::Offers(token_id)); + } else { + e.storage() + .persistent() + .set(&DataKey::Offers(token_id), &offers); + } + + e.events() + .publish((symbol_short!("OfferCanc"), token_id), offerer); + + Ok(()) + } + + /// @notice Get all stored offers for a specific NFT token, including + /// expired ones. Callers determine liveness by comparing each offer's + /// `expires_at` against the ledger timestamp (exclusive boundary). + /// @param token_id NFT token ID. + /// @return Vec of all offers for the token. + pub fn get_offers(e: Env, token_id: u32) -> Vec { + e.storage() + .persistent() + .get(&DataKey::Offers(token_id)) + .unwrap_or(Vec::new(&e)) + } + + // ======================================================================== + // Auction System + // ======================================================================== + + /// @notice Start an auction for an NFT. + /// @param seller Seller's address (must sign the transaction). + /// @param token_id NFT token ID. + /// @param starting_price Starting price for the auction (must be > 0). + /// @param duration_seconds Duration of the auction in seconds (must be > 0). + /// @param payment_token Token contract address for payment. + /// @dev Reentrancy guard enforced. + /// @error MarketplaceError::InvalidPrice if starting price <= 0. + /// @error MarketplaceError::InvalidDuration if duration is 0. + /// @error MarketplaceError::ListingExists if auction already exists for token. + /// @security Only callable by `seller` (require_auth). + pub fn start_auction( + e: Env, + seller: Address, + token_id: u32, + starting_price: i128, + duration_seconds: u64, + payment_token: Address, + ) -> Result<(), MarketplaceError> { + require_not_paused(&e)?; + // Reentrancy protection + let guard: bool = e + .storage() + .instance() + .get(&DataKey::ReentrancyGuard) + .unwrap_or(false); + if guard { + return Err(MarketplaceError::ReentrancyDetected); + } + e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + + // CHECKS + seller.require_auth(); + + if starting_price <= 0 { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::InvalidPrice); + } + + if duration_seconds == 0 { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::InvalidDuration); + } + + if let Err(err) = require_allowed_payment_token(&e, &payment_token) { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(err); + } + + if e.storage().persistent().has(&DataKey::Auction(token_id)) { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::ListingExists); + } + + // EFFECTS + let started_at = e.ledger().timestamp(); + let ends_at = started_at.checked_add(duration_seconds).ok_or_else(|| { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + MarketplaceError::InvalidDuration + })?; + + let auction = Auction { + token_id, + seller: seller.clone(), + starting_price, + current_bid: starting_price, + highest_bidder: None, + payment_token: payment_token.clone(), + started_at, + ends_at, + ended: false, + }; + + e.storage() + .persistent() + .set(&DataKey::Auction(token_id), &auction); + + let mut active_auctions: Vec = e + .storage() + .instance() + .get(&DataKey::ActiveAuctions) + .unwrap_or(Vec::new(&e)); + active_auctions.push_back(token_id); + e.storage() + .instance() + .set(&DataKey::ActiveAuctions, &active_auctions); + + // Clear reentrancy guard + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + + // Emit event + e.events().publish( + (symbol_short!("AucStart"), token_id), + (seller, starting_price, ends_at), + ); + + Ok(()) + } + + /// @notice Place a bid on an active auction. + /// @param bidder Bidder's address (must sign the transaction). + /// @param token_id NFT token ID. + /// @param bid_amount Amount of the bid (must be > current bid). + /// @dev Reentrancy guard enforced. Handles token transfers for bid refunds. + /// @error MarketplaceError::AuctionEnded if auction has ended. + /// @error MarketplaceError::BidTooLow if bid is not higher than current bid. + /// @error MarketplaceError::CannotBuyOwnListing if seller tries to bid. + /// @security Only callable by `bidder` (require_auth). + pub fn place_bid( + e: Env, + bidder: Address, + token_id: u32, + bid_amount: i128, + ) -> Result<(), MarketplaceError> { + require_not_paused(&e)?; + // Reentrancy protection + let guard: bool = e + .storage() + .instance() + .get(&DataKey::ReentrancyGuard) + .unwrap_or(false); + if guard { + return Err(MarketplaceError::ReentrancyDetected); + } + e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + + // CHECKS + bidder.require_auth(); + + let mut auction: Auction = e + .storage() + .persistent() + .get(&DataKey::Auction(token_id)) + .ok_or_else(|| { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + MarketplaceError::AuctionNotFound + })?; + + let current_time = e.ledger().timestamp(); + if current_time >= auction.ends_at { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::AuctionEnded); + } + + if bid_amount <= auction.current_bid { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::BidTooLow); + } + + if auction.seller == bidder { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::CannotBuyOwnListing); + } + + if let Err(err) = require_allowed_payment_token(&e, &auction.payment_token) { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(err); + } + + // EFFECTS + let previous_bidder = auction.highest_bidder.clone(); + let previous_bid = auction.current_bid; + + auction.current_bid = bid_amount; + auction.highest_bidder = Some(bidder.clone()); + + e.storage() + .persistent() + .set(&DataKey::Auction(token_id), &auction); + + // INTERACTIONS + let payment_token_client = token::Client::new(&e, &auction.payment_token); + + // Transfer new bid from bidder to contract (escrow) + payment_token_client.transfer(&bidder, &e.current_contract_address(), &bid_amount); + + // Refund previous bidder if exists + if let Some(prev_bidder) = previous_bidder { + payment_token_client.transfer( + &e.current_contract_address(), + &prev_bidder, + &previous_bid, + ); + } + + // Clear reentrancy guard + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + + // Emit event + e.events() + .publish((symbol_short!("BidPlaced"), token_id), (bidder, bid_amount)); + + Ok(()) + } + + /// @notice End an auction and settle payment/NFT transfer. + /// @param token_id NFT token ID. + /// @dev Reentrancy guard enforced. Handles final settlement. Anyone can call after auction ends. + /// @error MarketplaceError::AuctionNotFound if auction does not exist. + /// @error MarketplaceError::AuctionNotEnded if auction has not ended yet. + /// @error MarketplaceError::AuctionEnded if auction already ended. + pub fn end_auction(e: Env, token_id: u32) -> Result<(), MarketplaceError> { + // Reentrancy protection + let guard: bool = e + .storage() + .instance() + .get(&DataKey::ReentrancyGuard) + .unwrap_or(false); + if guard { + return Err(MarketplaceError::ReentrancyDetected); + } + e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + + // CHECKS + let mut auction: Auction = e + .storage() + .persistent() + .get(&DataKey::Auction(token_id)) + .ok_or_else(|| { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + MarketplaceError::AuctionNotFound + })?; + + let current_time = e.ledger().timestamp(); + if current_time < auction.ends_at { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::AuctionNotEnded); + } + + if auction.ended { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::AuctionEnded); + } + + let fee_recipient: Address = e + .storage() + .instance() + .get(&DataKey::FeeRecipient) + .ok_or_else(|| { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + MarketplaceError::NotInitialized + })?; + + if auction.highest_bidder.is_some() { + if let Err(err) = require_allowed_payment_token(&e, &auction.payment_token) { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(err); + } + } + + // EFFECTS + auction.ended = true; + e.storage() + .persistent() + .set(&DataKey::Auction(token_id), &auction); + + // Remove from active auctions + let mut active_auctions: Vec = e + .storage() + .instance() + .get(&DataKey::ActiveAuctions) + .unwrap_or(Vec::new(&e)); + if let Some(index) = active_auctions.iter().position(|id| id == token_id) { + active_auctions.remove(index as u32); + } + e.storage() + .instance() + .set(&DataKey::ActiveAuctions, &active_auctions); + + // INTERACTIONS + if let Some(winner) = auction.highest_bidder { + let (seller_proceeds, marketplace_fee, royalty_amount, royalty_recipient) = + calculate_sale_payouts(&e, token_id, auction.current_bid)?; + + let payment_token_client = token::Client::new(&e, &auction.payment_token); + + // Transfer payment from escrow to seller + payment_token_client.transfer( + &e.current_contract_address(), + &auction.seller, + &seller_proceeds, + ); + + // Transfer fee + if marketplace_fee > 0 { + payment_token_client.transfer( + &e.current_contract_address(), + &fee_recipient, + &marketplace_fee, + ); + } + + if royalty_amount > 0 { + if let Some(recipient) = royalty_recipient { + payment_token_client.transfer( + &e.current_contract_address(), + &recipient, + &royalty_amount, + ); + } + } + + e.storage().persistent().remove(&DataKey::Royalty(token_id)); + + // Transfer NFT to winner + // Note: Use NFT contract client in production + + // Clear reentrancy guard + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + + // Emit event + e.events().publish( + (symbol_short!("AucEnd"), token_id), + (winner, auction.current_bid), + ); + } else { + // No bids - return NFT to seller + e.storage().persistent().remove(&DataKey::Royalty(token_id)); + + // Clear reentrancy guard + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + + e.events() + .publish((symbol_short!("AucNoBid"), token_id), auction.seller); + } + + Ok(()) + } + + /// @notice Get details of a specific auction. + /// @param token_id NFT token ID. + /// @return Auction struct. + /// @error MarketplaceError::AuctionNotFound if auction does not exist. + pub fn get_auction(e: Env, token_id: u32) -> Result { + e.storage() + .persistent() + .get(&DataKey::Auction(token_id)) + .ok_or(MarketplaceError::AuctionNotFound) + } + + /// @notice Get all active auctions. + /// @return Vec of all active auctions. + pub fn get_all_auctions(e: Env) -> Vec { + let active_auctions: Vec = e + .storage() + .instance() + .get(&DataKey::ActiveAuctions) + .unwrap_or(Vec::new(&e)); + + let mut auctions: Vec = Vec::new(&e); + + for token_id in active_auctions.iter() { + if let Some(auction) = e + .storage() + .persistent() + .get::<_, Auction>(&DataKey::Auction(token_id)) + { + auctions.push_back(auction); + } + } + + auctions + } +} diff --git a/contracts/commitment_marketplace/src/tests.rs b/contracts/commitment_marketplace/src/tests.rs index 728c5d0..a8c23a0 100644 --- a/contracts/commitment_marketplace/src/tests.rs +++ b/contracts/commitment_marketplace/src/tests.rs @@ -1,1870 +1,1996 @@ -//! # Commitment Marketplace Contract Tests -//! -//! Unit tests for the CommitmentMarketplace Soroban contract. -//! -//! ## Coverage -//! - Initialization, listing, offers, auctions, and reentrancy guard. -//! - Edge cases and error conditions. -//! -//! ## Security -//! - Explicit tests for reentrancy guard on all entry points. -//! - All state-changing entry points require authentication. -//! -//! ## Usage -//! Run with `cargo test -p commitment-marketplace` from the workspace root. - -#![cfg(test)] - -extern crate std; - -use crate::*; -use soroban_sdk::{ - symbol_short, - testutils::{Address as _, Events, Ledger}, - vec, Address, Env, IntoVal, -}; - -// ============================================================================ -// Test Setup Helpers -// ============================================================================ - -/// @notice Helper to deploy and initialize the marketplace contract for tests. -/// @param e Test environment. -/// @return (admin, fee_recipient, client) -fn setup_marketplace(e: &Env) -> (Address, Address, CommitmentMarketplaceClient<'_>) { - let admin = Address::generate(e); - let nft_contract = Address::generate(e); - let fee_recipient = Address::generate(e); - - // Use register_contract for Soroban SDK - let marketplace_id = e.register_contract(None, CommitmentMarketplace); - let client = CommitmentMarketplaceClient::new(e, &marketplace_id); - - client.initialize(&admin, &nft_contract, &250, &fee_recipient); // 2.5% fee - - (admin, fee_recipient, client) -} - -/// @notice Helper to generate a test token address and allowlist it. -fn setup_test_token(e: &Env, client: &CommitmentMarketplaceClient<'_>) -> Address { - setup_allowed_payment_token(e, client) -} - -fn set_contract_reentrancy_guard(e: &Env, contract: &Address, active: bool) { - e.as_contract(contract, || { - e.storage() - .instance() - .set(&DataKey::ReentrancyGuard, &active); - }); -} - -fn setup_allowed_payment_token(e: &Env, client: &CommitmentMarketplaceClient<'_>) -> Address { - let payment_token = Address::generate(e); - client.add_payment_token(&payment_token); - payment_token -} - -// ============================================================================ -// Initialization Tests -// ============================================================================ - -#[test] -fn test_initialize_marketplace() { - let e = Env::default(); - e.mock_all_auths(); - - let admin = Address::generate(&e); - let nft_contract = Address::generate(&e); - let fee_recipient = Address::generate(&e); - - let marketplace_id = e.register_contract(None, CommitmentMarketplace); - let client = CommitmentMarketplaceClient::new(&e, &marketplace_id); - - client.initialize(&admin, &nft_contract, &250, &fee_recipient); - - assert_eq!(client.get_admin(), admin); -} - -#[test] -#[should_panic(expected = "Error(Contract, #2)")] // AlreadyInitialized -fn test_initialize_twice_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_admin, _, client) = setup_marketplace(&e); - let nft_contract = Address::generate(&e); - let fee_recipient = Address::generate(&e); - let new_admin = Address::generate(&e); - - client.initialize(&new_admin, &nft_contract, &250, &fee_recipient); -} - -#[test] -fn test_update_fee() { - let e = Env::default(); - e.mock_all_auths(); - - let (_admin, _, client) = setup_marketplace(&e); - - client.update_fee(&500); // Update to 5% - - // Verify event - let events = e.events().all(); - let last_event = events.last().unwrap(); - - assert_eq!(last_event.0, client.address); -} - -#[test] -fn test_admin_rotation_requires_nominee_acceptance() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - let nominee = Address::generate(&e); - - client.nominate_admin(&nominee); - assert_eq!(client.get_admin(), admin); - assert_eq!(client.get_pending_admin(), Some(nominee.clone())); - - client.accept_admin_transfer(&nominee); - assert_eq!(client.get_admin(), nominee); - assert_eq!(client.get_pending_admin(), None); -} - -#[test] -fn test_admin_rotation_replacement_and_cancellation() { - let e = Env::default(); - e.mock_all_auths(); - let (_admin, _, client) = setup_marketplace(&e); - let first = Address::generate(&e); - let replacement = Address::generate(&e); - - client.nominate_admin(&first); - client.nominate_admin(&replacement); - assert_eq!(client.get_pending_admin(), Some(replacement)); - client.cancel_admin_transfer(); - assert_eq!(client.get_pending_admin(), None); -} - -#[test] -#[should_panic(expected = "Error(Contract, #32)")] -fn test_non_nominee_cannot_accept_admin_rotation() { - let e = Env::default(); - e.mock_all_auths(); - let (_admin, _, client) = setup_marketplace(&e); - let nominee = Address::generate(&e); - let attacker = Address::generate(&e); - - client.nominate_admin(&nominee); - client.accept_admin_transfer(&attacker); -} - -// ============================================================================ -// Listing Tests -// ============================================================================ - -#[test] -#[should_panic(expected = "Error(Contract, #6)")] // InvalidPrice -fn test_list_nft_zero_price_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - - client.list_nft(&seller, &1, &0, &payment_token); -} - -#[test] -#[should_panic(expected = "Error(Contract, #7)")] // ListingExists -fn test_list_nft_twice_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, 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.list_nft(&seller, &1, &2000, &payment_token); // Should fail -} - -#[test] -fn test_cancel_listing() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - let token_id = 1u32; - - client.list_nft(&seller, &token_id, &1000, &payment_token); - client.cancel_listing(&seller, &token_id); - - // Verify event - let events = e.events().all(); - let last_event = events.last().unwrap(); - - assert_eq!( - last_event.1, - vec![ - &e, - symbol_short!("ListCncl").into_val(&e), - token_id.into_val(&e) - ] - ); -} - -#[test] -#[should_panic(expected = "Error(Contract, #3)")] // ListingNotFound -fn test_get_listing_after_cancel_panics() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let token_id = 1u32; - - let payment_token = setup_allowed_payment_token(&e, &client); - client.list_nft(&seller, &token_id, &1000, &payment_token); - client.cancel_listing(&seller, &token_id); - - // This will panic as expected - client.get_listing(&token_id); -} - -#[test] -#[should_panic(expected = "Error(Contract, #3)")] // ListingNotFound -fn test_cancel_nonexistent_listing_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - client.cancel_listing(&seller, &999); -} - -#[test] -#[should_panic(expected = "Error(Contract, #4)")] // NotSeller -fn test_cancel_listing_not_seller_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let not_seller = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - - client.list_nft(&seller, &1, &1000, &payment_token); - client.cancel_listing(¬_seller, &1); // Should fail -} - -#[test] -fn test_get_all_listings() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - - // List 3 NFTs - client.list_nft(&seller, &1, &1000, &payment_token); - client.list_nft(&seller, &2, &2000, &payment_token); - client.list_nft(&seller, &3, &3000, &payment_token); - - let listings = client.get_all_listings(); - assert_eq!(listings.len(), 3); -} - -// ============================================================================ -// Buy Tests (Note: These are simplified - real tests need token contract) -// ============================================================================ - -#[test] -fn test_buy_nft_flow() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let _buyer = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - let token_id = 1u32; - let price = 1000_0000000i128; - - // List NFT - client.list_nft(&seller, &token_id, &price, &payment_token); - - // Note: In a real test, you'd need to: - // 1. Deploy a test token contract - // 2. Mint tokens to the buyer - // 3. Have buyer approve marketplace to spend tokens - // 4. Call buy_nft - // 5. Verify token and NFT transfers - - // For this example, we're testing the flow logic only - // Uncomment when you have token contract set up: - // client.buy_nft(&buyer, &token_id); - - // Verify listing is removed - // let result = client.try_get_listing(&token_id); - // assert!(result.is_err()); -} - -#[test] -#[should_panic(expected = "Error(Contract, #8)")] // CannotBuyOwnListing -fn test_buy_own_listing_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, 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.buy_nft(&seller, &1); // Seller trying to buy their own listing -} - -// ============================================================================ -// Offer System Tests -// ============================================================================ - -#[test] -#[should_panic(expected = "Error(Contract, #12)")] // InvalidOfferAmount -fn test_make_offer_zero_amount_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let offerer = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - - client.make_offer(&offerer, &1, &0, &payment_token); -} - -#[test] -#[should_panic(expected = "Error(Contract, #13)")] // OfferExists -fn test_make_duplicate_offer_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let offerer = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - - client.make_offer(&offerer, &1, &500, &payment_token); - client.make_offer(&offerer, &1, &600, &payment_token); // Should fail -} - -#[test] -#[should_panic(expected = "Error(Contract, #8)")] // CannotBuyOwnListing -fn test_make_offer_own_listing_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - - client.list_nft(&seller, &1, &1000, &payment_token); - client.make_offer(&seller, &1, &800, &payment_token); // Seller making offer on own listing -} - -#[test] -#[should_panic(expected = "Error(Contract, #8)")] // CannotBuyOwnListing -fn test_make_offer_own_auction_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - - client.start_auction(&seller, &1, &1000, &86400, &payment_token); - client.make_offer(&seller, &1, &1100, &payment_token); // Seller making offer on own auction -} - -#[test] -#[should_panic(expected = "Error(Contract, #8)")] // CannotBuyOwnListing -fn test_accept_offer_own_listing_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - - client.make_offer(&seller, &1, &1000, &payment_token); - client.accept_offer(&seller, &1, &seller); // Seller accepting own offer -} - -#[test] -fn test_multiple_offers_same_token() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let offerer1 = Address::generate(&e); - let offerer2 = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - let token_id = 1u32; - - client.make_offer(&offerer1, &token_id, &500, &payment_token); - client.make_offer(&offerer2, &token_id, &600, &payment_token); - - let offers = client.get_offers(&token_id); - assert_eq!(offers.len(), 2); -} - -#[test] -fn test_cancel_offer() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let offerer = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - let token_id = 1u32; - - client.make_offer(&offerer, &token_id, &500, &payment_token); - client.cancel_offer(&offerer, &token_id); - - let offers = client.get_offers(&token_id); - assert_eq!(offers.len(), 0); -} - -#[test] -#[should_panic(expected = "Error(Contract, #11)")] // OfferNotFound -fn test_cancel_nonexistent_offer_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let offerer = Address::generate(&e); - client.cancel_offer(&offerer, &999); -} - -// ============================================================================ -// Auction System Tests -// ============================================================================ - -#[test] -#[should_panic(expected = "Error(Contract, #6)")] // InvalidPrice -fn test_start_auction_zero_price_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - - client.start_auction(&seller, &1, &0, &86400, &payment_token); -} - -#[test] -#[should_panic(expected = "Error(Contract, #19)")] // InvalidDuration -fn test_start_auction_zero_duration_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - - client.start_auction(&seller, &1, &1000, &0, &payment_token); -} - -#[test] -fn test_place_bid() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let _bidder = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - let token_id = 1u32; - let starting_price = 1000_0000000i128; - let _bid_amount = 1200_0000000i128; - - client.start_auction(&seller, &token_id, &starting_price, &86400, &payment_token); - - // Note: In real test, setup token contract and balances - // client.place_bid(&bidder, &token_id, &bid_amount); - // let auction = client.get_auction(&token_id); - // assert_eq!(auction.current_bid, bid_amount); - // assert_eq!(auction.highest_bidder, Some(bidder)); -} - -#[test] -#[should_panic(expected = "Error(Contract, #18)")] // BidTooLow -fn test_place_bid_too_low_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let bidder = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - let token_id = 1u32; - - client.start_auction(&seller, &token_id, &1000, &86400, &payment_token); - client.place_bid(&bidder, &token_id, &500); // Lower than starting price -} - -#[test] -#[should_panic(expected = "Error(Contract, #18)")] // BidTooLow -fn test_place_bid_not_high_enough_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let bidder = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - let token_id = 1u32; - let starting_price = 1000i128; - - client.start_auction(&seller, &token_id, &starting_price, &86400, &payment_token); - - // current_bid starts at starting_price; bidding the exact same amount is <= current_bid, - // so it must be rejected with BidTooLow before any token transfer happens. - client.place_bid(&bidder, &token_id, &starting_price); -} - -#[test] -#[should_panic(expected = "Error(Contract, #16)")] // AuctionEnded -fn test_place_bid_after_auction_ends_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let bidder = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - let token_id = 1u32; - let duration = 86400u64; // 1 day - - client.start_auction(&seller, &token_id, &1000, &duration, &payment_token); - - // Fast forward time past auction end - e.ledger().with_mut(|li| { - li.timestamp = 86400 + 1; - }); - - client.place_bid(&bidder, &token_id, &1500); -} - -#[test] -fn test_auction_duration_boundary() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let bidder = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - let token_id = 1u32; - let duration = 86400u64; - let starting_price = 1000i128; - - // Auction starts at timestamp 0, ends_at = 0 + duration = 86400 - client.start_auction( - &seller, - &token_id, - &starting_price, - &duration, - &payment_token, - ); - - // At timestamp 0 (start), bidding equal-to-current is rejected with BidTooLow, not AuctionEnded. - // This proves the time check passes (auction is active) but bid check fails. - let result_active = client.try_place_bid(&bidder, &token_id, &starting_price); - assert!( - result_active.is_err(), - "equal bid at auction start should fail" - ); - - // At ends_at - 1 (last active second): equal bid still fails with BidTooLow, not AuctionEnded. - e.ledger().with_mut(|li| { - li.timestamp = duration - 1; - }); - let result_last_second = client.try_place_bid(&bidder, &token_id, &starting_price); - assert!( - result_last_second.is_err(), - "equal bid one second before end should fail" - ); - - // At ends_at (expired): any bid is rejected with AuctionEnded. - e.ledger().with_mut(|li| { - li.timestamp = duration; - }); - let result_at_end = client.try_place_bid(&bidder, &token_id, &(starting_price + 1)); - let err = result_at_end.expect_err("bid at ends_at should fail"); - // Must fail with AuctionEnded (#16), not BidTooLow (#18) - assert_eq!(err.unwrap(), MarketplaceError::AuctionEnded); - - // At ends_at: end_auction should succeed - client.end_auction(&token_id); - let auction = client.get_auction(&token_id); - assert!(auction.ended); -} - -#[test] -#[should_panic(expected = "Error(Contract, #17)")] // AuctionNotEnded -fn test_end_auction_before_time_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, 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); - client.end_auction(&1); // Try to end immediately -} - -#[test] -#[should_panic(expected = "Error(Contract, #16)")] // AuctionEnded -fn test_end_auction_twice_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, 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.end_auction(&1); - client.end_auction(&1); // Should fail -} - -#[test] -fn test_auction_active_vs_ended() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - let token_id = 1u32; - - client.start_auction(&seller, &token_id, &1000, &86400, &payment_token); - - // Should be in active auctions - let auctions = client.get_all_auctions(); - assert_eq!(auctions.len(), 1); - assert_eq!(auctions.get(0).unwrap().token_id, token_id); - - // End auction - e.ledger().with_mut(|li| { - li.timestamp = 86400 + 1; - }); - client.end_auction(&token_id); - - // Should NOT be in active auctions - let auctions_after = client.get_all_auctions(); - assert_eq!(auctions_after.len(), 0); - - // But still retrievable via get_auction - let auction = client.get_auction(&token_id); - assert!(auction.ended); -} - -#[test] -fn test_get_all_auctions() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - - // Start 3 auctions - client.start_auction(&seller, &1, &1000, &86400, &payment_token); - client.start_auction(&seller, &2, &2000, &86400, &payment_token); - client.start_auction(&seller, &3, &3000, &86400, &payment_token); - - let auctions = client.get_all_auctions(); - assert_eq!(auctions.len(), 3); -} - -// ============================================================================ -// Issue #267: Unit tests for offers - duplicate offer, cancel, not maker -// ============================================================================ - -// Duplicate Offer Tests -#[test] -#[should_panic(expected = "Error(Contract, #13)")] // OfferExists -fn test_make_duplicate_offer_same_token_different_amount_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let offerer = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - let token_id = 1u32; - - // Make first offer - client.make_offer(&offerer, &token_id, &500, &payment_token); - - // Try to make another offer with different amount - should fail - client.make_offer(&offerer, &token_id, &1000, &payment_token); -} - -#[test] -#[should_panic(expected = "Error(Contract, #13)")] // OfferExists -fn test_make_duplicate_offer_different_tokens_same_user_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let offerer = Address::generate(&e); - let payment_token1 = setup_test_token(&e, &client); - let payment_token2 = setup_test_token(&e, &client); - - // Make offer on token 1 - client.make_offer(&offerer, &1, &500, &payment_token1); - - // Make offer on token 2 - should work (different token) - client.make_offer(&offerer, &2, &600, &payment_token2); - - // Try to make another offer on token 1 - should fail - client.make_offer(&offerer, &1, &700, &payment_token1); -} - -#[test] -fn test_different_users_can_offer_same_token() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let offerer1 = Address::generate(&e); - let offerer2 = Address::generate(&e); - let offerer3 = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - let token_id = 1u32; - - // Multiple users can offer on the same token - client.make_offer(&offerer1, &token_id, &500, &payment_token); - client.make_offer(&offerer2, &token_id, &600, &payment_token); - client.make_offer(&offerer3, &token_id, &700, &payment_token); - - let offers = client.get_offers(&token_id); - assert_eq!(offers.len(), 3); -} - -// Offer Cancellation Tests -#[test] -fn test_cancel_offer_removes_correct_offer_only() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let offerer1 = Address::generate(&e); - let offerer2 = Address::generate(&e); - let offerer3 = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - let token_id = 1u32; - - // Make multiple offers - client.make_offer(&offerer1, &token_id, &500, &payment_token); - client.make_offer(&offerer2, &token_id, &600, &payment_token); - client.make_offer(&offerer3, &token_id, &700, &payment_token); - - // Cancel middle offer - client.cancel_offer(&offerer2, &token_id); - - let offers = client.get_offers(&token_id); - assert_eq!(offers.len(), 2); - - // Verify correct offers remain - let mut found_500 = false; - let mut found_700 = false; - for o in offers.iter() { - if o.amount == 500 { - found_500 = true; - } - if o.amount == 700 { - found_700 = true; - } - assert_ne!(o.amount, 600); - } - assert!(found_500); - assert!(found_700); -} - -#[test] -fn test_cancel_last_offer_removes_storage() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let offerer = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - let token_id = 1u32; - - // Make offer - client.make_offer(&offerer, &token_id, &500, &payment_token); - - // Verify offer exists - let offers = client.get_offers(&token_id); - assert_eq!(offers.len(), 1); - - // Cancel offer - client.cancel_offer(&offerer, &token_id); - - // Verify offers are empty - let offers = client.get_offers(&token_id); - assert_eq!(offers.len(), 0); -} - -#[test] -#[should_panic(expected = "Error(Contract, #11)")] // OfferNotFound -fn test_cancel_offer_after_accept_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let offerer = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - let token_id = 1u32; - - // Make offer - client.make_offer(&offerer, &token_id, &500, &payment_token); - client.cancel_offer(&offerer, &token_id); - client.cancel_offer(&offerer, &token_id); -} - -#[test] -fn test_cancel_multiple_offers_same_user_different_tokens() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let offerer = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - - // Make offers on different tokens - client.make_offer(&offerer, &1, &500, &payment_token); - client.make_offer(&offerer, &2, &600, &payment_token); - client.make_offer(&offerer, &3, &700, &payment_token); - - // Cancel one offer - client.cancel_offer(&offerer, &2); - - // Verify other offers still exist - assert_eq!(client.get_offers(&1).len(), 1); - assert_eq!(client.get_offers(&2).len(), 0); - assert_eq!(client.get_offers(&3).len(), 1); -} - -// Not Maker Tests (Authorization Tests) -#[test] -#[should_panic(expected = "Error(Contract, #11)")] // OfferNotFound -fn test_non_maker_cannot_cancel_offer() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let offerer = Address::generate(&e); - let non_maker = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - let token_id = 1u32; - - // Make offer - client.make_offer(&offerer, &token_id, &500, &payment_token); - - // Try to cancel with different address - should fail - client.cancel_offer(&non_maker, &token_id); -} - -#[test] -#[should_panic(expected = "Error(Contract, #11)")] // OfferNotFound -fn test_different_offerer_cannot_cancel_other_offer() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let offerer1 = Address::generate(&e); - let offerer2 = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - let token_id = 1u32; - - // Make offers from different users - client.make_offer(&offerer1, &token_id, &500, &payment_token); - client.make_offer(&offerer2, &token_id, &600, &payment_token); - - let non_maker = Address::generate(&e); - client.cancel_offer(&non_maker, &token_id); -} - -#[test] -fn test_maker_can_cancel_own_offer_multiple_exist() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let offerer1 = Address::generate(&e); - let offerer2 = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - let token_id = 1u32; - - // Make offers from different users - client.make_offer(&offerer1, &token_id, &500, &payment_token); - client.make_offer(&offerer2, &token_id, &600, &payment_token); - - // offerer1 should be able to cancel their own offer - client.cancel_offer(&offerer1, &token_id); - - let offers = client.get_offers(&token_id); - assert_eq!(offers.len(), 1); - assert_eq!(offers.get(0).unwrap().offerer, offerer2); -} - -#[test] -#[should_panic(expected = "Error(Contract, #11)")] // OfferNotFound -fn test_cancel_nonexistent_offer_as_non_maker_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let non_maker = Address::generate(&e); - let token_id = 999u32; - - // Try to cancel offer that doesn't exist - should fail - client.cancel_offer(&non_maker, &token_id); -} - -#[test] -fn test_authorization_scenarios_comprehensive() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let offerer1 = Address::generate(&e); - let offerer2 = Address::generate(&e); - let offerer3 = Address::generate(&e); - let random_user = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - - // Create offers on multiple tokens - client.make_offer(&offerer1, &1, &100, &payment_token); - client.make_offer(&offerer2, &1, &200, &payment_token); - client.make_offer(&offerer1, &2, &300, &payment_token); - client.make_offer(&offerer3, &3, &400, &payment_token); - - // Each offerer can cancel their own offers - client.cancel_offer(&offerer1, &1); // Cancels offerer1's offer on token 1 - client.cancel_offer(&offerer1, &2); // Cancels offerer1's offer on token 2 - - // Verify remaining offers - assert_eq!(client.get_offers(&1).len(), 1); // Only offerer2's offer remains - assert_eq!(client.get_offers(&2).len(), 0); // offerer1's offer cancelled - assert_eq!(client.get_offers(&3).len(), 1); // offerer3's offer still exists - - // Random user cannot cancel any offers - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - client.cancel_offer(&random_user, &1); - })); - assert!(result.is_err()); -} - -// ============================================================================ -// Edge Cases and Integration Tests -// ============================================================================ - -#[test] -fn test_list_then_start_auction_same_token() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - let token_id = 1u32; - - // List NFT - client.list_nft(&seller, &token_id, &1000, &payment_token); - - // Cancel listing - client.cancel_listing(&seller, &token_id); - - // Now start auction (should work) - client.start_auction(&seller, &token_id, &1000, &86400, &payment_token); - - let auction = client.get_auction(&token_id); - assert_eq!(auction.token_id, token_id); -} - -#[test] -fn test_reentrancy_protection() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, _client) = setup_marketplace(&e); - - // The reentrancy guard prevents nested calls - // This is tested implicitly in the token transfer flows - // In production, you'd test with malicious contracts -} - -// ============================================================================ -// Gas / CPU Budget Profile Tests — Hot Paths (#272) -// -// These tests are designed to measure and document the resource consumption -// (CPU instructions and memory in Soroban) of the three hot paths: -// • buy_nft — fixed-price purchase -// • place_bid — auction bid (with previous-bidder refund) -// • end_auction — settle auction -// -// In the Soroban test environment the `budget()` API is available on `Env` -// when compiled with `features = ["testutils"]`. Each test records the -// budget consumed for a single hot-path invocation so that regressions are -// visible in CI output. -// -// NOTE: token transfers require a real deployed token contract. Where a -// live token contract is not available the test documents the *non-transfer* -// portion of the hot path (state reads/writes and event emission) and marks -// the transfer portion as a known stub. -// ============================================================================ - -// ============================================================================ -// Reentrancy Guard Unit Tests (Explicit) -// ============================================================================ - -/// @notice Test: list_nft fails if reentrancy guard is set. -#[test] -#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected -fn test_list_nft_reentrancy_guard() { - let e = Env::default(); - e.mock_all_auths(); - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - e.as_contract(&client.address, || { - e.storage().instance().set(&DataKey::ReentrancyGuard, &true); - }); - client.list_nft(&seller, &1, &1000, &payment_token); -} - -/// @notice Test: cancel_listing fails if reentrancy guard is set. -#[test] -#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected -fn test_cancel_listing_reentrancy_guard() { - let e = Env::default(); - e.mock_all_auths(); - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - let token_id = 1u32; - client.list_nft(&seller, &token_id, &1000, &payment_token); - e.as_contract(&client.address, || { - e.storage().instance().set(&DataKey::ReentrancyGuard, &true); - }); - client.cancel_listing(&seller, &token_id); -} - -/// @notice Test: buy_nft fails if reentrancy guard is set. -#[test] -#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected -fn test_buy_nft_reentrancy_guard() { - let e = Env::default(); - e.mock_all_auths(); - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let buyer = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - let token_id = 1u32; - client.list_nft(&seller, &token_id, &1000, &payment_token); - e.as_contract(&client.address, || { - e.storage().instance().set(&DataKey::ReentrancyGuard, &true); - }); - client.buy_nft(&buyer, &token_id); -} - -/// @notice Test: make_offer fails if reentrancy guard is set. -#[test] -#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected -fn test_make_offer_reentrancy_guard() { - let e = Env::default(); - e.mock_all_auths(); - let (_, _, client) = setup_marketplace(&e); - let offerer = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - e.as_contract(&client.address, || { - e.storage().instance().set(&DataKey::ReentrancyGuard, &true); - }); - client.make_offer(&offerer, &1, &500, &payment_token); -} - -/// @notice Test: accept_offer fails if reentrancy guard is set. -#[test] -#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected -fn test_accept_offer_reentrancy_guard() { - let e = Env::default(); - e.mock_all_auths(); - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let offerer = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - let token_id = 1u32; - client.list_nft(&seller, &token_id, &1000, &payment_token); - client.make_offer(&offerer, &token_id, &500, &payment_token); - e.as_contract(&client.address, || { - e.storage().instance().set(&DataKey::ReentrancyGuard, &true); - }); - client.accept_offer(&seller, &token_id, &offerer); -} - -/// @notice Test: start_auction fails if reentrancy guard is set. -#[test] -#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected -fn test_start_auction_reentrancy_guard() { - let e = Env::default(); - e.mock_all_auths(); - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - e.as_contract(&client.address, || { - e.storage().instance().set(&DataKey::ReentrancyGuard, &true); - }); - client.start_auction(&seller, &1, &1000, &86400, &payment_token); -} - -/// @notice Test: place_bid fails if reentrancy guard is set. -#[test] -#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected -fn test_place_bid_reentrancy_guard() { - let e = Env::default(); - e.mock_all_auths(); - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let bidder = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - let token_id = 1u32; - client.start_auction(&seller, &token_id, &1000, &86400, &payment_token); - e.as_contract(&client.address, || { - e.storage().instance().set(&DataKey::ReentrancyGuard, &true); - }); - client.place_bid(&bidder, &token_id, &1200); -} - -/// @notice Test: end_auction fails if reentrancy guard is set. -#[test] -#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected -fn test_end_auction_reentrancy_guard() { - let e = Env::default(); - e.mock_all_auths(); - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let payment_token = setup_test_token(&e, &client); - let token_id = 1u32; - client.start_auction(&seller, &token_id, &1000, &1, &payment_token); - e.ledger().with_mut(|li| { - li.timestamp = 2; - }); - e.as_contract(&client.address, || { - e.storage().instance().set(&DataKey::ReentrancyGuard, &true); - }); - client.end_auction(&token_id); -} - -#[test] -fn test_gas_listing_operations() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - - let seller = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - - // Measure operations for optimization - let start = e.ledger().sequence(); - - for i in 0..10 { - client.list_nft(&seller, &i, &1000, &payment_token); - } - - let end = e.ledger().sequence(); - let _operations = end - start; - - assert_eq!(client.get_all_listings().len(), 10); -} - -#[test] -fn test_add_and_remove_payment_token() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - let payment_token = Address::generate(&e); - - assert!(!client.is_payment_token_allowed(&payment_token)); - - client.add_payment_token(&payment_token); - assert!(client.is_payment_token_allowed(&payment_token)); - assert_eq!(client.get_allowed_payment_tokens().len(), 1); - - client.remove_payment_token(&payment_token); - assert!(!client.is_payment_token_allowed(&payment_token)); - assert_eq!(client.get_allowed_payment_tokens().len(), 0); -} - -#[test] -#[should_panic(expected = "Error(Contract, #22)")] // PaymentTokenNotAllowed -fn test_list_nft_with_unallowlisted_token_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let payment_token = Address::generate(&e); - - client.list_nft(&seller, &1, &1000, &payment_token); -} - -#[test] -#[should_panic(expected = "Error(Contract, #22)")] // PaymentTokenNotAllowed -fn test_make_offer_with_unallowlisted_token_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - let offerer = Address::generate(&e); - let payment_token = Address::generate(&e); - - client.make_offer(&offerer, &1, &1000, &payment_token); -} - -#[test] -#[should_panic(expected = "Error(Contract, #22)")] // PaymentTokenNotAllowed -fn test_start_auction_with_unallowlisted_token_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let payment_token = Address::generate(&e); - - client.start_auction(&seller, &1, &1000, &86400, &payment_token); -} - -#[test] -#[should_panic(expected = "Error(Contract, #22)")] // PaymentTokenNotAllowed -fn test_buy_nft_after_payment_token_is_removed_fails() { - let e = Env::default(); - e.mock_all_auths(); - - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let buyer = Address::generate(&e); - let token_admin = Address::generate(&e); - let token = e.register_stellar_asset_contract_v2(token_admin); - let payment_token = token.address(); - client.add_payment_token(&payment_token); - soroban_sdk::token::StellarAssetClient::new(&e, &payment_token).mint(&buyer, &10_000); - - client.list_nft(&seller, &1, &1000, &payment_token); - client.remove_payment_token(&payment_token); - client.buy_nft(&buyer, &1); -} - -// ============================================================================ -// Royalty Accounting and Settlement Invariant Tests -// ============================================================================ - -#[test] -fn test_royalty_configuration_is_visible_for_active_listing() { - let e = Env::default(); - e.mock_all_auths(); - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let recipient = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - - client.list_nft(&seller, &7, &10_000, &payment_token); - client.set_royalty(&seller, &7, &recipient, &375); - - let royalty = client.get_royalty(&7).expect("royalty must be stored"); - assert_eq!(royalty.recipient, recipient); - assert_eq!(royalty.basis_points, 375); - assert_eq!(client.get_all_listings().len(), 1); -} - -#[test] -fn test_zero_royalty_keeps_sale_amount_conserved() { - let e = Env::default(); - e.mock_all_auths(); - let (_, fee_recipient, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let buyer = Address::generate(&e); - let royalty_recipient = Address::generate(&e); - let token_admin = Address::generate(&e); - let token = e.register_stellar_asset_contract_v2(token_admin); - let payment_token = token.address(); - client.add_payment_token(&payment_token); - let price = 100_003i128; - soroban_sdk::token::StellarAssetClient::new(&e, &payment_token).mint(&buyer, &price); - - client.list_nft(&seller, &1, &price, &payment_token); - client.set_royalty(&seller, &1, &royalty_recipient, &0); - let buyer_before = soroban_sdk::token::Client::new(&e, &payment_token).balance(&buyer); - client.buy_nft(&buyer, &1); - - let payment_client = soroban_sdk::token::Client::new(&e, &payment_token); - let seller_received = payment_client.balance(&seller); - let fee_received = payment_client.balance(&fee_recipient); - let royalty_received = payment_client.balance(&royalty_recipient); - let buyer_after = payment_client.balance(&buyer); - assert_eq!(seller_received + fee_received + royalty_received, price); - assert_eq!(buyer_before - buyer_after, price); - assert!(client.get_royalty(&1).is_none()); -} - -#[test] -fn test_primary_sale_splits_fee_and_royalty_without_value_creation() { - let e = Env::default(); - e.mock_all_auths(); - let (_, fee_recipient, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let buyer = Address::generate(&e); - let royalty_recipient = Address::generate(&e); - let token_admin = Address::generate(&e); - let token = e.register_stellar_asset_contract_v2(token_admin); - let payment_token = token.address(); - client.add_payment_token(&payment_token); - let sale_amount = 1_234_567i128; - soroban_sdk::token::StellarAssetClient::new(&e, &payment_token).mint(&buyer, &sale_amount); - - client.list_nft(&seller, &9, &sale_amount, &payment_token); - client.set_royalty(&seller, &9, &royalty_recipient, &500); - client.buy_nft(&buyer, &9); - - let payment_client = soroban_sdk::token::Client::new(&e, &payment_token); - let fee = sale_amount * 250 / 10_000; - let royalty = sale_amount * 500 / 10_000; - let seller = payment_client.balance(&seller); - let fee_recipient = payment_client.balance(&fee_recipient); - let royalty_recipient = payment_client.balance(&royalty_recipient); - assert_eq!(fee_recipient, fee); - assert_eq!(royalty_recipient, royalty); - assert_eq!(seller + fee_recipient + royalty_recipient, sale_amount); -} - -#[test] -fn test_rounding_policy_conserves_every_small_sale_unit() { - let e = Env::default(); - e.mock_all_auths(); - let (_, fee_recipient, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let buyer = Address::generate(&e); - let royalty_recipient = Address::generate(&e); - let token_admin = Address::generate(&e); - let token = e.register_stellar_asset_contract_v2(token_admin); - let payment_token = token.address(); - client.add_payment_token(&payment_token); - let sale_amount = 101i128; - soroban_sdk::token::StellarAssetClient::new(&e, &payment_token).mint(&buyer, &sale_amount); - - client.list_nft(&seller, &3, &sale_amount, &payment_token); - client.set_royalty(&seller, &3, &royalty_recipient, &333); - client.buy_nft(&buyer, &3); - - let payment_client = soroban_sdk::token::Client::new(&e, &payment_token); - assert_eq!(payment_client.balance(&seller), 96); - assert_eq!(payment_client.balance(&fee_recipient), 2); - assert_eq!(payment_client.balance(&royalty_recipient), 3); - assert_eq!( - payment_client.balance(&seller) - + payment_client.balance(&fee_recipient) - + payment_client.balance(&royalty_recipient), - sale_amount - ); -} - -#[test] -#[should_panic(expected = "Error(Contract, #27)")] -fn test_royalty_above_policy_maximum_is_rejected() { - let e = Env::default(); - e.mock_all_auths(); - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let recipient = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - - client.list_nft(&seller, &5, &1000, &payment_token); - client.set_royalty(&seller, &5, &recipient, &(MAX_ROYALTY_BASIS_POINTS + 1)); -} - -#[test] -#[should_panic(expected = "Error(Contract, #30)")] -fn test_only_listing_seller_can_update_royalty() { - let e = Env::default(); - e.mock_all_auths(); - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let attacker = Address::generate(&e); - let recipient = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - - client.list_nft(&seller, &6, &1000, &payment_token); - client.set_royalty(&attacker, &6, &recipient, &500); -} - -#[test] -#[should_panic(expected = "Error(Contract, #29)")] -fn test_initialization_rejects_fee_over_sale_amount() { - let e = Env::default(); - e.mock_all_auths(); - let admin = Address::generate(&e); - let nft_contract = Address::generate(&e); - let fee_recipient = Address::generate(&e); - let marketplace_id = e.register_contract(None, CommitmentMarketplace); - let client = CommitmentMarketplaceClient::new(&e, &marketplace_id); - - client.initialize(&admin, &nft_contract, &10_001, &fee_recipient); -} - -#[test] -#[should_panic(expected = "Error(Contract, #29)")] -fn test_fee_update_rejects_percentage_above_one_hundred() { - let e = Env::default(); - e.mock_all_auths(); - let (_, _, client) = setup_marketplace(&e); - client.update_fee(&10_001); -} - -#[test] -fn test_failed_payment_keeps_listing_and_royalty_state() { - let e = Env::default(); - e.mock_all_auths(); - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let buyer = Address::generate(&e); - let recipient = Address::generate(&e); - let token_admin = Address::generate(&e); - let token = e.register_stellar_asset_contract_v2(token_admin); - let payment_token = token.address(); - client.add_payment_token(&payment_token); - soroban_sdk::token::StellarAssetClient::new(&e, &payment_token).mint(&buyer, &1); - client.list_nft(&seller, &11, &1000, &payment_token); - client.set_royalty(&seller, &11, &recipient, &500); - - let result = client.try_buy_nft(&buyer, &11); - assert!(result.is_err(), "an underfunded buyer must not settle"); - let listing = client.get_listing(&11); - assert_eq!(listing.price, 1000); - assert_eq!(client.get_royalty(&11).unwrap().basis_points, 500); -} - -#[test] -fn test_duplicate_settlement_cannot_reuse_consumed_royalty() { - let e = Env::default(); - e.mock_all_auths(); - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let buyer = Address::generate(&e); - let recipient = Address::generate(&e); - let token_admin = Address::generate(&e); - let token = e.register_stellar_asset_contract_v2(token_admin); - let payment_token = token.address(); - client.add_payment_token(&payment_token); - soroban_sdk::token::StellarAssetClient::new(&e, &payment_token).mint(&buyer, &1000); - - client.list_nft(&seller, &12, &1000, &payment_token); - client.set_royalty(&seller, &12, &recipient, &500); - client.buy_nft(&buyer, &12); - let second_attempt = client.try_buy_nft(&buyer, &12); - assert!(second_attempt.is_err()); - assert!(client.get_royalty(&12).is_some()); - assert!(client.get_listing(&12).is_ok()); -} - -#[test] -fn test_cancel_listing_removes_unsettled_royalty_configuration() { - let e = Env::default(); - e.mock_all_auths(); - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let recipient = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - - client.list_nft(&seller, &13, &1000, &payment_token); - client.set_royalty(&seller, &13, &recipient, &250); - client.cancel_listing(&seller, &13); - - assert!(client.get_royalty(&13).is_none()); - assert!(client.get_listing(&13).is_err()); -} - -#[test] -fn test_maximum_allowed_royalty_still_leaves_seller_proceeds() { - let e = Env::default(); - e.mock_all_auths(); - let (_, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let recipient = Address::generate(&e); - let payment_token = setup_allowed_payment_token(&e, &client); - let sale_amount = 10_000i128; - - client.list_nft(&seller, &14, &sale_amount, &payment_token); - client.set_royalty(&seller, &14, &recipient, &MAX_ROYALTY_BASIS_POINTS); - let royalty = client.get_royalty(&14).unwrap(); - assert_eq!(royalty.basis_points, MAX_ROYALTY_BASIS_POINTS); - assert_eq!(client.get_listing(&14).unwrap().price, sale_amount); -} - -// Emergency pause and recovery invariants (GrantFox #553) -// ============================================================================ - -#[test] -fn test_pause_starts_disabled_and_emits_ordered_pause_event() { - 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()); - - let events = e.events().all(); - assert_eq!(events.len(), 1); - let last = events.last().unwrap(); - assert_eq!(last.1.get(0).unwrap(), symbol_short!("Pause").into_val(&e)); -} - -#[test] -#[should_panic(expected = "Error(Contract, #24)")] -fn test_repeated_pause_is_rejected_without_state_change() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - - client.pause(&admin); - client.pause(&admin); -} - -#[test] -#[should_panic(expected = "Error(Contract, #25)")] -fn test_repeated_unpause_is_rejected() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - - client.unpause(&admin); -} - -#[test] -#[should_panic(expected = "Error(Contract, #26)")] -fn test_non_admin_cannot_toggle_pause() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - let attacker = Address::generate(&e); - assert_ne!(admin, attacker); - - client.pause(&attacker); -} - -#[test] -fn test_unpause_restores_normal_mutations_without_changing_views() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let token = setup_allowed_payment_token(&e, &client); - - client.pause(&admin); - assert!(client.is_paused()); - assert_eq!(client.get_all_listings().len(), 0); - client.unpause(&admin); - assert!(!client.is_paused()); - client.list_nft(&seller, &77, &1_000, &token); - assert_eq!(client.get_listing(&77).unwrap().price, 1_000); -} - -#[test] -#[should_panic(expected = "Error(Contract, #23)")] -fn test_pause_blocks_listing_without_writing_state() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let token = setup_allowed_payment_token(&e, &client); - - client.pause(&admin); - client.list_nft(&seller, &10, &1_000, &token); -} - -#[test] -#[should_panic(expected = "Error(Contract, #23)")] -fn test_pause_blocks_buy_before_payment_or_listing_mutation() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - let buyer = Address::generate(&e); - - client.pause(&admin); - client.buy_nft(&buyer, &10); -} - -#[test] -#[should_panic(expected = "Error(Contract, #23)")] -fn test_pause_blocks_offer_creation() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - let offerer = Address::generate(&e); - let token = setup_allowed_payment_token(&e, &client); - - client.pause(&admin); - client.make_offer(&offerer, &10, &1_000, &token); -} - -#[test] -#[should_panic(expected = "Error(Contract, #23)")] -fn test_pause_blocks_auction_start_and_bids() { - 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 token = setup_allowed_payment_token(&e, &client); - - client.pause(&admin); - client.start_auction(&seller, &11, &1_000, &100, &token); - client.place_bid(&bidder, &11, &1_100); -} - -#[test] -fn test_cancel_listing_remains_available_as_paused_recovery_path() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let token = setup_allowed_payment_token(&e, &client); - - client.list_nft(&seller, &12, &1_000, &token); - client.pause(&admin); - client.cancel_listing(&seller, &12); - assert_eq!(client.get_all_listings().len(), 0); - assert!(client.is_paused()); -} - -#[test] -fn test_cancel_offer_remains_available_as_paused_recovery_path() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - let offerer = Address::generate(&e); - let token = setup_allowed_payment_token(&e, &client); - - client.make_offer(&offerer, &13, &1_000, &token); - client.pause(&admin); - client.cancel_offer(&offerer, &13); - assert_eq!(client.get_offers(&13).len(), 0); - assert!(client.is_paused()); -} - -#[test] -fn test_pause_preserves_existing_listing_snapshot() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let token = setup_allowed_payment_token(&e, &client); - - client.list_nft(&seller, &14, &42_000, &token); - let before = client.get_listing(&14).unwrap(); - client.pause(&admin); - let after = client.get_listing(&14).unwrap(); - assert_eq!(before, after); - assert_eq!(client.get_all_listings().len(), 1); -} - -#[test] -#[should_panic(expected = "Error(Contract, #23)")] -fn test_pause_blocks_fee_configuration() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - - client.pause(&admin); - client.update_fee(&500); -} - -#[test] -#[should_panic(expected = "Error(Contract, #23)")] -fn test_pause_blocks_payment_token_allowlist_changes() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - let token = Address::generate(&e); - - client.pause(&admin); - client.add_payment_token(&token); -} - -#[test] -fn test_pause_and_unpause_emit_only_successful_transition_events_in_order() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - - client.pause(&admin); - client.unpause(&admin); - - let events = e.events().all(); - assert_eq!(events.len(), 2); - assert_eq!( - events.first().unwrap().1.get(0).unwrap(), - symbol_short!("Pause").into_val(&e) - ); - assert_eq!( - events.last().unwrap().1.get(0).unwrap(), - symbol_short!("Unpause").into_val(&e) - ); - assert!(!client.is_paused()); -} - -#[test] -fn test_multiple_pause_cycles_preserve_listing_ownership_snapshot() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let token = setup_allowed_payment_token(&e, &client); - - client.list_nft(&seller, &88, &9_999, &token); - let snapshot = client.get_listing(&88).unwrap(); - for _ in 0..3 { - client.pause(&admin); - assert_eq!(client.get_listing(&88).unwrap(), snapshot); - client.unpause(&admin); - assert_eq!(client.get_listing(&88).unwrap(), snapshot); - } - assert!(!client.is_paused()); - assert_eq!(client.get_all_listings().len(), 1); -} - -#[test] -#[should_panic(expected = "Error(Contract, #23)")] -fn test_pause_blocks_offer_acceptance() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let offerer = Address::generate(&e); - - client.pause(&admin); - client.accept_offer(&seller, &20, &offerer); -} - -#[test] -#[should_panic(expected = "Error(Contract, #23)")] -fn test_pause_blocks_bid_submission() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - let bidder = Address::generate(&e); - - client.pause(&admin); - client.place_bid(&bidder, &20, &2_000); -} - -#[test] -#[should_panic(expected = "Error(Contract, #23)")] -fn test_pause_blocks_allowlist_removal() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - let token = setup_allowed_payment_token(&e, &client); - - client.pause(&admin); - client.remove_payment_token(&token); -} - -#[test] -fn test_auction_can_be_ended_as_recovery_without_bids_while_paused() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let token = setup_allowed_payment_token(&e, &client); - - client.start_auction(&seller, &21, &1_000, &10, &token); - e.ledger().with_mut(|ledger| ledger.timestamp = 11); - client.pause(&admin); - client.end_auction(&21); - - assert!(client.get_auction(&21).unwrap().ended); - assert!(client.is_paused()); -} - -#[test] -fn test_pausing_does_not_change_active_auction_snapshot() { - let e = Env::default(); - e.mock_all_auths(); - let (admin, _, client) = setup_marketplace(&e); - let seller = Address::generate(&e); - let token = setup_allowed_payment_token(&e, &client); - - client.start_auction(&seller, &22, &7_500, &100, &token); - let before = client.get_auction(&22).unwrap(); - client.pause(&admin); - let after = client.get_auction(&22).unwrap(); - assert_eq!(before, after); - assert_eq!(client.get_all_auctions().len(), 1); -} +//! # Commitment Marketplace Contract Tests +//! +//! Unit tests for the CommitmentMarketplace Soroban contract. +//! +//! ## Coverage +//! - Initialization, listing, offers, auctions, and reentrancy guard. +//! - Edge cases and error conditions. +//! +//! ## Security +//! - Explicit tests for reentrancy guard on all entry points. +//! - All state-changing entry points require authentication. +//! +//! ## Usage +//! Run with `cargo test -p commitment-marketplace` from the workspace root. + +#![cfg(test)] + +extern crate std; + +use crate::*; +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, Events, Ledger}, + vec, Address, Env, IntoVal, +}; + +// ============================================================================ +// Test Setup Helpers +// ============================================================================ + +/// @notice Helper to deploy and initialize the marketplace contract for tests. +/// @param e Test environment. +/// @return (admin, fee_recipient, client) +fn setup_marketplace(e: &Env) -> (Address, Address, CommitmentMarketplaceClient<'_>) { + let admin = Address::generate(e); + let nft_contract = Address::generate(e); + let fee_recipient = Address::generate(e); + + // Use register_contract for Soroban SDK + let marketplace_id = e.register_contract(None, CommitmentMarketplace); + let client = CommitmentMarketplaceClient::new(e, &marketplace_id); + + client.initialize(&admin, &nft_contract, &250, &fee_recipient); // 2.5% fee + + (admin, fee_recipient, client) +} + +/// @notice Helper to generate a test token address and allowlist it. +fn setup_test_token(e: &Env, client: &CommitmentMarketplaceClient<'_>) -> Address { + setup_allowed_payment_token(e, client) +} + +fn set_contract_reentrancy_guard(e: &Env, contract: &Address, active: bool) { + e.as_contract(contract, || { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &active); + }); +} + +fn setup_allowed_payment_token(e: &Env, client: &CommitmentMarketplaceClient<'_>) -> Address { + let payment_token = Address::generate(e); + client.add_payment_token(&payment_token); + payment_token +} + +// ============================================================================ +// Initialization Tests +// ============================================================================ + +#[test] +fn test_initialize_marketplace() { + let e = Env::default(); + e.mock_all_auths(); + + let admin = Address::generate(&e); + let nft_contract = Address::generate(&e); + let fee_recipient = Address::generate(&e); + + let marketplace_id = e.register_contract(None, CommitmentMarketplace); + let client = CommitmentMarketplaceClient::new(&e, &marketplace_id); + + client.initialize(&admin, &nft_contract, &250, &fee_recipient); + + assert_eq!(client.get_admin(), admin); +} + +#[test] +#[should_panic(expected = "Error(Contract, #2)")] // AlreadyInitialized +fn test_initialize_twice_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_admin, _, client) = setup_marketplace(&e); + let nft_contract = Address::generate(&e); + let fee_recipient = Address::generate(&e); + let new_admin = Address::generate(&e); + + client.initialize(&new_admin, &nft_contract, &250, &fee_recipient); +} + +#[test] +fn test_update_fee() { + let e = Env::default(); + e.mock_all_auths(); + + let (_admin, _, client) = setup_marketplace(&e); + + client.update_fee(&500); // Update to 5% + + // Verify event + let events = e.events().all(); + let last_event = events.last().unwrap(); + + assert_eq!(last_event.0, client.address); +} + +#[test] +fn test_admin_rotation_requires_nominee_acceptance() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + let nominee = Address::generate(&e); + + client.nominate_admin(&nominee); + assert_eq!(client.get_admin(), admin); + assert_eq!(client.get_pending_admin(), Some(nominee.clone())); + + client.accept_admin_transfer(&nominee); + assert_eq!(client.get_admin(), nominee); + assert_eq!(client.get_pending_admin(), None); +} + +#[test] +fn test_admin_rotation_replacement_and_cancellation() { + let e = Env::default(); + e.mock_all_auths(); + let (_admin, _, client) = setup_marketplace(&e); + let first = Address::generate(&e); + let replacement = Address::generate(&e); + + client.nominate_admin(&first); + client.nominate_admin(&replacement); + assert_eq!(client.get_pending_admin(), Some(replacement)); + client.cancel_admin_transfer(); + assert_eq!(client.get_pending_admin(), None); +} + +#[test] +#[should_panic(expected = "Error(Contract, #32)")] +fn test_non_nominee_cannot_accept_admin_rotation() { + let e = Env::default(); + e.mock_all_auths(); + let (_admin, _, client) = setup_marketplace(&e); + let nominee = Address::generate(&e); + let attacker = Address::generate(&e); + + client.nominate_admin(&nominee); + client.accept_admin_transfer(&attacker); +} + +// ============================================================================ +// Listing Tests +// ============================================================================ + +#[test] +#[should_panic(expected = "Error(Contract, #6)")] // InvalidPrice +fn test_list_nft_zero_price_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + client.list_nft(&seller, &1, &0, &payment_token); +} + +#[test] +#[should_panic(expected = "Error(Contract, #7)")] // ListingExists +fn test_list_nft_twice_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, 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.list_nft(&seller, &1, &2000, &payment_token); // Should fail +} + +#[test] +fn test_cancel_listing() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + let token_id = 1u32; + + client.list_nft(&seller, &token_id, &1000, &payment_token); + client.cancel_listing(&seller, &token_id); + + // Verify event + let events = e.events().all(); + let last_event = events.last().unwrap(); + + assert_eq!( + last_event.1, + vec![ + &e, + symbol_short!("ListCncl").into_val(&e), + token_id.into_val(&e) + ] + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3)")] // ListingNotFound +fn test_get_listing_after_cancel_panics() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let token_id = 1u32; + + let payment_token = setup_allowed_payment_token(&e, &client); + client.list_nft(&seller, &token_id, &1000, &payment_token); + client.cancel_listing(&seller, &token_id); + + // This will panic as expected + client.get_listing(&token_id); +} + +#[test] +#[should_panic(expected = "Error(Contract, #3)")] // ListingNotFound +fn test_cancel_nonexistent_listing_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + client.cancel_listing(&seller, &999); +} + +#[test] +#[should_panic(expected = "Error(Contract, #4)")] // NotSeller +fn test_cancel_listing_not_seller_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let not_seller = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + client.list_nft(&seller, &1, &1000, &payment_token); + client.cancel_listing(¬_seller, &1); // Should fail +} + +#[test] +fn test_get_all_listings() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + // List 3 NFTs + client.list_nft(&seller, &1, &1000, &payment_token); + client.list_nft(&seller, &2, &2000, &payment_token); + client.list_nft(&seller, &3, &3000, &payment_token); + + let listings = client.get_all_listings(); + assert_eq!(listings.len(), 3); +} + +// ============================================================================ +// Buy Tests (Note: These are simplified - real tests need token contract) +// ============================================================================ + +#[test] +fn test_buy_nft_flow() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let _buyer = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + let token_id = 1u32; + let price = 1000_0000000i128; + + // List NFT + client.list_nft(&seller, &token_id, &price, &payment_token); + + // Note: In a real test, you'd need to: + // 1. Deploy a test token contract + // 2. Mint tokens to the buyer + // 3. Have buyer approve marketplace to spend tokens + // 4. Call buy_nft + // 5. Verify token and NFT transfers + + // For this example, we're testing the flow logic only + // Uncomment when you have token contract set up: + // client.buy_nft(&buyer, &token_id); + + // Verify listing is removed + // let result = client.try_get_listing(&token_id); + // assert!(result.is_err()); +} + +#[test] +#[should_panic(expected = "Error(Contract, #8)")] // CannotBuyOwnListing +fn test_buy_own_listing_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, 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.buy_nft(&seller, &1); // Seller trying to buy their own listing +} + +// ============================================================================ +// Offer System Tests +// ============================================================================ + +#[test] +#[should_panic(expected = "Error(Contract, #12)")] // InvalidOfferAmount +fn test_make_offer_zero_amount_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + client.make_offer(&offerer, &1, &0, &payment_token, &86400); +} + +#[test] +#[should_panic(expected = "Error(Contract, #13)")] // OfferExists +fn test_make_duplicate_offer_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + client.make_offer(&offerer, &1, &500, &payment_token, &86400); + client.make_offer(&offerer, &1, &600, &payment_token, &86400); // Should fail +} + +#[test] +#[should_panic(expected = "Error(Contract, #8)")] // CannotBuyOwnListing +fn test_make_offer_own_listing_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + + client.list_nft(&seller, &1, &1000, &payment_token); + client.make_offer(&seller, &1, &800, &payment_token, &86400); // Seller making offer on own listing +} + +#[test] +#[should_panic(expected = "Error(Contract, #8)")] // CannotBuyOwnListing +fn test_make_offer_own_auction_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + + client.start_auction(&seller, &1, &1000, &86400, &payment_token); + client.make_offer(&seller, &1, &1100, &payment_token, &86400); // Seller making offer on own auction +} + +#[test] +#[should_panic(expected = "Error(Contract, #8)")] // CannotBuyOwnListing +fn test_accept_offer_own_listing_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + + client.make_offer(&seller, &1, &1000, &payment_token, &86400); + client.accept_offer(&seller, &1, &seller); // Seller accepting own offer +} + +#[test] +fn test_multiple_offers_same_token() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer1 = Address::generate(&e); + let offerer2 = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + let token_id = 1u32; + + client.make_offer(&offerer1, &token_id, &500, &payment_token, &86400); + client.make_offer(&offerer2, &token_id, &600, &payment_token, &86400); + + let offers = client.get_offers(&token_id); + assert_eq!(offers.len(), 2); +} + +#[test] +fn test_cancel_offer() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + let token_id = 1u32; + + client.make_offer(&offerer, &token_id, &500, &payment_token, &86400); + client.cancel_offer(&offerer, &token_id); + + let offers = client.get_offers(&token_id); + assert_eq!(offers.len(), 0); +} + +#[test] +#[should_panic(expected = "Error(Contract, #11)")] // OfferNotFound +fn test_cancel_nonexistent_offer_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer = Address::generate(&e); + client.cancel_offer(&offerer, &999); +} + +// ============================================================================ +// Offer Expiration Policy Tests +// ============================================================================ +// Policy: an offer is valid only while `ledger.timestamp() < expires_at` — +// the boundary is exclusive, the same convention `Auction::ends_at` uses. +// At the exact expiry instant the offer is already expired. + +#[test] +#[should_panic(expected = "Error(Contract, #19)")] // InvalidDuration +fn test_make_offer_zero_duration_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + client.make_offer(&offerer, &1, &500, &payment_token, &0); +} + +#[test] +#[should_panic(expected = "Error(Contract, #19)")] // InvalidDuration +fn test_make_offer_duration_overflow_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + e.ledger().with_mut(|li| { + li.timestamp = 1_000; + }); + client.make_offer(&offerer, &1, &500, &payment_token, &u64::MAX); +} + +#[test] +fn test_offer_expires_at_exclusive_boundary() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer = Address::generate(&e); + let seller = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + let duration = 3600u64; + + e.ledger().with_mut(|li| { + li.timestamp = 1_000; + }); + client.make_offer(&offerer, &token_id, &500, &payment_token, &duration); + + // The effective expiry is recorded on the offer: 1000 + 3600 = 4600. + let offers = client.get_offers(&token_id); + assert_eq!(offers.len(), 1); + assert_eq!(offers.get(0).unwrap().expires_at, 4_600); + + // One second before expiry the offer is still live: the expiry check + // passes it, so a duplicate offer reaches the OfferExists check (#13) + // rather than replacing it. + e.ledger().with_mut(|li| { + li.timestamp = 4_599; + }); + let res = client.try_make_offer(&offerer, &token_id, &600, &payment_token, &duration); + assert_eq!(res.unwrap_err().unwrap(), MarketplaceError::OfferExists); + + // At the exact boundary the offer is expired: acceptance fails + // deterministically with OfferExpired (#35) before any token movement, + // and the stored offer is not mutated by the rejected call. + e.ledger().with_mut(|li| { + li.timestamp = 4_600; + }); + let res = client.try_accept_offer(&seller, &token_id, &offerer); + assert_eq!(res.unwrap_err().unwrap(), MarketplaceError::OfferExpired); + let offers = client.get_offers(&token_id); + assert_eq!(offers.len(), 1); + assert_eq!(offers.get(0).unwrap().amount, 500); + + // Past the boundary acceptance stays rejected. + e.ledger().with_mut(|li| { + li.timestamp = 4_601; + }); + let res = client.try_accept_offer(&seller, &token_id, &offerer); + assert_eq!(res.unwrap_err().unwrap(), MarketplaceError::OfferExpired); + + // The offerer can still cancel an expired offer (cleanup path). + client.cancel_offer(&offerer, &token_id); + let offers = client.get_offers(&token_id); + assert_eq!(offers.len(), 0); +} + +#[test] +fn test_expired_offer_replaced_by_reoffer() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + let duration = 3600u64; + + e.ledger().with_mut(|li| { + li.timestamp = 1_000; + }); + client.make_offer(&offerer, &token_id, &500, &payment_token, &duration); + + // Advance past expiry and re-offer: the expired offer is replaced in + // place (documented update path), keeping one offer per offerer. + e.ledger().with_mut(|li| { + li.timestamp = 4_600; + }); + client.make_offer(&offerer, &token_id, &700, &payment_token, &duration); + + let offers = client.get_offers(&token_id); + assert_eq!(offers.len(), 1); + assert_eq!(offers.get(0).unwrap().amount, 700); + assert_eq!(offers.get(0).unwrap().created_at, 4_600); + assert_eq!(offers.get(0).unwrap().expires_at, 4_600 + duration); +} + +// ============================================================================ +// Auction System Tests +// ============================================================================ + +#[test] +#[should_panic(expected = "Error(Contract, #6)")] // InvalidPrice +fn test_start_auction_zero_price_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + client.start_auction(&seller, &1, &0, &86400, &payment_token); +} + +#[test] +#[should_panic(expected = "Error(Contract, #19)")] // InvalidDuration +fn test_start_auction_zero_duration_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + client.start_auction(&seller, &1, &1000, &0, &payment_token); +} + +#[test] +fn test_place_bid() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let _bidder = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + let token_id = 1u32; + let starting_price = 1000_0000000i128; + let _bid_amount = 1200_0000000i128; + + client.start_auction(&seller, &token_id, &starting_price, &86400, &payment_token); + + // Note: In real test, setup token contract and balances + // client.place_bid(&bidder, &token_id, &bid_amount); + // let auction = client.get_auction(&token_id); + // assert_eq!(auction.current_bid, bid_amount); + // assert_eq!(auction.highest_bidder, Some(bidder)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #18)")] // BidTooLow +fn test_place_bid_too_low_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let bidder = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + let token_id = 1u32; + + client.start_auction(&seller, &token_id, &1000, &86400, &payment_token); + client.place_bid(&bidder, &token_id, &500); // Lower than starting price +} + +#[test] +#[should_panic(expected = "Error(Contract, #18)")] // BidTooLow +fn test_place_bid_not_high_enough_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let bidder = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + let starting_price = 1000i128; + + client.start_auction(&seller, &token_id, &starting_price, &86400, &payment_token); + + // current_bid starts at starting_price; bidding the exact same amount is <= current_bid, + // so it must be rejected with BidTooLow before any token transfer happens. + client.place_bid(&bidder, &token_id, &starting_price); +} + +#[test] +#[should_panic(expected = "Error(Contract, #16)")] // AuctionEnded +fn test_place_bid_after_auction_ends_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let bidder = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + let token_id = 1u32; + let duration = 86400u64; // 1 day + + client.start_auction(&seller, &token_id, &1000, &duration, &payment_token); + + // Fast forward time past auction end + e.ledger().with_mut(|li| { + li.timestamp = 86400 + 1; + }); + + client.place_bid(&bidder, &token_id, &1500); +} + +#[test] +fn test_auction_duration_boundary() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let bidder = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + let duration = 86400u64; + let starting_price = 1000i128; + + // Auction starts at timestamp 0, ends_at = 0 + duration = 86400 + client.start_auction( + &seller, + &token_id, + &starting_price, + &duration, + &payment_token, + ); + + // At timestamp 0 (start), bidding equal-to-current is rejected with BidTooLow, not AuctionEnded. + // This proves the time check passes (auction is active) but bid check fails. + let result_active = client.try_place_bid(&bidder, &token_id, &starting_price); + assert!( + result_active.is_err(), + "equal bid at auction start should fail" + ); + + // At ends_at - 1 (last active second): equal bid still fails with BidTooLow, not AuctionEnded. + e.ledger().with_mut(|li| { + li.timestamp = duration - 1; + }); + let result_last_second = client.try_place_bid(&bidder, &token_id, &starting_price); + assert!( + result_last_second.is_err(), + "equal bid one second before end should fail" + ); + + // At ends_at (expired): any bid is rejected with AuctionEnded. + e.ledger().with_mut(|li| { + li.timestamp = duration; + }); + let result_at_end = client.try_place_bid(&bidder, &token_id, &(starting_price + 1)); + let err = result_at_end.expect_err("bid at ends_at should fail"); + // Must fail with AuctionEnded (#16), not BidTooLow (#18) + assert_eq!(err.unwrap(), MarketplaceError::AuctionEnded); + + // At ends_at: end_auction should succeed + client.end_auction(&token_id); + let auction = client.get_auction(&token_id); + assert!(auction.ended); +} + +#[test] +#[should_panic(expected = "Error(Contract, #17)")] // AuctionNotEnded +fn test_end_auction_before_time_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, 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); + client.end_auction(&1); // Try to end immediately +} + +#[test] +#[should_panic(expected = "Error(Contract, #16)")] // AuctionEnded +fn test_end_auction_twice_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, 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.end_auction(&1); + client.end_auction(&1); // Should fail +} + +#[test] +fn test_auction_active_vs_ended() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + + client.start_auction(&seller, &token_id, &1000, &86400, &payment_token); + + // Should be in active auctions + let auctions = client.get_all_auctions(); + assert_eq!(auctions.len(), 1); + assert_eq!(auctions.get(0).unwrap().token_id, token_id); + + // End auction + e.ledger().with_mut(|li| { + li.timestamp = 86400 + 1; + }); + client.end_auction(&token_id); + + // Should NOT be in active auctions + let auctions_after = client.get_all_auctions(); + assert_eq!(auctions_after.len(), 0); + + // But still retrievable via get_auction + let auction = client.get_auction(&token_id); + assert!(auction.ended); +} + +#[test] +fn test_get_all_auctions() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + // Start 3 auctions + client.start_auction(&seller, &1, &1000, &86400, &payment_token); + client.start_auction(&seller, &2, &2000, &86400, &payment_token); + client.start_auction(&seller, &3, &3000, &86400, &payment_token); + + let auctions = client.get_all_auctions(); + assert_eq!(auctions.len(), 3); +} + +// ============================================================================ +// Issue #267: Unit tests for offers - duplicate offer, cancel, not maker +// ============================================================================ + +// Duplicate Offer Tests +#[test] +#[should_panic(expected = "Error(Contract, #13)")] // OfferExists +fn test_make_duplicate_offer_same_token_different_amount_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + + // Make first offer + client.make_offer(&offerer, &token_id, &500, &payment_token, &86400); + + // Try to make another offer with different amount - should fail + client.make_offer(&offerer, &token_id, &1000, &payment_token, &86400); +} + +#[test] +#[should_panic(expected = "Error(Contract, #13)")] // OfferExists +fn test_make_duplicate_offer_different_tokens_same_user_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer = Address::generate(&e); + let payment_token1 = setup_test_token(&e, &client); + let payment_token2 = setup_test_token(&e, &client); + + // Make offer on token 1 + client.make_offer(&offerer, &1, &500, &payment_token1, &86400); + + // Make offer on token 2 - should work (different token) + client.make_offer(&offerer, &2, &600, &payment_token2, &86400); + + // Try to make another offer on token 1 - should fail + client.make_offer(&offerer, &1, &700, &payment_token1, &86400); +} + +#[test] +fn test_different_users_can_offer_same_token() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer1 = Address::generate(&e); + let offerer2 = Address::generate(&e); + let offerer3 = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + + // Multiple users can offer on the same token + client.make_offer(&offerer1, &token_id, &500, &payment_token, &86400); + client.make_offer(&offerer2, &token_id, &600, &payment_token, &86400); + client.make_offer(&offerer3, &token_id, &700, &payment_token, &86400); + + let offers = client.get_offers(&token_id); + assert_eq!(offers.len(), 3); +} + +// Offer Cancellation Tests +#[test] +fn test_cancel_offer_removes_correct_offer_only() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer1 = Address::generate(&e); + let offerer2 = Address::generate(&e); + let offerer3 = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + + // Make multiple offers + client.make_offer(&offerer1, &token_id, &500, &payment_token, &86400); + client.make_offer(&offerer2, &token_id, &600, &payment_token, &86400); + client.make_offer(&offerer3, &token_id, &700, &payment_token, &86400); + + // Cancel middle offer + client.cancel_offer(&offerer2, &token_id); + + let offers = client.get_offers(&token_id); + assert_eq!(offers.len(), 2); + + // Verify correct offers remain + let mut found_500 = false; + let mut found_700 = false; + for o in offers.iter() { + if o.amount == 500 { + found_500 = true; + } + if o.amount == 700 { + found_700 = true; + } + assert_ne!(o.amount, 600); + } + assert!(found_500); + assert!(found_700); +} + +#[test] +fn test_cancel_last_offer_removes_storage() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + + // Make offer + client.make_offer(&offerer, &token_id, &500, &payment_token, &86400); + + // Verify offer exists + let offers = client.get_offers(&token_id); + assert_eq!(offers.len(), 1); + + // Cancel offer + client.cancel_offer(&offerer, &token_id); + + // Verify offers are empty + let offers = client.get_offers(&token_id); + assert_eq!(offers.len(), 0); +} + +#[test] +#[should_panic(expected = "Error(Contract, #11)")] // OfferNotFound +fn test_cancel_offer_after_accept_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let offerer = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + + // Make offer + client.make_offer(&offerer, &token_id, &500, &payment_token, &86400); + client.cancel_offer(&offerer, &token_id); + client.cancel_offer(&offerer, &token_id); +} + +#[test] +fn test_cancel_multiple_offers_same_user_different_tokens() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + + // Make offers on different tokens + client.make_offer(&offerer, &1, &500, &payment_token, &86400); + client.make_offer(&offerer, &2, &600, &payment_token, &86400); + client.make_offer(&offerer, &3, &700, &payment_token, &86400); + + // Cancel one offer + client.cancel_offer(&offerer, &2); + + // Verify other offers still exist + assert_eq!(client.get_offers(&1).len(), 1); + assert_eq!(client.get_offers(&2).len(), 0); + assert_eq!(client.get_offers(&3).len(), 1); +} + +// Not Maker Tests (Authorization Tests) +#[test] +#[should_panic(expected = "Error(Contract, #11)")] // OfferNotFound +fn test_non_maker_cannot_cancel_offer() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer = Address::generate(&e); + let non_maker = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + + // Make offer + client.make_offer(&offerer, &token_id, &500, &payment_token, &86400); + + // Try to cancel with different address - should fail + client.cancel_offer(&non_maker, &token_id); +} + +#[test] +#[should_panic(expected = "Error(Contract, #11)")] // OfferNotFound +fn test_different_offerer_cannot_cancel_other_offer() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer1 = Address::generate(&e); + let offerer2 = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + + // Make offers from different users + client.make_offer(&offerer1, &token_id, &500, &payment_token, &86400); + client.make_offer(&offerer2, &token_id, &600, &payment_token, &86400); + + let non_maker = Address::generate(&e); + client.cancel_offer(&non_maker, &token_id); +} + +#[test] +fn test_maker_can_cancel_own_offer_multiple_exist() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer1 = Address::generate(&e); + let offerer2 = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + + // Make offers from different users + client.make_offer(&offerer1, &token_id, &500, &payment_token, &86400); + client.make_offer(&offerer2, &token_id, &600, &payment_token, &86400); + + // offerer1 should be able to cancel their own offer + client.cancel_offer(&offerer1, &token_id); + + let offers = client.get_offers(&token_id); + assert_eq!(offers.len(), 1); + assert_eq!(offers.get(0).unwrap().offerer, offerer2); +} + +#[test] +#[should_panic(expected = "Error(Contract, #11)")] // OfferNotFound +fn test_cancel_nonexistent_offer_as_non_maker_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let non_maker = Address::generate(&e); + let token_id = 999u32; + + // Try to cancel offer that doesn't exist - should fail + client.cancel_offer(&non_maker, &token_id); +} + +#[test] +fn test_authorization_scenarios_comprehensive() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let offerer1 = Address::generate(&e); + let offerer2 = Address::generate(&e); + let offerer3 = Address::generate(&e); + let random_user = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + + // Create offers on multiple tokens + client.make_offer(&offerer1, &1, &100, &payment_token, &86400); + client.make_offer(&offerer2, &1, &200, &payment_token, &86400); + client.make_offer(&offerer1, &2, &300, &payment_token, &86400); + client.make_offer(&offerer3, &3, &400, &payment_token, &86400); + + // Each offerer can cancel their own offers + client.cancel_offer(&offerer1, &1); // Cancels offerer1's offer on token 1 + client.cancel_offer(&offerer1, &2); // Cancels offerer1's offer on token 2 + + // Verify remaining offers + assert_eq!(client.get_offers(&1).len(), 1); // Only offerer2's offer remains + assert_eq!(client.get_offers(&2).len(), 0); // offerer1's offer cancelled + assert_eq!(client.get_offers(&3).len(), 1); // offerer3's offer still exists + + // Random user cannot cancel any offers + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + client.cancel_offer(&random_user, &1); + })); + assert!(result.is_err()); +} + +// ============================================================================ +// Edge Cases and Integration Tests +// ============================================================================ + +#[test] +fn test_list_then_start_auction_same_token() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + let token_id = 1u32; + + // List NFT + client.list_nft(&seller, &token_id, &1000, &payment_token); + + // Cancel listing + client.cancel_listing(&seller, &token_id); + + // Now start auction (should work) + client.start_auction(&seller, &token_id, &1000, &86400, &payment_token); + + let auction = client.get_auction(&token_id); + assert_eq!(auction.token_id, token_id); +} + +#[test] +fn test_reentrancy_protection() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, _client) = setup_marketplace(&e); + + // The reentrancy guard prevents nested calls + // This is tested implicitly in the token transfer flows + // In production, you'd test with malicious contracts +} + +// ============================================================================ +// Gas / CPU Budget Profile Tests — Hot Paths (#272) +// +// These tests are designed to measure and document the resource consumption +// (CPU instructions and memory in Soroban) of the three hot paths: +// • buy_nft — fixed-price purchase +// • place_bid — auction bid (with previous-bidder refund) +// • end_auction — settle auction +// +// In the Soroban test environment the `budget()` API is available on `Env` +// when compiled with `features = ["testutils"]`. Each test records the +// budget consumed for a single hot-path invocation so that regressions are +// visible in CI output. +// +// NOTE: token transfers require a real deployed token contract. Where a +// live token contract is not available the test documents the *non-transfer* +// portion of the hot path (state reads/writes and event emission) and marks +// the transfer portion as a known stub. +// ============================================================================ + +// ============================================================================ +// Reentrancy Guard Unit Tests (Explicit) +// ============================================================================ + +/// @notice Test: list_nft fails if reentrancy guard is set. +#[test] +#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected +fn test_list_nft_reentrancy_guard() { + let e = Env::default(); + e.mock_all_auths(); + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + e.as_contract(&client.address, || { + e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + }); + client.list_nft(&seller, &1, &1000, &payment_token); +} + +/// @notice Test: cancel_listing fails if reentrancy guard is set. +#[test] +#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected +fn test_cancel_listing_reentrancy_guard() { + let e = Env::default(); + e.mock_all_auths(); + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + client.list_nft(&seller, &token_id, &1000, &payment_token); + e.as_contract(&client.address, || { + e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + }); + client.cancel_listing(&seller, &token_id); +} + +/// @notice Test: buy_nft fails if reentrancy guard is set. +#[test] +#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected +fn test_buy_nft_reentrancy_guard() { + let e = Env::default(); + e.mock_all_auths(); + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let buyer = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + client.list_nft(&seller, &token_id, &1000, &payment_token); + e.as_contract(&client.address, || { + e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + }); + client.buy_nft(&buyer, &token_id); +} + +/// @notice Test: make_offer fails if reentrancy guard is set. +#[test] +#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected +fn test_make_offer_reentrancy_guard() { + let e = Env::default(); + e.mock_all_auths(); + let (_, _, client) = setup_marketplace(&e); + let offerer = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + e.as_contract(&client.address, || { + e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + }); + client.make_offer(&offerer, &1, &500, &payment_token, &86400); +} + +/// @notice Test: accept_offer fails if reentrancy guard is set. +#[test] +#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected +fn test_accept_offer_reentrancy_guard() { + let e = Env::default(); + e.mock_all_auths(); + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let offerer = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + client.list_nft(&seller, &token_id, &1000, &payment_token); + client.make_offer(&offerer, &token_id, &500, &payment_token, &86400); + e.as_contract(&client.address, || { + e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + }); + client.accept_offer(&seller, &token_id, &offerer); +} + +/// @notice Test: start_auction fails if reentrancy guard is set. +#[test] +#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected +fn test_start_auction_reentrancy_guard() { + let e = Env::default(); + e.mock_all_auths(); + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + e.as_contract(&client.address, || { + e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + }); + client.start_auction(&seller, &1, &1000, &86400, &payment_token); +} + +/// @notice Test: place_bid fails if reentrancy guard is set. +#[test] +#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected +fn test_place_bid_reentrancy_guard() { + let e = Env::default(); + e.mock_all_auths(); + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let bidder = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + client.start_auction(&seller, &token_id, &1000, &86400, &payment_token); + e.as_contract(&client.address, || { + e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + }); + client.place_bid(&bidder, &token_id, &1200); +} + +/// @notice Test: end_auction fails if reentrancy guard is set. +#[test] +#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected +fn test_end_auction_reentrancy_guard() { + let e = Env::default(); + e.mock_all_auths(); + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let payment_token = setup_test_token(&e, &client); + let token_id = 1u32; + client.start_auction(&seller, &token_id, &1000, &1, &payment_token); + e.ledger().with_mut(|li| { + li.timestamp = 2; + }); + e.as_contract(&client.address, || { + e.storage().instance().set(&DataKey::ReentrancyGuard, &true); + }); + client.end_auction(&token_id); +} + +#[test] +fn test_gas_listing_operations() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + + let seller = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + // Measure operations for optimization + let start = e.ledger().sequence(); + + for i in 0..10 { + client.list_nft(&seller, &i, &1000, &payment_token); + } + + let end = e.ledger().sequence(); + let _operations = end - start; + + assert_eq!(client.get_all_listings().len(), 10); +} + +#[test] +fn test_add_and_remove_payment_token() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + let payment_token = Address::generate(&e); + + assert!(!client.is_payment_token_allowed(&payment_token)); + + client.add_payment_token(&payment_token); + assert!(client.is_payment_token_allowed(&payment_token)); + assert_eq!(client.get_allowed_payment_tokens().len(), 1); + + client.remove_payment_token(&payment_token); + assert!(!client.is_payment_token_allowed(&payment_token)); + assert_eq!(client.get_allowed_payment_tokens().len(), 0); +} + +#[test] +#[should_panic(expected = "Error(Contract, #22)")] // PaymentTokenNotAllowed +fn test_list_nft_with_unallowlisted_token_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let payment_token = Address::generate(&e); + + client.list_nft(&seller, &1, &1000, &payment_token); +} + +#[test] +#[should_panic(expected = "Error(Contract, #22)")] // PaymentTokenNotAllowed +fn test_make_offer_with_unallowlisted_token_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + let offerer = Address::generate(&e); + let payment_token = Address::generate(&e); + + client.make_offer(&offerer, &1, &1000, &payment_token, &86400); +} + +#[test] +#[should_panic(expected = "Error(Contract, #22)")] // PaymentTokenNotAllowed +fn test_start_auction_with_unallowlisted_token_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let payment_token = Address::generate(&e); + + client.start_auction(&seller, &1, &1000, &86400, &payment_token); +} + +#[test] +#[should_panic(expected = "Error(Contract, #22)")] // PaymentTokenNotAllowed +fn test_buy_nft_after_payment_token_is_removed_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let buyer = Address::generate(&e); + let token_admin = Address::generate(&e); + let token = e.register_stellar_asset_contract_v2(token_admin); + let payment_token = token.address(); + client.add_payment_token(&payment_token); + soroban_sdk::token::StellarAssetClient::new(&e, &payment_token).mint(&buyer, &10_000); + + client.list_nft(&seller, &1, &1000, &payment_token); + client.remove_payment_token(&payment_token); + client.buy_nft(&buyer, &1); +} + +// ============================================================================ +// Royalty Accounting and Settlement Invariant Tests +// ============================================================================ + +#[test] +fn test_royalty_configuration_is_visible_for_active_listing() { + let e = Env::default(); + e.mock_all_auths(); + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let recipient = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + client.list_nft(&seller, &7, &10_000, &payment_token); + client.set_royalty(&seller, &7, &recipient, &375); + + let royalty = client.get_royalty(&7).expect("royalty must be stored"); + assert_eq!(royalty.recipient, recipient); + assert_eq!(royalty.basis_points, 375); + assert_eq!(client.get_all_listings().len(), 1); +} + +#[test] +fn test_zero_royalty_keeps_sale_amount_conserved() { + let e = Env::default(); + e.mock_all_auths(); + let (_, fee_recipient, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let buyer = Address::generate(&e); + let royalty_recipient = Address::generate(&e); + let token_admin = Address::generate(&e); + let token = e.register_stellar_asset_contract_v2(token_admin); + let payment_token = token.address(); + client.add_payment_token(&payment_token); + let price = 100_003i128; + soroban_sdk::token::StellarAssetClient::new(&e, &payment_token).mint(&buyer, &price); + + client.list_nft(&seller, &1, &price, &payment_token); + client.set_royalty(&seller, &1, &royalty_recipient, &0); + let buyer_before = soroban_sdk::token::Client::new(&e, &payment_token).balance(&buyer); + client.buy_nft(&buyer, &1); + + let payment_client = soroban_sdk::token::Client::new(&e, &payment_token); + let seller_received = payment_client.balance(&seller); + let fee_received = payment_client.balance(&fee_recipient); + let royalty_received = payment_client.balance(&royalty_recipient); + let buyer_after = payment_client.balance(&buyer); + assert_eq!(seller_received + fee_received + royalty_received, price); + assert_eq!(buyer_before - buyer_after, price); + assert!(client.get_royalty(&1).is_none()); +} + +#[test] +fn test_primary_sale_splits_fee_and_royalty_without_value_creation() { + let e = Env::default(); + e.mock_all_auths(); + let (_, fee_recipient, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let buyer = Address::generate(&e); + let royalty_recipient = Address::generate(&e); + let token_admin = Address::generate(&e); + let token = e.register_stellar_asset_contract_v2(token_admin); + let payment_token = token.address(); + client.add_payment_token(&payment_token); + let sale_amount = 1_234_567i128; + soroban_sdk::token::StellarAssetClient::new(&e, &payment_token).mint(&buyer, &sale_amount); + + client.list_nft(&seller, &9, &sale_amount, &payment_token); + client.set_royalty(&seller, &9, &royalty_recipient, &500); + client.buy_nft(&buyer, &9); + + let payment_client = soroban_sdk::token::Client::new(&e, &payment_token); + let fee = sale_amount * 250 / 10_000; + let royalty = sale_amount * 500 / 10_000; + let seller = payment_client.balance(&seller); + let fee_recipient = payment_client.balance(&fee_recipient); + let royalty_recipient = payment_client.balance(&royalty_recipient); + assert_eq!(fee_recipient, fee); + assert_eq!(royalty_recipient, royalty); + assert_eq!(seller + fee_recipient + royalty_recipient, sale_amount); +} + +#[test] +fn test_rounding_policy_conserves_every_small_sale_unit() { + let e = Env::default(); + e.mock_all_auths(); + let (_, fee_recipient, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let buyer = Address::generate(&e); + let royalty_recipient = Address::generate(&e); + let token_admin = Address::generate(&e); + let token = e.register_stellar_asset_contract_v2(token_admin); + let payment_token = token.address(); + client.add_payment_token(&payment_token); + let sale_amount = 101i128; + soroban_sdk::token::StellarAssetClient::new(&e, &payment_token).mint(&buyer, &sale_amount); + + client.list_nft(&seller, &3, &sale_amount, &payment_token); + client.set_royalty(&seller, &3, &royalty_recipient, &333); + client.buy_nft(&buyer, &3); + + let payment_client = soroban_sdk::token::Client::new(&e, &payment_token); + assert_eq!(payment_client.balance(&seller), 96); + assert_eq!(payment_client.balance(&fee_recipient), 2); + assert_eq!(payment_client.balance(&royalty_recipient), 3); + assert_eq!( + payment_client.balance(&seller) + + payment_client.balance(&fee_recipient) + + payment_client.balance(&royalty_recipient), + sale_amount + ); +} + +#[test] +#[should_panic(expected = "Error(Contract, #27)")] +fn test_royalty_above_policy_maximum_is_rejected() { + let e = Env::default(); + e.mock_all_auths(); + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let recipient = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + client.list_nft(&seller, &5, &1000, &payment_token); + client.set_royalty(&seller, &5, &recipient, &(MAX_ROYALTY_BASIS_POINTS + 1)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #30)")] +fn test_only_listing_seller_can_update_royalty() { + let e = Env::default(); + e.mock_all_auths(); + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let attacker = Address::generate(&e); + let recipient = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + client.list_nft(&seller, &6, &1000, &payment_token); + client.set_royalty(&attacker, &6, &recipient, &500); +} + +#[test] +#[should_panic(expected = "Error(Contract, #29)")] +fn test_initialization_rejects_fee_over_sale_amount() { + let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); + let nft_contract = Address::generate(&e); + let fee_recipient = Address::generate(&e); + let marketplace_id = e.register_contract(None, CommitmentMarketplace); + let client = CommitmentMarketplaceClient::new(&e, &marketplace_id); + + client.initialize(&admin, &nft_contract, &10_001, &fee_recipient); +} + +#[test] +#[should_panic(expected = "Error(Contract, #29)")] +fn test_fee_update_rejects_percentage_above_one_hundred() { + let e = Env::default(); + e.mock_all_auths(); + let (_, _, client) = setup_marketplace(&e); + client.update_fee(&10_001); +} + +#[test] +fn test_failed_payment_keeps_listing_and_royalty_state() { + let e = Env::default(); + e.mock_all_auths(); + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let buyer = Address::generate(&e); + let recipient = Address::generate(&e); + let token_admin = Address::generate(&e); + let token = e.register_stellar_asset_contract_v2(token_admin); + let payment_token = token.address(); + client.add_payment_token(&payment_token); + soroban_sdk::token::StellarAssetClient::new(&e, &payment_token).mint(&buyer, &1); + client.list_nft(&seller, &11, &1000, &payment_token); + client.set_royalty(&seller, &11, &recipient, &500); + + let result = client.try_buy_nft(&buyer, &11); + assert!(result.is_err(), "an underfunded buyer must not settle"); + let listing = client.get_listing(&11); + assert_eq!(listing.price, 1000); + assert_eq!(client.get_royalty(&11).unwrap().basis_points, 500); +} + +#[test] +fn test_duplicate_settlement_cannot_reuse_consumed_royalty() { + let e = Env::default(); + e.mock_all_auths(); + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let buyer = Address::generate(&e); + let recipient = Address::generate(&e); + let token_admin = Address::generate(&e); + let token = e.register_stellar_asset_contract_v2(token_admin); + let payment_token = token.address(); + client.add_payment_token(&payment_token); + soroban_sdk::token::StellarAssetClient::new(&e, &payment_token).mint(&buyer, &1000); + + client.list_nft(&seller, &12, &1000, &payment_token); + client.set_royalty(&seller, &12, &recipient, &500); + client.buy_nft(&buyer, &12); + let second_attempt = client.try_buy_nft(&buyer, &12); + assert!(second_attempt.is_err()); + assert!(client.get_royalty(&12).is_some()); + assert!(client.get_listing(&12).is_ok()); +} + +#[test] +fn test_cancel_listing_removes_unsettled_royalty_configuration() { + let e = Env::default(); + e.mock_all_auths(); + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let recipient = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + + client.list_nft(&seller, &13, &1000, &payment_token); + client.set_royalty(&seller, &13, &recipient, &250); + client.cancel_listing(&seller, &13); + + assert!(client.get_royalty(&13).is_none()); + assert!(client.get_listing(&13).is_err()); +} + +#[test] +fn test_maximum_allowed_royalty_still_leaves_seller_proceeds() { + let e = Env::default(); + e.mock_all_auths(); + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let recipient = Address::generate(&e); + let payment_token = setup_allowed_payment_token(&e, &client); + let sale_amount = 10_000i128; + + client.list_nft(&seller, &14, &sale_amount, &payment_token); + client.set_royalty(&seller, &14, &recipient, &MAX_ROYALTY_BASIS_POINTS); + let royalty = client.get_royalty(&14).unwrap(); + assert_eq!(royalty.basis_points, MAX_ROYALTY_BASIS_POINTS); + assert_eq!(client.get_listing(&14).unwrap().price, sale_amount); +} + +// Emergency pause and recovery invariants (GrantFox #553) +// ============================================================================ + +#[test] +fn test_pause_starts_disabled_and_emits_ordered_pause_event() { + 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()); + + let events = e.events().all(); + assert_eq!(events.len(), 1); + let last = events.last().unwrap(); + assert_eq!(last.1.get(0).unwrap(), symbol_short!("Pause").into_val(&e)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #24)")] +fn test_repeated_pause_is_rejected_without_state_change() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + + client.pause(&admin); + client.pause(&admin); +} + +#[test] +#[should_panic(expected = "Error(Contract, #25)")] +fn test_repeated_unpause_is_rejected() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + + client.unpause(&admin); +} + +#[test] +#[should_panic(expected = "Error(Contract, #26)")] +fn test_non_admin_cannot_toggle_pause() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + let attacker = Address::generate(&e); + assert_ne!(admin, attacker); + + client.pause(&attacker); +} + +#[test] +fn test_unpause_restores_normal_mutations_without_changing_views() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let token = setup_allowed_payment_token(&e, &client); + + client.pause(&admin); + assert!(client.is_paused()); + assert_eq!(client.get_all_listings().len(), 0); + client.unpause(&admin); + assert!(!client.is_paused()); + client.list_nft(&seller, &77, &1_000, &token); + assert_eq!(client.get_listing(&77).unwrap().price, 1_000); +} + +#[test] +#[should_panic(expected = "Error(Contract, #23)")] +fn test_pause_blocks_listing_without_writing_state() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let token = setup_allowed_payment_token(&e, &client); + + client.pause(&admin); + client.list_nft(&seller, &10, &1_000, &token); +} + +#[test] +#[should_panic(expected = "Error(Contract, #23)")] +fn test_pause_blocks_buy_before_payment_or_listing_mutation() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + let buyer = Address::generate(&e); + + client.pause(&admin); + client.buy_nft(&buyer, &10); +} + +#[test] +#[should_panic(expected = "Error(Contract, #23)")] +fn test_pause_blocks_offer_creation() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + let offerer = Address::generate(&e); + let token = setup_allowed_payment_token(&e, &client); + + client.pause(&admin); + client.make_offer(&offerer, &10, &1_000, &token, &86400); +} + +#[test] +#[should_panic(expected = "Error(Contract, #23)")] +fn test_pause_blocks_auction_start_and_bids() { + 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 token = setup_allowed_payment_token(&e, &client); + + client.pause(&admin); + client.start_auction(&seller, &11, &1_000, &100, &token); + client.place_bid(&bidder, &11, &1_100); +} + +#[test] +fn test_cancel_listing_remains_available_as_paused_recovery_path() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let token = setup_allowed_payment_token(&e, &client); + + client.list_nft(&seller, &12, &1_000, &token); + client.pause(&admin); + client.cancel_listing(&seller, &12); + assert_eq!(client.get_all_listings().len(), 0); + assert!(client.is_paused()); +} + +#[test] +fn test_cancel_offer_remains_available_as_paused_recovery_path() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + let offerer = Address::generate(&e); + let token = setup_allowed_payment_token(&e, &client); + + client.make_offer(&offerer, &13, &1_000, &token, &86400); + client.pause(&admin); + client.cancel_offer(&offerer, &13); + assert_eq!(client.get_offers(&13).len(), 0); + assert!(client.is_paused()); +} + +#[test] +fn test_pause_preserves_existing_listing_snapshot() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let token = setup_allowed_payment_token(&e, &client); + + client.list_nft(&seller, &14, &42_000, &token); + let before = client.get_listing(&14).unwrap(); + client.pause(&admin); + let after = client.get_listing(&14).unwrap(); + assert_eq!(before, after); + assert_eq!(client.get_all_listings().len(), 1); +} + +#[test] +#[should_panic(expected = "Error(Contract, #23)")] +fn test_pause_blocks_fee_configuration() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + + client.pause(&admin); + client.update_fee(&500); +} + +#[test] +#[should_panic(expected = "Error(Contract, #23)")] +fn test_pause_blocks_payment_token_allowlist_changes() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + let token = Address::generate(&e); + + client.pause(&admin); + client.add_payment_token(&token); +} + +#[test] +fn test_pause_and_unpause_emit_only_successful_transition_events_in_order() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + + client.pause(&admin); + client.unpause(&admin); + + let events = e.events().all(); + assert_eq!(events.len(), 2); + assert_eq!( + events.first().unwrap().1.get(0).unwrap(), + symbol_short!("Pause").into_val(&e) + ); + assert_eq!( + events.last().unwrap().1.get(0).unwrap(), + symbol_short!("Unpause").into_val(&e) + ); + assert!(!client.is_paused()); +} + +#[test] +fn test_multiple_pause_cycles_preserve_listing_ownership_snapshot() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let token = setup_allowed_payment_token(&e, &client); + + client.list_nft(&seller, &88, &9_999, &token); + let snapshot = client.get_listing(&88).unwrap(); + for _ in 0..3 { + client.pause(&admin); + assert_eq!(client.get_listing(&88).unwrap(), snapshot); + client.unpause(&admin); + assert_eq!(client.get_listing(&88).unwrap(), snapshot); + } + assert!(!client.is_paused()); + assert_eq!(client.get_all_listings().len(), 1); +} + +#[test] +#[should_panic(expected = "Error(Contract, #23)")] +fn test_pause_blocks_offer_acceptance() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let offerer = Address::generate(&e); + + client.pause(&admin); + client.accept_offer(&seller, &20, &offerer); +} + +#[test] +#[should_panic(expected = "Error(Contract, #23)")] +fn test_pause_blocks_bid_submission() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + let bidder = Address::generate(&e); + + client.pause(&admin); + client.place_bid(&bidder, &20, &2_000); +} + +#[test] +#[should_panic(expected = "Error(Contract, #23)")] +fn test_pause_blocks_allowlist_removal() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + let token = setup_allowed_payment_token(&e, &client); + + client.pause(&admin); + client.remove_payment_token(&token); +} + +#[test] +fn test_auction_can_be_ended_as_recovery_without_bids_while_paused() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let token = setup_allowed_payment_token(&e, &client); + + client.start_auction(&seller, &21, &1_000, &10, &token); + e.ledger().with_mut(|ledger| ledger.timestamp = 11); + client.pause(&admin); + client.end_auction(&21); + + assert!(client.get_auction(&21).unwrap().ended); + assert!(client.is_paused()); +} + +#[test] +fn test_pausing_does_not_change_active_auction_snapshot() { + let e = Env::default(); + e.mock_all_auths(); + let (admin, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let token = setup_allowed_payment_token(&e, &client); + + client.start_auction(&seller, &22, &7_500, &100, &token); + let before = client.get_auction(&22).unwrap(); + client.pause(&admin); + let after = client.get_auction(&22).unwrap(); + assert_eq!(before, after); + assert_eq!(client.get_all_auctions().len(), 1); +} diff --git a/docs/commitment_marketplace.md b/docs/commitment_marketplace.md index 7cbb25d..f237858 100644 --- a/docs/commitment_marketplace.md +++ b/docs/commitment_marketplace.md @@ -1,44 +1,44 @@ -# Commitment Marketplace Contract - -This page documents the public entry points, access control, and security notes for the `commitment_marketplace` Soroban contract. It is intended for integrators and auditors. - -## Entry Points - -| Function | Summary | Access Control | Errors / 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 | -| 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 | -| make_offer | Make offer on NFT | Offerer require_auth | Fails if amount <= 0 or duplicate offer | -| accept_offer | Accept offer on NFT | Seller require_auth | Fails if offer not found or not initialized | -| cancel_offer | Cancel offer | Offerer require_auth | Fails if offer not found | -| start_auction | Start auction for NFT | Seller require_auth | Fails if price/duration invalid or auction exists | -| place_bid | Place bid on auction | Bidder require_auth | Fails if bid too low, ended, or self-bid | -| end_auction | End auction and settle | Anyone (time-gated) | Fails if not ended, already ended, or not found | -| get_listing | Get listing details | View | Fails if not found | -| get_all_listings | Get all active listings | View | | -| get_offers | Get all offers for NFT | View | | -| get_auction | Get auction details | View | Fails if not found | -| get_all_auctions | Get all active auctions | View | | - -## Security -- All state-changing entry points require authentication (`require_auth`) for the relevant actor, except `end_auction` (which is time-gated). -- Reentrancy guard is enforced on all entry points that mutate state and/or make external calls. -- Arithmetic is performed using checked math; integer division truncates toward zero. -- No cross-contract NFT ownership checks or transfers are performed in this implementation (see contract comments). -- All token transfers use Soroban token interface. - -## Reentrancy Guard -- All mutating entry points set/check/clear a `ReentrancyGuard` storage key. -- If the guard is set, the contract returns `MarketplaceError::ReentrancyDetected`. -- See contract and tests for explicit coverage. - -## Integrator Notes -- Integrators should expect deterministic errors for all invalid operations. -- All public APIs are documented with Rustdoc/NatSpec comments in the contract source. -- See also: `docs/CONTRACT_FUNCTIONS.md` for cross-contract summary. - -## Changelog -- 2026-03-25: Added explicit reentrancy guard tests and NatSpec documentation. This page created for integrator clarity. +# Commitment Marketplace Contract + +This page documents the public entry points, access control, and security notes for the `commitment_marketplace` Soroban contract. It is intended for integrators and auditors. + +## Entry Points + +| Function | Summary | Access Control | Errors / 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 | +| 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 | +| make_offer | Make offer on NFT, expires after `duration` | Offerer require_auth | Fails if amount <= 0, duration == 0/overflows, or a live duplicate offer exists | +| accept_offer | Accept offer on NFT | Seller require_auth | Fails if offer not found, expired (`OfferExpired`), or not initialized | +| cancel_offer | Cancel offer | Offerer require_auth | Fails if offer not found | +| start_auction | Start auction for NFT | Seller require_auth | Fails if price/duration invalid or auction exists | +| place_bid | Place bid on auction | Bidder require_auth | Fails if bid too low, ended, or self-bid | +| end_auction | End auction and settle | Anyone (time-gated) | Fails if not ended, already ended, or not found | +| get_listing | Get listing details | View | Fails if not found | +| get_all_listings | Get all active listings | View | | +| get_offers | Get all offers for NFT | View | | +| get_auction | Get auction details | View | Fails if not found | +| get_all_auctions | Get all active auctions | View | | + +## Security +- All state-changing entry points require authentication (`require_auth`) for the relevant actor, except `end_auction` (which is time-gated). +- Reentrancy guard is enforced on all entry points that mutate state and/or make external calls. +- Arithmetic is performed using checked math; integer division truncates toward zero. +- No cross-contract NFT ownership checks or transfers are performed in this implementation (see contract comments). +- All token transfers use Soroban token interface. + +## Reentrancy Guard +- All mutating entry points set/check/clear a `ReentrancyGuard` storage key. +- If the guard is set, the contract returns `MarketplaceError::ReentrancyDetected`. +- See contract and tests for explicit coverage. + +## Integrator Notes +- Integrators should expect deterministic errors for all invalid operations. +- All public APIs are documented with Rustdoc/NatSpec comments in the contract source. +- See also: `docs/CONTRACT_FUNCTIONS.md` for cross-contract summary. + +## Changelog +- 2026-03-25: Added explicit reentrancy guard tests and NatSpec documentation. This page created for integrator clarity.