Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion contracts/commitment_marketplace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`

Expand Down
115 changes: 113 additions & 2 deletions contracts/commitment_marketplace/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -148,6 +159,8 @@ pub enum DataKey {
Auction(u32),
/// Active auctions list
ActiveAuctions,
/// Auction increment and anti-sniping settings
AuctionSettings,
/// Reentrancy guard
ReentrancyGuard,
}
Expand Down Expand Up @@ -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 {
// ========================================================================
Expand Down Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1075,6 +1155,7 @@ impl CommitmentMarketplace {
payment_token: payment_token.clone(),
started_at,
ends_at,
extension_seconds: 0,
ended: false,
};

Expand Down Expand Up @@ -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);
Expand All @@ -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()
Expand Down Expand Up @@ -1396,4 +1507,4 @@ impl CommitmentMarketplace {

auctions
}
}
}
120 changes: 120 additions & 0 deletions contracts/commitment_marketplace/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ============================================================================
Expand Down Expand Up @@ -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() {
Expand Down
17 changes: 13 additions & 4 deletions docs/MARKETPLACE_LISTING_LIFECYCLE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`

Expand All @@ -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)`
Expand Down
Loading
Loading