diff --git a/Cargo.toml b/Cargo.toml index 35f628c..66e2558 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["contracts/credit", "gateway-contract/contracts/auction_contract"] + members = [ "contracts/credit", "gateway-contract/contracts/auction_contract", diff --git a/gateway-contract/contracts/auction_contract/src/lib.rs b/gateway-contract/contracts/auction_contract/src/lib.rs index 6072447..a3746c7 100644 --- a/gateway-contract/contracts/auction_contract/src/lib.rs +++ b/gateway-contract/contracts/auction_contract/src/lib.rs @@ -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; @@ -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, + dutch_floor_price: Option, ) { if start_time >= end_time { panic!("invalid times"); @@ -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, @@ -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(); @@ -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
= 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
= 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); } @@ -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); } @@ -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 { diff --git a/gateway-contract/contracts/auction_contract/src/storage.rs b/gateway-contract/contracts/auction_contract/src/storage.rs index e95d946..b140ee3 100644 --- a/gateway-contract/contracts/auction_contract/src/storage.rs +++ b/gateway-contract/contracts/auction_contract/src/storage.rs @@ -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); } } diff --git a/gateway-contract/contracts/auction_contract/src/test.rs b/gateway-contract/contracts/auction_contract/src/test.rs index fb3c59f..69ec8fe 100644 --- a/gateway-contract/contracts/auction_contract/src/test.rs +++ b/gateway-contract/contracts/auction_contract/src/test.rs @@ -8,10 +8,10 @@ mod tests { use std::panic::{catch_unwind, AssertUnwindSafe}; use std::vec::Vec; - use soroban_sdk::testutils::{Address as _, Ledger}; use soroban_sdk::testutils::Events as _; - use soroban_sdk::testutils::{Ledger, MockAuth, MockAuthInvoke}; use soroban_sdk::testutils::Ledger as _; + use soroban_sdk::testutils::{Address as _, Ledger}; + use soroban_sdk::testutils::{Ledger, MockAuth, MockAuthInvoke}; use soroban_sdk::token::{Client as TokenClient, StellarAssetClient}; use soroban_sdk::{Address, Env, IntoVal, Symbol, TryFromVal, TryIntoVal}; @@ -83,7 +83,16 @@ mod tests { let client = AuctionClient::new(&env, &contract_id); let auction_id = Symbol::new(&env, "auc1"); - client.init_auction(&auction_id, &0, &1000, &50_i128, &0_u32); // start 0, end 1000, min 50, 0 bps + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); // start 0, end 1000, min 50, 0 bps client.place_bid(&auction_id, &alice, &100_i128); client.place_bid(&auction_id, &bob, &200_i128); @@ -107,7 +116,16 @@ mod tests { let client = AuctionClient::new(&env, &contract_id); let auction_id = Symbol::new(&env, "eq_highest"); - client.init_auction(&auction_id, &0, &1000, &50_i128); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); client.place_bid(&auction_id, &alice, &100_i128); @@ -145,7 +163,16 @@ mod tests { let client = AuctionClient::new(&env, &contract_id); let auction_id = Symbol::new(&env, AUCTION_ID); - client.init_auction(&auction_id, &0, &u64::MAX, &1_i128, &0_u32); // long auction, min 1, 0 bps + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &u64::MAX, + &1_i128, + &0_u32, + &None, + &None, + ); // long auction, min 1, 0 bps let mut seed: u64 = 0xdeadbeefcafebabe; let mut expected: Option<(Address, i128)> = None; @@ -224,7 +251,16 @@ mod tests { let mut seed: u64 = 0x1234_5678_9abc_def0; let auction_id = Symbol::new(&env, "refund_auc"); - client.init_auction(&auction_id, &0, &u64::MAX, &1_i128, &0_u32); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &u64::MAX, + &1_i128, + &0_u32, + &None, + &None, + ); for _ in 0..FUZZ_STEPS { let bidder_idx = pick_index(&mut seed, 0..bidders.len()); @@ -284,7 +320,16 @@ mod tests { let client = AuctionClient::new(&env, &contract_id); let auction_id = Symbol::new(&env, "close_auc"); - client.init_auction(&auction_id, &0, &u64::MAX, &1_i128, &0_u32); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &u64::MAX, + &1_i128, + &0_u32, + &None, + &None, + ); let mut seed: u64 = 0x11ce_f00d_cafe_beef; let mut seed: u64 = 0xdeadbeef_cafe_beef; @@ -331,8 +376,26 @@ mod tests { let auction_id = Symbol::new(&env, "liq_open"); client.set_factory_contract(&factory); - client.init_auction(&auction_id, &0, &1000, &50_i128); - client.init_auction(&auction_id, &0, &1000, &50_i128, &0_u32); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); client.place_bid(&auction_id, &bidder, &100_i128); let result = client.try_settle_default_liquidation( @@ -358,8 +421,26 @@ mod tests { let auction_id = Symbol::new(&env, "liq_closed"); client.set_factory_contract(&factory); - client.init_auction(&auction_id, &0, &1000, &50_i128); - client.init_auction(&auction_id, &0, &1000, &50_i128, &0_u32); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); client.place_bid(&auction_id, &bidder, &420_i128); client.close_auction(&auction_id); client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); @@ -389,7 +470,16 @@ mod tests { let auction_id = Symbol::new(&env, "liq_replay"); client.set_factory_contract(&factory); - client.init_auction(&auction_id, &0, &1000, &50_i128); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); client.close_auction(&auction_id); client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); // second call must panic @@ -413,8 +503,26 @@ mod tests { let auction_id = Symbol::new(&env, "zero_bid"); client.set_factory_contract(&factory); - client.init_auction(&auction_id, &0, &1000, &50_i128); - client.init_auction(&auction_id, &0, &1000, &50_i128, &0_u32); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); // no bids client.close_auction(&auction_id); client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); @@ -438,7 +546,16 @@ mod tests { let auction_id = Symbol::new(&env, "no_factory"); // No set_factory_contract call — factory is unset - client.init_auction(&auction_id, &0, &1000, &50_i128); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); client.close_auction(&auction_id); let result = catch_unwind(AssertUnwindSafe(|| { @@ -449,7 +566,10 @@ mod tests { ); })); - assert!(result.is_err(), "should revert when factory contract is unset"); + assert!( + result.is_err(), + "should revert when factory contract is unset" + ); } #[test] @@ -466,7 +586,16 @@ mod tests { // Setup: register factory and close an auction env.mock_all_auths(); client.set_factory_contract(&factory); - client.init_auction(&auction_id, &0, &1000, &50_i128); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); client.close_auction(&auction_id); // Attempt settlement with no auth provided — factory.require_auth() will reject @@ -480,7 +609,16 @@ mod tests { // Setup with mocks env2.mock_all_auths(); client2.set_factory_contract(&factory2); - client2.init_auction(&auction_id2, &0, &1000, &50_i128); + client2.init_auction( + &auction_id2, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); client2.close_auction(&auction_id2); // Call with only a non-factory address authorized let wrong_caller = Address::generate(&env2); @@ -521,7 +659,16 @@ mod tests { let bidder = Address::generate(&env); let auction_id = Symbol::new(&env, "timed_out"); - client.init_auction(&auction_id, &0, &1000, &50_i128, &0_u32); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); let attempt = client.try_place_bid(&auction_id, &bidder, &100_i128); assert!(attempt.is_err(), "bid after end time should be rejected"); @@ -540,7 +687,16 @@ mod tests { let credit_contract = Address::generate(&env); let auction_id = Symbol::new(&env, "no_factory"); - client.init_auction(&auction_id, &0, &1000, &50_i128); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); client.place_bid(&auction_id, &bidder, &420_i128); client.close_auction(&auction_id); @@ -556,24 +712,33 @@ mod tests { let env = Env::default(); let contract_id = env.register(Auction, ()); let client = AuctionClient::new(&env, &contract_id); - + let factory = Address::generate(&env); let intruder = Address::generate(&env); let bidder = Address::generate(&env); let borrower = Address::generate(&env); let credit_contract = Address::generate(&env); let auction_id = Symbol::new(&env, "unauth"); - + env.as_contract(&contract_id, || { set_factory_contract(&env, &factory); }); // Use mock_all_auths for setup env.mock_all_auths(); - client.init_auction(&auction_id, &0, &1000, &50_i128); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); client.place_bid(&auction_id, &bidder, &420_i128); client.close_auction(&auction_id); - + // This test may not work perfectly with mock_all_auths() active. // Let's just try to settle as intruder and expect panic, // if it fails, I'll need a better way to handle auth. @@ -596,17 +761,26 @@ mod tests { let borrower = Address::generate(&env); let credit_contract = Address::generate(&env); let auction_id = Symbol::new(&env, "auth_success"); - + env.as_contract(&contract_id, || { set_factory_contract(&env, &factory); }); // Use mock_all_auths for setup env.mock_all_auths(); - client.init_auction(&auction_id, &0, &1000, &50_i128); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); client.place_bid(&auction_id, &bidder, &420_i128); client.close_auction(&auction_id); - + // Call as factory env.as_contract(&factory, || { client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); @@ -615,23 +789,6 @@ mod tests { let events = settlement_events(&env); assert_eq!(events.len(), 1); } - client.init_auction(&auction_id, &0, &1000, &50_i128, &0_u32); - client.place_bid(&auction_id, &bidder, &100_i128); - client.close_auction(&auction_id); - - // Check close event - let close_events = env - .events() - .all() - .iter() - .filter(|(_contract, topics, _data)| { - let t0: Symbol = Symbol::try_from_val(&env, &topics.get(0).unwrap()).unwrap(); - t0 == Symbol::new(&env, "AUC_CLOSE") - }) - .collect::>(); - assert_eq!(close_events.len(), 1); - } - // ── min_increment_bps: validation at init ────────────────────────────── #[test] @@ -643,7 +800,16 @@ mod tests { let auction_id = Symbol::new(&env, "bad_bps"); let result = catch_unwind(AssertUnwindSafe(|| { - client.init_auction(&auction_id, &0, &1000, &50_i128, &10_001_u32); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &10_001_u32, + &None, + &None, + ); })); assert!(result.is_err(), "bps > 10000 should be rejected at init"); } @@ -656,9 +822,27 @@ mod tests { let client = AuctionClient::new(&env, &contract_id); // 0 bps (no percentage requirement) is valid - client.init_auction(&Symbol::new(&env, "bps0"), &0, &1000, &1_i128, &0_u32); + client.init_auction( + &Symbol::new(&env, "bps0"), + &AuctionMode::English, + &0, + &1000, + &1_i128, + &0_u32, + &None, + &None, + ); // 10_000 bps (100% increment) is the maximum valid value - client.init_auction(&Symbol::new(&env, "bps10k"), &0, &1000, &1_i128, &10_000_u32); + client.init_auction( + &Symbol::new(&env, "bps10k"), + &AuctionMode::English, + &0, + &1000, + &1_i128, + &10_000_u32, + &None, + &None, + ); } // ── min_increment_bps: bid threshold enforcement ─────────────────────── @@ -675,13 +859,25 @@ mod tests { let bob = Address::generate(&env); // 100 bps = 1%; threshold after 1000 = 1000 + ceil(1000*100/10000) = 1010 - client.init_auction(&auction_id, &0, &u64::MAX, &1_i128, &100_u32); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &u64::MAX, + &1_i128, + &100_u32, + &None, + &None, + ); client.place_bid(&auction_id, &alice, &1_000_i128); let result = catch_unwind(AssertUnwindSafe(|| { client.place_bid(&auction_id, &bob, &1_009_i128); // 1009 < 1010 })); - assert!(result.is_err(), "bid one stroop below threshold must be rejected"); + assert!( + result.is_err(), + "bid one stroop below threshold must be rejected" + ); // state must be unchanged let state: crate::types::AuctionState = env @@ -703,7 +899,16 @@ mod tests { let bob = Address::generate(&env); // 100 bps = 1%; threshold after 1000 = 1010 - client.init_auction(&auction_id, &0, &u64::MAX, &1_i128, &100_u32); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &u64::MAX, + &1_i128, + &100_u32, + &None, + &None, + ); client.place_bid(&auction_id, &alice, &1_000_i128); client.place_bid(&auction_id, &bob, &1_010_i128); // exactly at threshold @@ -727,7 +932,16 @@ mod tests { let carol = Address::generate(&env); // 333 bps = 3.33%; increment on 1000 = ceil(1000*333/10000) = ceil(33.3) = 34; threshold = 1034 - client.init_auction(&auction_id, &0, &u64::MAX, &1_i128, &333_u32); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &u64::MAX, + &1_i128, + &333_u32, + &None, + &None, + ); client.place_bid(&auction_id, &alice, &1_000_i128); let just_below = catch_unwind(AssertUnwindSafe(|| { @@ -757,7 +971,16 @@ mod tests { let carol = Address::generate(&env); // 0 bps: any strictly higher bid is accepted; equal bid must be rejected - client.init_auction(&auction_id, &0, &u64::MAX, &1_i128, &0_u32); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &u64::MAX, + &1_i128, + &0_u32, + &None, + &None, + ); client.place_bid(&auction_id, &alice, &500_i128); let equal = catch_unwind(AssertUnwindSafe(|| { @@ -772,104 +995,482 @@ mod tests { .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) .unwrap(); assert_eq!(state.highest_bid, 501_i128); - assert_eq!(state.highest_bid, 501_i128); -} + } -#[test] -fn claim_non_winner_fails_not_winner() { - let env = Env::default(); - env.mock_all_auths(); + #[test] + fn claim_non_winner_fails_not_winner() { + let env = Env::default(); + env.mock_all_auths(); - let alice = Address::generate(&env); - let bob = Address::generate(&env); - let winner = Address::generate(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let winner = Address::generate(&env); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); - let auction_id = Symbol::new(&env, "claim_non_winner"); + let auction_id = Symbol::new(&env, "claim_non_winner"); - client.init_auction(&auction_id, &0, &u64::MAX, &1_i128, &0_u32); - client.place_bid(&auction_id, &winner, &100_i128); - client.close_auction(&auction_id); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &u64::MAX, + &1_i128, + &0_u32, + &None, + &None, + ); + client.place_bid(&auction_id, &winner, &100_i128); + client.close_auction(&auction_id); - let result = catch_unwind(AssertUnwindSafe(|| { - // alice (not winner) attempts to claim - client.claim_auction(&auction_id); - })); - assert!(result.is_err(), "non-winner claim should fail"); -} + let result = catch_unwind(AssertUnwindSafe(|| { + // alice (not winner) attempts to claim + client.claim_auction(&auction_id); + })); + assert!(result.is_err(), "non-winner claim should fail"); + } -#[test] -fn claim_double_claim_fails_already_claimed() { - let env = Env::default(); - env.mock_all_auths(); + #[test] + fn claim_double_claim_fails_already_claimed() { + let env = Env::default(); + env.mock_all_auths(); - let winner = Address::generate(&env); + let winner = Address::generate(&env); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); - let auction_id = Symbol::new(&env, "claim_double"); + let auction_id = Symbol::new(&env, "claim_double"); - client.init_auction(&auction_id, &0, &u64::MAX, &1_i128, &0_u32); - client.place_bid(&auction_id, &winner, &100_i128); - client.close_auction(&auction_id); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &u64::MAX, + &1_i128, + &0_u32, + &None, + &None, + ); + client.place_bid(&auction_id, &winner, &100_i128); + client.close_auction(&auction_id); - // first claim succeeds - let first = catch_unwind(AssertUnwindSafe(|| { - client.claim_auction(&auction_id); - })); - assert!(first.is_ok(), "first claim should succeed"); + // first claim succeeds + let first = catch_unwind(AssertUnwindSafe(|| { + client.claim_auction(&auction_id); + })); + assert!(first.is_ok(), "first claim should succeed"); - // second claim should fail - let second = catch_unwind(AssertUnwindSafe(|| { - client.claim_auction(&auction_id); - })); - assert!(second.is_err(), "second claim should fail"); -} + // second claim should fail + let second = catch_unwind(AssertUnwindSafe(|| { + client.claim_auction(&auction_id); + })); + assert!(second.is_err(), "second claim should fail"); + } -#[test] -fn claim_before_close_fails_not_closed() { - let env = Env::default(); - env.mock_all_auths(); + #[test] + fn claim_before_close_fails_not_closed() { + let env = Env::default(); + env.mock_all_auths(); - let winner = Address::generate(&env); + let winner = Address::generate(&env); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); - let auction_id = Symbol::new(&env, "claim_not_closed"); + let auction_id = Symbol::new(&env, "claim_not_closed"); - client.init_auction(&auction_id, &0, &u64::MAX, &1_i128, &0_u32); - client.place_bid(&auction_id, &winner, &100_i128); - // not closing the auction + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &u64::MAX, + &1_i128, + &0_u32, + &None, + &None, + ); + client.place_bid(&auction_id, &winner, &100_i128); + // not closing the auction - let result = catch_unwind(AssertUnwindSafe(|| { - client.claim_auction(&auction_id); - })); - assert!(result.is_err(), "claim before close should fail"); -} + let result = catch_unwind(AssertUnwindSafe(|| { + client.claim_auction(&auction_id); + })); + assert!(result.is_err(), "claim before close should fail"); + } -#[test] -fn claim_zero_bid_auction_fails_not_winner() { - let env = Env::default(); - env.mock_all_auths(); + #[test] + fn claim_zero_bid_auction_fails_not_winner() { + let env = Env::default(); + env.mock_all_auths(); - let borrower = Address::generate(&env); + let borrower = Address::generate(&env); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); - let auction_id = Symbol::new(&env, "zero_bid_claim"); + let auction_id = Symbol::new(&env, "zero_bid_claim"); - client.init_auction(&auction_id, &0, &u64::MAX, &1_i128, &0_u32); - // no bids placed - client.close_auction(&auction_id); + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &u64::MAX, + &1_i128, + &0_u32, + &None, + &None, + ); + // no bids placed + client.close_auction(&auction_id); - let result = catch_unwind(AssertUnwindSafe(|| { - client.claim_auction(&auction_id); - })); - assert!(result.is_err(), "zero-bid claim should fail"); -} + let result = catch_unwind(AssertUnwindSafe(|| { + client.claim_auction(&auction_id); + })); + assert!(result.is_err(), "zero-bid claim should fail"); + } + + // === Dutch Auction Tests === + + #[test] + fn dutch_auction_price_at_start() { + let env = Env::default(); + env.mock_all_auths(); + + let alice = Address::generate(&env); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let auction_id = Symbol::new(&env, "dutch_start"); + + client.init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &50_i128, + &0_u32, + &Some(500_i128), + &Some(100_i128), + ); + + env.ledger().with_mut(|li| li.timestamp = 1000); + client.place_bid(&auction_id, &alice, &500_i128); + + let stored: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + + assert_eq!(stored.status, AuctionStatus::Closed); + assert_eq!(stored.highest_bidder.unwrap(), alice); + assert_eq!(stored.highest_bid, 500_i128); + } + + #[test] + fn dutch_auction_price_at_mid() { + let env = Env::default(); + env.mock_all_auths(); + + let alice = Address::generate(&env); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let auction_id = Symbol::new(&env, "dutch_mid"); + + client.init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &50_i128, + &0_u32, + &Some(500_i128), + &Some(100_i128), + ); + + env.ledger().with_mut(|li| li.timestamp = 1500); + client.place_bid(&auction_id, &alice, &300_i128); + + let stored: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + + assert_eq!(stored.status, AuctionStatus::Closed); + assert_eq!(stored.highest_bidder.unwrap(), alice); + assert_eq!(stored.highest_bid, 300_i128); + } + + #[test] + fn dutch_auction_price_at_floor() { + let env = Env::default(); + env.mock_all_auths(); + + let alice = Address::generate(&env); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let auction_id = Symbol::new(&env, "dutch_floor"); + + client.init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &50_i128, + &0_u32, + &Some(500_i128), + &Some(100_i128), + ); + + env.ledger().with_mut(|li| li.timestamp = 2000); + client.place_bid(&auction_id, &alice, &100_i128); + + let stored: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + + assert_eq!(stored.status, AuctionStatus::Closed); + assert_eq!(stored.highest_bidder.unwrap(), alice); + assert_eq!(stored.highest_bid, 100_i128); + } + + #[test] + fn dutch_auction_bid_below_current_price_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let alice = Address::generate(&env); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let auction_id = Symbol::new(&env, "dutch_low_bid"); + + client.init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &50_i128, + &0_u32, + &Some(500_i128), + &Some(100_i128), + ); + + env.ledger().with_mut(|li| li.timestamp = 1500); + let result = client.try_place_bid(&auction_id, &alice, &250_i128); + assert!(result.is_err()); + } + + #[test] + fn dutch_auction_first_bid_settles_immediately() { + let env = Env::default(); + env.mock_all_auths(); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let auction_id = Symbol::new(&env, "dutch_first_bid"); + + client.init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &50_i128, + &0_u32, + &Some(500_i128), + &Some(100_i128), + ); + + env.ledger().with_mut(|li| li.timestamp = 1500); + client.place_bid(&auction_id, &alice, &300_i128); + + let stored: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + + assert_eq!(stored.status, AuctionStatus::Closed); + let result = client.try_place_bid(&auction_id, &bob, &400_i128); + assert!(result.is_err()); + } + + #[test] + fn english_mode_unchanged_with_new_signature() { + let env = Env::default(); + env.mock_all_auths(); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let auction_id = Symbol::new(&env, "english_unchanged"); + + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); + + client.place_bid(&auction_id, &alice, &100_i128); + client.place_bid(&auction_id, &bob, &200_i128); + + let stored: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + + assert_eq!(stored.status, AuctionStatus::Open); + assert_eq!(stored.highest_bidder.unwrap(), bob); + assert_eq!(stored.highest_bid, 200_i128); + } + + // === Dutch Auction Tests === + + #[test] + fn dutch_auction_price_at_start() { + let env = Env::default(); + env.mock_all_auths(); + + let alice = Address::generate(&env); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let auction_id = Symbol::new(&env, "dutch_start"); + + client.init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &50_i128, + &0_u32, + &Some(500_i128), + &Some(100_i128), + ); + + env.ledger().with_mut(|li| li.timestamp = 1000); + client.place_bid(&auction_id, &alice, &500_i128); + + let stored: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + + assert_eq!(stored.status, AuctionStatus::Closed); + assert_eq!(stored.highest_bidder.unwrap(), alice); + assert_eq!(stored.highest_bid, 500_i128); + } + + #[test] + fn dutch_auction_price_at_mid() { + let env = Env::default(); + env.mock_all_auths(); + + let alice = Address::generate(&env); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let auction_id = Symbol::new(&env, "dutch_mid"); + + client.init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &50_i128, + &0_u32, + &Some(500_i128), + &Some(100_i128), + ); + + env.ledger().with_mut(|li| li.timestamp = 1500); + client.place_bid(&auction_id, &alice, &300_i128); + + let stored: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + + assert_eq!(stored.status, AuctionStatus::Closed); + assert_eq!(stored.highest_bidder.unwrap(), alice); + assert_eq!(stored.highest_bid, 300_i128); + } + + #[test] + fn dutch_auction_bid_below_current_price_fails() { + let env = Env::default(); + env.mock_all_auths(); + + let alice = Address::generate(&env); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let auction_id = Symbol::new(&env, "dutch_low_bid"); + + client.init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &50_i128, + &0_u32, + &Some(500_i128), + &Some(100_i128), + ); + + env.ledger().with_mut(|li| li.timestamp = 1500); + let result = client.try_place_bid(&auction_id, &alice, &250_i128); + assert!(result.is_err()); + } + + #[test] + fn english_mode_unchanged_with_new_signature() { + let env = Env::default(); + env.mock_all_auths(); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let auction_id = Symbol::new(&env, "english_unchanged"); + + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); + + client.place_bid(&auction_id, &alice, &100_i128); + client.place_bid(&auction_id, &bob, &200_i128); + + let stored: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + + assert_eq!(stored.status, AuctionStatus::Open); + assert_eq!(stored.highest_bidder.unwrap(), bob); + assert_eq!(stored.highest_bid, 200_i128); + } } diff --git a/gateway-contract/contracts/auction_contract/src/types.rs b/gateway-contract/contracts/auction_contract/src/types.rs index 4e64cf0..0f5f656 100644 --- a/gateway-contract/contracts/auction_contract/src/types.rs +++ b/gateway-contract/contracts/auction_contract/src/types.rs @@ -1,5 +1,14 @@ use soroban_sdk::{contracttype, Address, BytesN}; +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AuctionMode { + /// English auction: ascending price, highest bidder wins at end + English, + /// Dutch auction: descending price, first qualifying bid wins + Dutch, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub enum AuctionStatus { @@ -34,6 +43,7 @@ pub enum AuctionKey { #[contracttype] #[derive(Clone)] pub struct AuctionConfig { + pub mode: AuctionMode, pub username_hash: BytesN<32>, pub start_time: u64, pub end_time: u64, @@ -42,6 +52,10 @@ pub struct AuctionConfig { /// Each new bid must be at least `highest * (1 + min_increment_bps / 10_000)`. /// Capped at 10_000 (100%) on init. Use 0 to require only a 1-stroop increment. pub min_increment_bps: u32, + /// Starting price for Dutch auction (only used in Dutch mode) + pub dutch_start_price: Option, + /// Floor price for Dutch auction (only used in Dutch mode) + pub dutch_floor_price: Option, } #[contracttype]