Skip to content
Merged
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
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["contracts/credit", "gateway-contract/contracts/auction_contract"]

members = [
"contracts/credit",
"gateway-contract/contracts/auction_contract",
Expand Down
199 changes: 163 additions & 36 deletions gateway-contract/contracts/auction_contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,53 @@ fn min_next_bid(highest_bid: i128, min_increment_bps: u32) -> i128 {
.expect("overflow computing minimum next bid threshold")
}

/// Computes the current Dutch auction price based on elapsed time.
///
/// The price decays linearly from start_price to floor_price over the auction duration.
/// Formula: current_price = start_price - ((start_price - floor_price) * elapsed_time) / duration
///
/// This function is overflow-safe and ensures monotone decreasing prices.
fn compute_dutch_price(
start_price: i128,
floor_price: i128,
elapsed_time: u64,
duration: u64,
) -> i128 {
if duration == 0 {
return floor_price; // Avoid division by zero
}

if elapsed_time >= duration {
return floor_price; // Clamp at floor when auction ends
}

// Compute total price drop
let price_drop = start_price
.checked_sub(floor_price)
.expect("start_price must be >= floor_price");

// Compute the portion of time elapsed as a fraction
// Use checked arithmetic to prevent overflow
let elapsed_i128 = elapsed_time as i128;
let duration_i128 = duration as i128;

// Compute drop so far: (price_drop * elapsed_time) / duration
// This is safe because we ensure duration > 0 and values are reasonable
let drop_so_far = price_drop
.checked_mul(elapsed_i128)
.expect("overflow in Dutch price calculation")
.checked_div(duration_i128)
.expect("division should succeed with positive duration");

// Current price = start_price - drop_so_far
let current_price = start_price
.checked_sub(drop_so_far)
.expect("current price should not underflow");

// Ensure we never go below floor (shouldn't happen with correct math, but safety check)
current_price.max(floor_price)
}

#[contract]
pub struct Auction;

Expand All @@ -55,9 +102,13 @@ impl Auction {
pub fn init_auction(
env: Env,
auction_id: Symbol,
mode: AuctionMode,
start_time: u64,
end_time: u64,
min_bid: i128,
min_increment_bps: u32,
dutch_start_price: Option<i128>,
dutch_floor_price: Option<i128>,
) {
if start_time >= end_time {
panic!("invalid times");
Expand All @@ -66,12 +117,28 @@ impl Auction {
if min_increment_bps > 10_000 {
panic!("min_increment_bps exceeds maximum of 10000 (100%)");
}

// Validate Dutch auction parameters
if mode == AuctionMode::Dutch {
let start = dutch_start_price.expect("dutch_start_price required for Dutch mode");
let floor = dutch_floor_price.expect("dutch_floor_price required for Dutch mode");
if start < floor {
panic!("dutch_start_price must be >= dutch_floor_price");
}
if start < min_bid {
panic!("dutch_start_price must be >= min_bid");
}
}

let config = AuctionConfig {
mode,
username_hash: BytesN::from_array(&env, &[0; 32]),
start_time,
end_time,
min_bid,
min_increment_bps,
dutch_start_price,
dutch_floor_price,
};
let state = AuctionState {
config,
Expand Down Expand Up @@ -108,12 +175,15 @@ impl Auction {

/// Place a bid for an auction identified by `auction_id`.
///
/// Bid floor: `amount` must be strictly greater than `max(min_bid - 1, highest_bid)`.
/// Equivalently, the first bid must be at least `min_bid`, and every later bid must
/// exceed the current highest. Equal-to-highest bids abort with `AuctionError::BidTooLow`.
/// For English auctions:
/// - Bid floor: `amount` must be strictly greater than `max(min_bid - 1, highest_bid)`.
/// - First bid must be at least `min_bid`, every later bid must exceed the current highest.
/// - When outbidding, the previous highest bidder is refunded exactly `highest_bid`.
///
/// When outbidding, the previous highest bidder is refunded exactly `highest_bid`
/// (event first, then token transfer when `bid_token` is configured).
/// For Dutch auctions:
/// - First bid at or above the current Dutch price wins immediately.
/// - Price decays linearly from start_price to floor_price over the auction duration.
/// - No outbidding - first qualifying bid settles the auction.
pub fn place_bid(env: Env, auction_id: Symbol, bidder: Address, amount: i128) {
bidder.require_auth();

Expand All @@ -136,37 +206,90 @@ impl Auction {
panic!("auction closed");
}

let min_floor = state.config.min_bid.saturating_sub(1);
let required_floor = if state.highest_bid > min_floor {
state.highest_bid
} else {
min_floor
};
if amount <= required_floor {
env.panic_with_error(AuctionError::BidTooLow);
}

let token_addr: Option<Address> = env
.storage()
.instance()
.get(&Symbol::new(&env, "bid_token"));

if let (Some(prev_bidder), Some(tkn)) = (state.highest_bidder.clone(), token_addr) {
let refund_amount = state.highest_bid;

// Emit refund event before performing token transfer
publish_bid_refunded_event(&env, prev_bidder.clone(), state.highest_bid);

let token_client = token::Client::new(&env, &tkn);
token_client.transfer(
&env.current_contract_address(),
&prev_bidder,
&refund_amount,
);
match state.config.mode {
AuctionMode::English => {
// English auction: require bid to exceed current highest
let min_floor = state.config.min_bid.saturating_sub(1);
let required_floor = if state.highest_bid > min_floor {
state.highest_bid
} else {
min_floor
};
if amount <= required_floor {
env.panic_with_error(AuctionError::BidTooLow);
}

let token_addr: Option<Address> = env
.storage()
.instance()
.get(&Symbol::new(&env, "bid_token"));

if let (Some(prev_bidder), Some(tkn)) = (state.highest_bidder.clone(), token_addr) {
let refund_amount = state.highest_bid;

// Emit refund event before performing token transfer
publish_bid_refunded_event(&env, prev_bidder.clone(), state.highest_bid);

let token_client = token::Client::new(&env, &tkn);
token_client.transfer(
&env.current_contract_address(),
&prev_bidder,
&refund_amount,
);
}

state.highest_bidder = Some(bidder);
state.highest_bid = amount;
}
AuctionMode::Dutch => {
// Dutch auction: first qualifying bid wins
let current_time = env.ledger().timestamp();
let elapsed_time = current_time
.checked_sub(state.config.start_time)
.unwrap_or(0);
let duration = state
.config
.end_time
.checked_sub(state.config.start_time)
.unwrap_or(1);

let start_price = state
.config
.dutch_start_price
.unwrap_or(state.config.min_bid);
let floor_price = state
.config
.dutch_floor_price
.unwrap_or(state.config.min_bid);

let current_price =
compute_dutch_price(start_price, floor_price, elapsed_time, duration);

// Bid must be at least current price
if amount < current_price {
env.panic_with_error(AuctionError::BidTooLow);
}

// Bid must be at least min_bid
if amount < state.config.min_bid {
env.panic_with_error(AuctionError::BidTooLow);
}

// In Dutch auction, first qualifying bid wins - close the auction
state.highest_bidder = Some(bidder);
state.highest_bid = amount;
state.status = AuctionStatus::Closed;

// Publish close event for Dutch auction settlement
publish_auction_closed_event(
&env,
auction_id.clone(),
state.highest_bidder.clone(),
state.highest_bid,
);
}
}

state.highest_bidder = Some(bidder);
state.highest_bid = amount;
env.storage().persistent().set(&auction_id, &state);
bump_auction_state_ttl(&env, &auction_id);
}
Expand All @@ -183,7 +306,8 @@ impl Auction {
credit_contract: Address,
borrower: Address,
) {
let factory = get_factory_contract(&env).unwrap_or_else(|| panic!(AuctionError::NoFactoryContract));
let factory =
get_factory_contract(&env).unwrap_or_else(|| panic!(AuctionError::NoFactoryContract));
if env.invoker() != factory {
panic!(AuctionError::Unauthorized);
}
Expand Down Expand Up @@ -242,7 +366,10 @@ impl Auction {
env.panic_with_error(AuctionError::AuctionNotClosed);
}

let winner = state.highest_bidder.clone().unwrap_or_else(|| env.panic_with_error(AuctionError::NoWinner));
let winner = state
.highest_bidder
.clone()
.unwrap_or_else(|| env.panic_with_error(AuctionError::NoWinner));
winner.require_auth();

if state.status == AuctionStatus::Claimed {
Expand Down
8 changes: 3 additions & 5 deletions gateway-contract/contracts/auction_contract/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,9 @@ pub(crate) fn bump_auction_state_ttl(env: &Env, auction_id: &Symbol) {
/// Extend TTL for settlement replay-protection markers (only when the key exists).
pub(crate) fn bump_settlement_marker_ttl(env: &Env, key: &crate::AuctionKey) {
if env.storage().persistent().has(key) {
env.storage().persistent().extend_ttl(
key,
PERSISTENT_BUMP_AMOUNT,
PERSISTENT_BUMP_AMOUNT,
);
env.storage()
.persistent()
.extend_ttl(key, PERSISTENT_BUMP_AMOUNT, PERSISTENT_BUMP_AMOUNT);
}
}

Expand Down
Loading
Loading