From 2a96e9b7cd238006cf8965901cecea94a984c23c Mon Sep 17 00:00:00 2001 From: D'Angelo Rodriguez <70290504+dangelo352@users.noreply.github.com> Date: Sun, 21 Jun 2026 21:46:07 -0400 Subject: [PATCH] feat: add marketplace bid increment anti-sniping --- contracts/commitment_marketplace/README.md | 27 +++- contracts/commitment_marketplace/src/lib.rs | 115 ++++++++++++++++- contracts/commitment_marketplace/src/tests.rs | 120 ++++++++++++++++++ docs/MARKETPLACE_LISTING_LIFECYCLE.md | 17 ++- docs/commitment_marketplace.md | 7 + 5 files changed, 279 insertions(+), 7 deletions(-) diff --git a/contracts/commitment_marketplace/README.md b/contracts/commitment_marketplace/README.md index 3538ed39..2051d699 100644 --- a/contracts/commitment_marketplace/README.md +++ b/contracts/commitment_marketplace/README.md @@ -187,6 +187,17 @@ marketplace.place_bid( ) ``` +### Configure Auction Bidding + +```rust +marketplace.update_auction_settings( + min_bid_increment, + anti_sniping_window, + anti_sniping_extension, + max_anti_sniping_extension +) +``` + ### End Auction ```rust @@ -347,7 +358,21 @@ fn place_bid( ) -> Result<(), MarketplaceError> ``` -Place a bid on an active auction. +Place a bid on an active auction. When configured by `update_auction_settings`, bids must satisfy the absolute minimum increment and bids inside the anti-sniping window extend `ends_at` until the per-auction extension cap is reached. With default zero settings, bids only need to remain strictly higher than the current bid. + +#### `update_auction_settings` + +```rust +fn update_auction_settings( + e: Env, + min_bid_increment: i128, + anti_sniping_window: u64, + anti_sniping_extension: u64, + max_anti_sniping_extension: u64, +) -> Result<(), MarketplaceError> +``` + +Configure auction bid increments and anti-sniping extensions. #### `end_auction` diff --git a/contracts/commitment_marketplace/src/lib.rs b/contracts/commitment_marketplace/src/lib.rs index 71a602ce..2f85dca3 100644 --- a/contracts/commitment_marketplace/src/lib.rs +++ b/contracts/commitment_marketplace/src/lib.rs @@ -120,9 +120,20 @@ pub struct Auction { pub payment_token: Address, pub started_at: u64, pub ends_at: u64, + pub extension_seconds: u64, pub ended: bool, } +/// Auction bid increment and anti-sniping settings. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuctionSettings { + pub min_bid_increment: i128, + pub anti_sniping_window: u64, + pub anti_sniping_extension: u64, + pub max_anti_sniping_extension: u64, +} + /// Storage keys #[contracttype] pub enum DataKey { @@ -148,6 +159,8 @@ pub enum DataKey { Auction(u32), /// Active auctions list ActiveAuctions, + /// Auction increment and anti-sniping settings + AuctionSettings, /// Reentrancy guard ReentrancyGuard, } @@ -200,6 +213,18 @@ fn require_allowed_payment_token( Ok(()) } +fn read_auction_settings(e: &Env) -> AuctionSettings { + e.storage() + .instance() + .get(&DataKey::AuctionSettings) + .unwrap_or(AuctionSettings { + min_bid_increment: 0, + anti_sniping_window: 0, + anti_sniping_extension: 0, + max_anti_sniping_extension: 0, + }) +} + #[contractimpl] impl CommitmentMarketplace { // ======================================================================== @@ -253,6 +278,16 @@ impl CommitmentMarketplace { .instance() .set(&DataKey::AllowedPaymentTokens, &allowed_payment_tokens); + e.storage().instance().set( + &DataKey::AuctionSettings, + &AuctionSettings { + min_bid_increment: 0, + anti_sniping_window: 0, + anti_sniping_extension: 0, + max_anti_sniping_extension: 0, + }, + ); + Ok(()) } @@ -282,6 +317,51 @@ impl CommitmentMarketplace { Ok(()) } + /// @notice Update auction minimum bid increment and anti-sniping settings. + /// @param min_bid_increment Absolute minimum amount a new bid must exceed the current bid by. Zero preserves existing strictly-higher bidding. + /// @param anti_sniping_window Seconds before `ends_at` that trigger an extension. Zero disables anti-sniping. + /// @param anti_sniping_extension Seconds added when a bid lands inside the anti-sniping window. + /// @param max_anti_sniping_extension Total extension seconds allowed per auction. + /// @dev Only callable by admin. Existing auctions use the latest settings when bids are placed. + /// @error MarketplaceError::InvalidPrice if min_bid_increment is negative. + /// @security Only callable by admin (require_auth). + pub fn update_auction_settings( + e: Env, + min_bid_increment: i128, + anti_sniping_window: u64, + anti_sniping_extension: u64, + max_anti_sniping_extension: u64, + ) -> Result<(), MarketplaceError> { + let admin: Address = Self::get_admin(e.clone())?; + admin.require_auth(); + + if min_bid_increment < 0 { + return Err(MarketplaceError::InvalidPrice); + } + + let settings = AuctionSettings { + min_bid_increment, + anti_sniping_window, + anti_sniping_extension, + max_anti_sniping_extension, + }; + + e.storage() + .instance() + .set(&DataKey::AuctionSettings, &settings); + e.events().publish( + (symbol_short!("AucCfg"),), + ( + min_bid_increment, + anti_sniping_window, + anti_sniping_extension, + max_anti_sniping_extension, + ), + ); + + Ok(()) + } + /// Add a token contract to the payment-token allowlist. /// /// # Arguments @@ -1075,6 +1155,7 @@ impl CommitmentMarketplace { payment_token: payment_token.clone(), started_at, ends_at, + extension_seconds: 0, ended: false, }; @@ -1154,7 +1235,17 @@ impl CommitmentMarketplace { return Err(MarketplaceError::AuctionEnded); } - if bid_amount <= auction.current_bid { + let settings = read_auction_settings(&e); + let bid_too_low = if settings.min_bid_increment == 0 { + bid_amount <= auction.current_bid + } else { + match auction.current_bid.checked_add(settings.min_bid_increment) { + Some(minimum_bid) => bid_amount < minimum_bid, + None => true, + } + }; + + if bid_too_low { e.storage() .instance() .set(&DataKey::ReentrancyGuard, &false); @@ -1181,6 +1272,26 @@ impl CommitmentMarketplace { auction.current_bid = bid_amount; auction.highest_bidder = Some(bidder.clone()); + if settings.anti_sniping_window > 0 + && settings.anti_sniping_extension > 0 + && settings.max_anti_sniping_extension > auction.extension_seconds + && auction.ends_at.saturating_sub(current_time) <= settings.anti_sniping_window + { + let remaining_extension = + settings.max_anti_sniping_extension - auction.extension_seconds; + let extension = if settings.anti_sniping_extension < remaining_extension { + settings.anti_sniping_extension + } else { + remaining_extension + }; + auction.ends_at = auction.ends_at.checked_add(extension).ok_or_else(|| { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + MarketplaceError::InvalidDuration + })?; + auction.extension_seconds += extension; + } e.storage() .persistent() @@ -1396,4 +1507,4 @@ impl CommitmentMarketplace { auctions } -} \ No newline at end of file +} diff --git a/contracts/commitment_marketplace/src/tests.rs b/contracts/commitment_marketplace/src/tests.rs index 4163b6a7..1638035e 100644 --- a/contracts/commitment_marketplace/src/tests.rs +++ b/contracts/commitment_marketplace/src/tests.rs @@ -65,6 +65,22 @@ fn setup_allowed_payment_token(e: &Env, client: &CommitmentMarketplaceClient<'_> payment_token } +fn setup_stellar_payment_token( + e: &Env, + client: &CommitmentMarketplaceClient<'_>, + holders: &[&Address], +) -> Address { + 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 token_client = soroban_sdk::token::StellarAssetClient::new(e, &payment_token); + for holder in holders { + token_client.mint(holder, &10_000); + } + payment_token +} + // ============================================================================ // Initialization Tests // ============================================================================ @@ -590,6 +606,110 @@ fn test_auction_duration_boundary() { assert!(auction.ended); } +#[test] +#[should_panic(expected = "Error(Contract, #18)")] // BidTooLow +fn test_place_bid_below_min_increment_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.update_auction_settings(&100, &0, &0, &0); + client.start_auction(&seller, &token_id, &1000, &86400, &payment_token); + client.place_bid(&bidder, &token_id, &1099); +} + +#[test] +fn test_place_bid_exact_min_increment_succeeds() { + let e = Env::default(); + e.mock_all_auths_allowing_non_root_auth(); + + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let bidder = Address::generate(&e); + let payment_token = setup_stellar_payment_token(&e, &client, &[&bidder]); + let token_id = 1u32; + + client.update_auction_settings(&100, &0, &0, &0); + client.start_auction(&seller, &token_id, &1000, &86400, &payment_token); + client.place_bid(&bidder, &token_id, &1100); + + let auction = client.get_auction(&token_id); + assert_eq!(auction.current_bid, 1100); + assert_eq!(auction.highest_bidder, Some(bidder)); + assert_eq!(auction.ends_at, 86400); + assert_eq!(auction.extension_seconds, 0); +} + +#[test] +fn test_place_bid_in_sniping_window_extends_auction() { + let e = Env::default(); + e.mock_all_auths_allowing_non_root_auth(); + + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let bidder = Address::generate(&e); + let payment_token = setup_stellar_payment_token(&e, &client, &[&bidder]); + let token_id = 1u32; + + client.update_auction_settings(&0, &60, &300, &600); + client.start_auction(&seller, &token_id, &1000, &1000, &payment_token); + e.ledger().with_mut(|li| { + li.timestamp = 950; + }); + + client.place_bid(&bidder, &token_id, &1100); + + let auction = client.get_auction(&token_id); + assert_eq!(auction.ends_at, 1300); + assert_eq!(auction.extension_seconds, 300); +} + +#[test] +fn test_anti_sniping_extension_respects_total_cap() { + let e = Env::default(); + e.mock_all_auths_allowing_non_root_auth(); + + let (_, _, client) = setup_marketplace(&e); + let seller = Address::generate(&e); + let bidder1 = Address::generate(&e); + let bidder2 = Address::generate(&e); + let payment_token = setup_stellar_payment_token(&e, &client, &[&bidder1, &bidder2]); + let token_id = 1u32; + + client.update_auction_settings(&0, &500, &300, &500); + client.start_auction(&seller, &token_id, &1000, &1000, &payment_token); + + e.ledger().with_mut(|li| { + li.timestamp = 950; + }); + client.place_bid(&bidder1, &token_id, &1100); + + e.ledger().with_mut(|li| { + li.timestamp = 1250; + }); + client.place_bid(&bidder2, &token_id, &1200); + + let auction = client.get_auction(&token_id); + assert_eq!(auction.ends_at, 1500); + assert_eq!(auction.extension_seconds, 500); + assert_eq!(auction.highest_bidder, Some(bidder2)); +} + +#[test] +#[should_panic(expected = "Error(Contract, #6)")] // InvalidPrice +fn test_update_auction_settings_negative_increment_fails() { + let e = Env::default(); + e.mock_all_auths(); + + let (_, _, client) = setup_marketplace(&e); + client.update_auction_settings(&-1, &60, &300, &600); +} + #[test] #[should_panic(expected = "Error(Contract, #17)")] // AuctionNotEnded fn test_end_auction_before_time_fails() { diff --git a/docs/MARKETPLACE_LISTING_LIFECYCLE.md b/docs/MARKETPLACE_LISTING_LIFECYCLE.md index ae51ce6d..392d27b5 100644 --- a/docs/MARKETPLACE_LISTING_LIFECYCLE.md +++ b/docs/MARKETPLACE_LISTING_LIFECYCLE.md @@ -153,9 +153,17 @@ Seller ──────────────────────── | `payment_token` | `Address` | Token used for bids | | `started_at` | `u64` | Ledger timestamp at `start_auction` | | `ends_at` | `u64` | `started_at + duration_seconds` | +| `extension_seconds` | `u64` | Total anti-sniping extension applied so far | | `ended` | `bool` | Set to `true` by `end_auction` | -### 4.2 Function Reference +### 4.2 Bid Increment and Anti-Sniping + +- **Config**: `update_auction_settings(min_bid_increment, anti_sniping_window, anti_sniping_extension, max_anti_sniping_extension)` is admin-only. +- **Minimum bid**: default `min_bid_increment = 0` keeps the old rule (`bid_amount > current_bid`). When configured, the bid must be at least `current_bid + min_bid_increment`. +- **Anti-sniping**: if a valid bid is placed with `ends_at - ledger.timestamp() <= anti_sniping_window`, `ends_at` is extended by `anti_sniping_extension`. +- **Cap**: each auction tracks total extension seconds and never extends past `max_anti_sniping_extension`. + +### 4.3 Function Reference #### `start_auction(seller, token_id, starting_price, duration_seconds, payment_token)` @@ -168,11 +176,12 @@ Seller ──────────────────────── - **Auth**: `bidder.require_auth()` - **Reentrancy guard**: yes -- **Preconditions**: auction is active (`timestamp < ends_at`), `bid_amount > current_bid`, `bidder ≠ seller` +- **Preconditions**: auction is active (`timestamp < ends_at`), bid satisfies configured increment, `bidder ≠ seller` - **Effects** (in order): 1. Update `auction.current_bid` and `auction.highest_bidder` - 2. Transfer `bid_amount` from bidder → contract (escrow) - 3. Refund previous bidder from contract (if any) + 2. Extend `auction.ends_at` when anti-sniping settings apply + 3. Transfer `bid_amount` from bidder → contract (escrow) + 4. Refund previous bidder from contract (if any) - **Event**: `("BidPlaced", token_id) → (bidder, bid_amount)` #### `end_auction(token_id)` diff --git a/docs/commitment_marketplace.md b/docs/commitment_marketplace.md index 7cbb25d0..c5d05b61 100644 --- a/docs/commitment_marketplace.md +++ b/docs/commitment_marketplace.md @@ -8,6 +8,7 @@ This page documents the public entry points, access control, and security notes |------------------------|----------------------------------------------|-----------------------|----------------------------------------------------------| | initialize | Set admin, NFT contract, fee, fee recipient | Admin require_auth | Fails if already initialized | | update_fee | Update marketplace fee | Admin require_auth | Fails if not initialized | +| update_auction_settings | Update auction bid and anti-sniping config | Admin require_auth | Fails if min bid increment is negative | | 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 | @@ -23,6 +24,12 @@ This page documents the public entry points, access control, and security notes | get_auction | Get auction details | View | Fails if not found | | get_all_auctions | Get all active auctions | View | | +## Auction Bid Policy +- `update_auction_settings` stores a global absolute `min_bid_increment` plus anti-sniping window, extension, and total extension cap. +- With default zero settings, `place_bid` preserves the existing behavior: each bid must be strictly greater than `current_bid`. +- When `min_bid_increment > 0`, `place_bid` requires `bid_amount >= current_bid + min_bid_increment` using checked math. +- If a valid bid lands within `anti_sniping_window` seconds of `ends_at`, the auction extends by `anti_sniping_extension` seconds, capped by `max_anti_sniping_extension` total seconds per auction. + ## 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.