From 3118bd9540b5802b188d879db0a2e5e550b2ad91 Mon Sep 17 00:00:00 2001 From: D'Angelo Rodriguez <70290504+dangelo352@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:44:37 -0400 Subject: [PATCH] feat: add dutch auction mode --- contracts/commitment_marketplace/src/lib.rs | 272 +++++++++++++++++- contracts/commitment_marketplace/src/tests.rs | 113 ++++++++ docs/MARKETPLACE_LISTING_LIFECYCLE.md | 42 ++- 3 files changed, 423 insertions(+), 4 deletions(-) diff --git a/contracts/commitment_marketplace/src/lib.rs b/contracts/commitment_marketplace/src/lib.rs index 71a602ce..728d2b8f 100644 --- a/contracts/commitment_marketplace/src/lib.rs +++ b/contracts/commitment_marketplace/src/lib.rs @@ -115,12 +115,14 @@ pub struct Auction { pub token_id: u32, pub seller: Address, pub starting_price: i128, + pub reserve_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, + pub is_dutch: bool, } /// Storage keys @@ -200,6 +202,29 @@ fn require_allowed_payment_token( Ok(()) } +fn current_dutch_price(e: &Env, auction: &Auction) -> i128 { + let now = e.ledger().timestamp(); + if now <= auction.started_at { + return auction.starting_price; + } + if now >= auction.ends_at { + return auction.reserve_price; + } + + let duration = auction.ends_at.saturating_sub(auction.started_at); + if duration == 0 { + return auction.reserve_price; + } + + let elapsed = now.saturating_sub(auction.started_at); + let decay_range = SafeMath::sub(auction.starting_price, auction.reserve_price); + let elapsed_decay = SafeMath::div( + SafeMath::mul(decay_range, elapsed as i128), + duration as i128, + ); + SafeMath::sub(auction.starting_price, elapsed_decay) +} + #[contractimpl] impl CommitmentMarketplace { // ======================================================================== @@ -1070,12 +1095,14 @@ impl CommitmentMarketplace { token_id, seller: seller.clone(), starting_price, + reserve_price: 0, current_bid: starting_price, highest_bidder: None, payment_token: payment_token.clone(), started_at, ends_at, ended: false, + is_dutch: false, }; e.storage() @@ -1106,6 +1133,249 @@ impl CommitmentMarketplace { Ok(()) } + /// @notice Start a descending Dutch auction for an NFT. + /// @param seller Seller's address (must sign the transaction). + /// @param token_id NFT token ID. + /// @param start_price Initial auction price (must be > reserve_price). + /// @param reserve_price Minimum terminal price (must be > 0). + /// @param duration_seconds Duration over which price decays linearly. + /// @param payment_token Token contract address for payment. + /// @dev Reentrancy guard enforced. Price decays linearly from start to reserve. + pub fn start_dutch_auction( + e: Env, + seller: Address, + token_id: u32, + start_price: i128, + reserve_price: i128, + duration_seconds: u64, + payment_token: Address, + ) -> Result<(), MarketplaceError> { + 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); + + seller.require_auth(); + + if start_price <= 0 || reserve_price <= 0 || reserve_price >= start_price { + 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); + } + + 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: start_price, + reserve_price, + current_bid: start_price, + highest_bidder: None, + payment_token: payment_token.clone(), + started_at, + ends_at, + ended: false, + is_dutch: true, + }; + + 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); + + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + + e.events().publish( + (symbol_short!("DutStart"), token_id), + (seller, start_price, reserve_price, ends_at), + ); + + Ok(()) + } + + /// @notice Return the current decayed price for an active Dutch auction. + pub fn get_dutch_price(e: Env, token_id: u32) -> Result { + let auction: Auction = e + .storage() + .persistent() + .get(&DataKey::Auction(token_id)) + .ok_or(MarketplaceError::AuctionNotFound)?; + + if !auction.is_dutch { + return Err(MarketplaceError::AuctionNotFound); + } + + Ok(current_dutch_price(&e, &auction)) + } + + /// @notice Buy a Dutch auction at the current decayed price. + /// @param buyer Buyer's address (must sign the transaction). + /// @param token_id NFT token ID. + /// @dev First valid buyer wins. Reentrancy guard is set before state changes. + pub fn buy_dutch( + e: Env, + buyer: Address, + token_id: u32, + ) -> Result<(), MarketplaceError> { + 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); + + buyer.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 + })?; + + if !auction.is_dutch || auction.ended { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::AuctionEnded); + } + + let current_time = e.ledger().timestamp(); + if current_time >= auction.ends_at { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + return Err(MarketplaceError::AuctionEnded); + } + + if auction.seller == buyer { + 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); + } + + let fee_basis_points: u32 = e + .storage() + .instance() + .get(&DataKey::MarketplaceFee) + .unwrap_or(0); + + let fee_recipient: Address = e + .storage() + .instance() + .get(&DataKey::FeeRecipient) + .ok_or_else(|| { + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + MarketplaceError::NotInitialized + })?; + + let price = current_dutch_price(&e, &auction); + auction.current_bid = price; + auction.highest_bidder = Some(buyer.clone()); + auction.ended = true; + 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)); + 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); + + let fee_bps = if fee_basis_points > 10_000 { + 10_000 + } else { + fee_basis_points + }; + let marketplace_fee = + SafeMath::div(SafeMath::mul(price, fee_bps as i128), 10_000_i128); + let seller_proceeds = SafeMath::sub(price, marketplace_fee); + + let payment_token_client = token::Client::new(&e, &auction.payment_token); + payment_token_client.transfer(&buyer, &auction.seller, &seller_proceeds); + if marketplace_fee > 0 { + payment_token_client.transfer(&buyer, &fee_recipient, &marketplace_fee); + } + + e.storage() + .instance() + .set(&DataKey::ReentrancyGuard, &false); + + e.events() + .publish((symbol_short!("DutBuy"), token_id), (buyer, price)); + + Ok(()) + } + /// @notice Place a bid on an active auction. /// @param bidder Bidder's address (must sign the transaction). /// @param token_id NFT token ID. @@ -1396,4 +1666,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..5c437956 100644 --- a/contracts/commitment_marketplace/src/tests.rs +++ b/contracts/commitment_marketplace/src/tests.rs @@ -590,6 +590,119 @@ fn test_auction_duration_boundary() { assert!(auction.ended); } +#[test] +fn test_dutch_auction_price_decay() { + 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 = 10u32; + + client.start_dutch_auction(&seller, &token_id, &1000, &200, &100, &payment_token); + + assert_eq!(client.get_dutch_price(&token_id), 1000); + + e.ledger().with_mut(|li| { + li.timestamp = 50; + }); + assert_eq!(client.get_dutch_price(&token_id), 600); + + e.ledger().with_mut(|li| { + li.timestamp = 100; + }); + assert_eq!(client.get_dutch_price(&token_id), 200); +} + +#[test] +fn test_buy_dutch_near_reserve_settles_and_removes_active_auction() { + 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 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::Client::new(&e, &payment_token); + soroban_sdk::token::StellarAssetClient::new(&e, &payment_token).mint(&buyer, &1000); + + let token_id = 11u32; + client.start_dutch_auction(&seller, &token_id, &1000, &200, &100, &payment_token); + + e.ledger().with_mut(|li| { + li.timestamp = 99; + }); + assert_eq!(client.get_dutch_price(&token_id), 208); + + client.buy_dutch(&buyer, &token_id); + + let auction = client.get_auction(&token_id); + assert!(auction.ended); + assert_eq!(auction.current_bid, 208); + assert_eq!(auction.highest_bidder, Some(buyer.clone())); + assert_eq!(client.get_all_auctions().len(), 0); + assert_eq!(token_client.balance(&seller), 203); + assert_eq!(token_client.balance(&fee_recipient), 5); + assert_eq!(token_client.balance(&buyer), 792); +} + +#[test] +#[should_panic(expected = "Error(Contract, #16)")] // AuctionEnded +fn test_buy_dutch_after_end_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 payment_token = setup_test_token(&e, &client); + let token_id = 12u32; + + client.start_dutch_auction(&seller, &token_id, &1000, &200, &100, &payment_token); + e.ledger().with_mut(|li| { + li.timestamp = 100; + }); + + client.buy_dutch(&buyer, &token_id); +} + +#[test] +#[should_panic(expected = "Error(Contract, #22)")] // PaymentTokenNotAllowed +fn test_start_dutch_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_dutch_auction(&seller, &13, &1000, &200, &100, &payment_token); +} + +#[test] +#[should_panic(expected = "Error(Contract, #20)")] // ReentrancyDetected +fn test_buy_dutch_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 = 14u32; + + client.start_dutch_auction(&seller, &token_id, &1000, &200, &100, &payment_token); + set_contract_reentrancy_guard(&e, &client.address, true); + + client.buy_dutch(&buyer, &token_id); +} + #[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..8f8e63b6 100644 --- a/docs/MARKETPLACE_LISTING_LIFECYCLE.md +++ b/docs/MARKETPLACE_LISTING_LIFECYCLE.md @@ -154,6 +154,8 @@ Seller ──────────────────────── | `started_at` | `u64` | Ledger timestamp at `start_auction` | | `ends_at` | `u64` | `started_at + duration_seconds` | | `ended` | `bool` | Set to `true` by `end_auction` | +| `reserve_price` | `i128` | Dutch-auction terminal price (`0` for English auctions) | +| `is_dutch` | `bool` | `true` for descending Dutch auctions | ### 4.2 Function Reference @@ -192,6 +194,40 @@ Seller ──────────────────────── - Winner: `("AucEnd", token_id) → (winner, current_bid)` - No bids: `("AucNoBid", token_id) → seller` +### 4.3 Dutch Auction Lifecycle + +Dutch auctions share `Auction` storage and `ActiveAuctions`, but are created with `start_dutch_auction`. + +#### `start_dutch_auction(seller, token_id, start_price, reserve_price, duration_seconds, payment_token)` + +- **Auth**: `seller.require_auth()` +- **Reentrancy guard**: yes +- **Preconditions**: `start_price > reserve_price > 0`, `duration_seconds > 0`, no existing auction, payment token allowlisted +- **Event**: `("DutStart", token_id) → (seller, start_price, reserve_price, ends_at)` + +#### Price Decay Formula + +For `started_at < now < ends_at`: + +```text +price = start_price - ((start_price - reserve_price) * (now - started_at) / (ends_at - started_at)) +``` + +The price is clamped to `start_price` at or before `started_at`, and `reserve_price` at or after `ends_at`. Example: with `start_price = 1000`, `reserve_price = 200`, and `duration_seconds = 100`, the price at `t = 50` is `1000 - ((800 * 50) / 100) = 600`. + +#### `buy_dutch(buyer, token_id)` + +- **Auth**: `buyer.require_auth()` +- **Reentrancy guard**: yes +- **Preconditions**: auction is Dutch, not ended, `timestamp < ends_at`, buyer is not seller, payment token still allowlisted +- **Effects**: + 1. Compute the current decayed price + 2. Mark `auction.ended = true`, set `current_bid = price`, and store `highest_bidder = buyer` + 3. Remove from `ActiveAuctions` + 4. Transfer `seller_proceeds` from buyer → seller + 5. Transfer `marketplace_fee` from buyer → fee recipient (if fee > 0) +- **Event**: `("DutBuy", token_id) → (buyer, price)` + --- ## 5. Error Codes @@ -203,9 +239,9 @@ Seller ──────────────────────── | 3 | `ListingNotFound` | `cancel_listing`, `buy_nft`, `get_listing` with unknown token | | 4 | `NotSeller` | `cancel_listing` by non-owner | | 5 | `NFTNotActive` | Reserved | -| 6 | `InvalidPrice` | `list_nft` / `start_auction` with `price ≤ 0` | -| 7 | `ListingExists` | `list_nft` twice; `start_auction` twice | -| 8 | `CannotBuyOwnListing` | `buy_nft` / `place_bid` by seller | +| 6 | `InvalidPrice` | `list_nft` / `start_auction` with `price ≤ 0`; Dutch auction with `start_price <= reserve_price` | +| 7 | `ListingExists` | `list_nft` twice; `start_auction` / `start_dutch_auction` twice | +| 8 | `CannotBuyOwnListing` | `buy_nft` / `place_bid` / `buy_dutch` by seller | | 9 | `InsufficientPayment` | Reserved | | 10 | `NFTContractError` | Reserved | | 11 | `OfferNotFound` | `cancel_offer`, `accept_offer` with unknown offerer |