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 |