From 068fc43435da93f5ff38f9e20aea3364fc9213fc Mon Sep 17 00:00:00 2001 From: olaleyeolajide81-sketch Date: Fri, 29 May 2026 17:12:54 +0100 Subject: [PATCH 1/4] feat: add Dutch descending-price auction mode - Add AuctionMode enum (English/Dutch) to types.rs - Extend AuctionConfig with Dutch auction parameters (dutch_start_price, dutch_floor_price) - Implement compute_dutch_price function for overflow-safe monotone decreasing price calculation - Update init_auction to accept mode and Dutch parameters - Update place_bid to support Dutch auction first-bid settlement - Add comprehensive Dutch auction tests covering price at start, mid, and floor - Update existing tests to use new init_auction signature - Fix duplicate members in Cargo.toml Generated with Devin https://cli.devin.ai/docs Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Cargo.toml | 2 +- .../contracts/auction_contract/src/lib.rs | 179 +- .../contracts/auction_contract/src/test.rs | 2121 ++++++++++------- .../contracts/auction_contract/src/types.rs | 14 + 4 files changed, 1406 insertions(+), 910 deletions(-) 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..86477d8 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,78 @@ 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); } diff --git a/gateway-contract/contracts/auction_contract/src/test.rs b/gateway-contract/contracts/auction_contract/src/test.rs index fb3c59f..a466065 100644 --- a/gateway-contract/contracts/auction_contract/src/test.rs +++ b/gateway-contract/contracts/auction_contract/src/test.rs @@ -1,875 +1,1246 @@ -#[cfg(test)] -mod tests { - extern crate std; - use super::super::*; - use crate::errors::AuctionError; - use core::convert::TryFrom; - use core::ops::Range; - 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::token::{Client as TokenClient, StellarAssetClient}; - use soroban_sdk::{Address, Env, IntoVal, Symbol, TryFromVal, TryIntoVal}; - - const REFUND_TOPIC: &str = "BID_RFDN"; - const SETTLEMENT_TOPIC: &str = "LIQ_SETL"; - const AUCTION_ID: &str = "inv_auc"; - const FUZZ_STEPS: usize = 64; - const MAX_INCREMENT: u64 = 500; - - fn advance_ledgers(env: &Env, ledgers: u32) { - env.ledger().with_mut(|li| { - li.sequence_number += ledgers; - li.timestamp += (ledgers as u64) * 5; - }); - } - - fn next_u64(state: &mut u64) -> u64 { - let mut x = *state; - x ^= x << 13; - x ^= x >> 7; - x ^= x << 17; - *state = x; - x - } - - fn pick_index(seed: &mut u64, range: Range) -> usize { - let len = range.end - range.start; - range.start + (next_u64(seed) as usize % len) - } - - fn next_amount_above(seed: &mut u64, current: i128) -> i128 { - current + i128::from((next_u64(seed) % MAX_INCREMENT) + 1) - } - - fn refunded_events(env: &Env) -> Vec { - let mut output = Vec::new(); - for (_contract, topics, data) in env.events().all().iter() { - let t0: Symbol = Symbol::try_from_val(env, &topics.get(0).unwrap()).unwrap(); - if t0 == Symbol::new(env, REFUND_TOPIC) { - let event_data: events::BidRefundedEvent = data.try_into_val(env).unwrap(); - output.push(event_data); - } - } - output - } - - fn settlement_events(env: &Env) -> Vec { - let mut output = Vec::new(); - for (_contract, topics, data) in env.events().all().iter() { - let t0: Symbol = Symbol::try_from_val(env, &topics.get(0).unwrap()).unwrap(); - if t0 == Symbol::new(env, SETTLEMENT_TOPIC) { - let event_data: events::DefaultLiquidationSettlementEvent = - data.try_into_val(env).unwrap(); - output.push(event_data); - } - } - output - } - - #[test] - fn bid_refunded_event_emitted_on_outbid() { - 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, "auc1"); - client.init_auction(&auction_id, &0, &1000, &50_i128, &0_u32); // start 0, end 1000, min 50, 0 bps - - client.place_bid(&auction_id, &alice, &100_i128); - client.place_bid(&auction_id, &bob, &200_i128); - - let refund_events = refunded_events(&env); - assert_eq!(refund_events.len(), 1); - let event_data = refund_events.last().unwrap(); - assert_eq!(event_data.prev_bidder, alice); - assert_eq!(event_data.amount, 100_i128); - } - - #[test] - fn equal_to_highest_bid_rejected_as_bid_too_low() { - 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, "eq_highest"); - client.init_auction(&auction_id, &0, &1000, &50_i128); - - client.place_bid(&auction_id, &alice, &100_i128); - - let result = client.try_place_bid(&auction_id, &bob, &100_i128); - assert!(result.is_err(), "equal-to-highest bid must fail"); - let contract_err = result.unwrap_err().unwrap(); - assert_eq!( - contract_err, - AuctionError::BidTooLow.into(), - "equal-to-highest bid must return BidTooLow" - ); - - let stored_after: crate::types::AuctionState = env - .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) - .unwrap(); - assert_eq!(stored_after.highest_bidder.unwrap(), alice); - assert_eq!(stored_after.highest_bid, 100_i128); - assert_eq!(refunded_events(&env).len(), 0); - } - - #[test] - fn fuzz_bid_sequence_invariants_deterministic() { - let env = Env::default(); - env.mock_all_auths(); - - let bidders: [Address; 5] = [ - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - ]; - - let contract_id = env.register(Auction, ()); - 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 - - let mut seed: u64 = 0xdeadbeefcafebabe; - let mut expected: Option<(Address, i128)> = None; - - for _ in 0..FUZZ_STEPS { - let bidder_idx = pick_index(&mut seed, 0..bidders.len()); - let bidder = bidders[bidder_idx].clone(); - let amount = - next_amount_above(&mut seed, expected.as_ref().map(|(_, a)| *a).unwrap_or(0)); - - client.place_bid(&auction_id, &bidder, &amount); - - // In soroban-sdk v22, env.events() returns events from the most recent successful - // transaction only (not cumulative). Check that this bid emitted exactly one - // BID_RFDN event with the correct previous bidder and amount. - if let Some((prev_addr, prev_amount)) = expected.clone() { - let events = refunded_events(&env); - let evt = events.last().unwrap(); - assert_eq!(evt.prev_bidder, prev_addr); - assert_eq!(evt.amount, prev_amount); - } - - expected = Some((bidder.clone(), amount)); - - let stored: Option = - env.as_contract(&contract_id, || env.storage().persistent().get(&auction_id)); - assert!(stored.is_some(), "stored state must exist"); - let s = stored.unwrap(); - assert_eq!(s.highest_bidder.unwrap(), bidder); - assert_eq!(s.highest_bid, amount); - } - } - - #[test] - fn fuzz_refund_balance_invariant_deterministic() { - let env = Env::default(); - env.mock_all_auths(); - - let bidders: [Address; 4] = [ - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - ]; - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - let token_admin = Address::generate(&env); - let token_id = env.register_stellar_asset_contract_v2(token_admin); - let bid_token = token_id.address(); - - env.as_contract(&contract_id, || { - env.storage() - .instance() - .set(&Symbol::new(&env, "bid_token"), &bid_token); - }); - - let sac = StellarAssetClient::new(&env, &bid_token); - let token_client = TokenClient::new(&env, &bid_token); - - let initial_bidder_balance = 100_000_i128; - for bidder in bidders.iter() { - sac.mint(bidder, &initial_bidder_balance); - } - - let total_initial_balance = token_client.balance(&contract_id) - + bidders - .iter() - .map(|bidder| token_client.balance(bidder)) - .sum::(); - - let mut refunded_by_bidder = [0_i128; 4]; - let mut spent_by_bidder = [0_i128; 4]; - let mut expected: Option<(usize, i128)> = None; - 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); - - for _ in 0..FUZZ_STEPS { - let bidder_idx = pick_index(&mut seed, 0..bidders.len()); - let amount = - next_amount_above(&mut seed, expected.as_ref().map(|(_, a)| *a).unwrap_or(0)); - spent_by_bidder[bidder_idx] += amount; - client.place_bid(&auction_id, &bidders[bidder_idx], &amount); - - if let Some((prev_idx, prev_amount)) = expected { - refunded_by_bidder[prev_idx] += prev_amount; - - let events = refunded_events(&env); - let last = events.last().unwrap(); - assert_eq!(last.prev_bidder, bidders[prev_idx]); - assert_eq!(last.amount, prev_amount); - } - - let stored: crate::types::AuctionState = env - .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) - .unwrap(); - assert_eq!( - token_client.balance(&contract_id), - stored.highest_bid, - "contract escrow must equal only the current highest bid" - ); - for idx in 0..bidders.len() { - assert_eq!( - token_client.balance(&bidders[idx]), - initial_bidder_balance - spent_by_bidder[idx] + refunded_by_bidder[idx], - "bidder balance must reflect exact deposits and refunds" - ); - } - - let total_balance = token_client.balance(&contract_id) - + bidders - .iter() - .map(|bidder| token_client.balance(bidder)) - .sum::(); - assert_eq!(total_balance, total_initial_balance); - - expected = Some((bidder_idx, amount)); - } - } - - #[test] - fn close_semantics_cannot_be_bypassed() { - let env = Env::default(); - env.mock_all_auths(); - - let bidders: [Address; 3] = [ - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - ]; - - let contract_id = env.register(Auction, ()); - 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); - - let mut seed: u64 = 0x11ce_f00d_cafe_beef; - let mut seed: u64 = 0xdeadbeef_cafe_beef; - let mut highest = 0_i128; - for _ in 0..8 { - let idx = pick_index(&mut seed, 0..bidders.len()); - highest = next_amount_above(&mut seed, highest); - client.place_bid(&auction_id, &bidders[idx], &highest); - } - - let expected_state: crate::types::AuctionState = env - .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) - .unwrap(); - let refunds_before_close = refunded_events(&env).len(); - - client.close_auction(&auction_id); - - for _ in 0..16 { - let idx = pick_index(&mut seed, 0..bidders.len()); - let attempted_amount = next_amount_above(&mut seed, expected_state.highest_bid); - - let attempt = client.try_place_bid(&auction_id, &bidders[idx], &attempted_amount); - assert!(attempt.is_err(), "closed auction accepted a new bid"); - - let stored_state: crate::types::AuctionState = env - .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) - .unwrap(); - assert_eq!(stored_state.highest_bidder, expected_state.highest_bidder); - assert_eq!(stored_state.highest_bid, expected_state.highest_bid); - assert_eq!(stored_state.status, AuctionStatus::Closed); - assert_eq!(refunded_events(&env).len(), refunds_before_close); - } - } - - #[test] - fn settle_default_liquidation_requires_closed_auction() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - let bidder = Address::generate(&env); - let factory = Address::generate(&env); - 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.place_bid(&auction_id, &bidder, &100_i128); - - let result = client.try_settle_default_liquidation( - &auction_id, - &Address::generate(&env), - &Address::generate(&env), - ); - assert!(result.is_err(), "open auction should not settle"); - } - - #[test] - fn settle_default_liquidation_emits_once_after_close() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - let bidder = Address::generate(&env); - let borrower = Address::generate(&env); - let credit_contract = Address::generate(&env); - let factory = Address::generate(&env); - 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.place_bid(&auction_id, &bidder, &420_i128); - client.close_auction(&auction_id); - client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); - - let events = settlement_events(&env); - assert_eq!(events.len(), 1); - let evt = events.last().unwrap(); - assert_eq!(evt.auction_id, auction_id); - assert_eq!(evt.credit_contract, credit_contract); - assert_eq!(evt.borrower, borrower); - assert_eq!(evt.winner, bidder); - assert_eq!(evt.recovered_amount, 420_i128); - } - - #[test] - #[should_panic] - fn settle_default_liquidation_replay_reverts() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - let factory = Address::generate(&env); - let borrower = Address::generate(&env); - let credit_contract = Address::generate(&env); - let auction_id = Symbol::new(&env, "liq_replay"); - - client.set_factory_contract(&factory); - client.init_auction(&auction_id, &0, &1000, &50_i128); - client.close_auction(&auction_id); - client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); - // second call must panic - client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); - let replay = - client.try_settle_default_liquidation(&auction_id, &credit_contract, &borrower); - assert!(replay.is_err(), "settlement replay should panic"); - } - - #[test] - fn zero_bid_auction_settles_with_borrower_as_winner() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - let borrower = Address::generate(&env); - let credit_contract = Address::generate(&env); - let factory = Address::generate(&env); - 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); - // no bids - client.close_auction(&auction_id); - client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); - - let events = settlement_events(&env); - assert_eq!(events.len(), 1); - let evt = events.last().unwrap(); - assert_eq!(evt.winner, borrower); - assert_eq!(evt.recovered_amount, 0_i128); - } - - // --- factory auth negative tests --- - - #[test] - fn settle_default_liquidation_reverts_when_factory_unset() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - 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.close_auction(&auction_id); - - let result = catch_unwind(AssertUnwindSafe(|| { - client.settle_default_liquidation( - &auction_id, - &Address::generate(&env), - &Address::generate(&env), - ); - })); - - assert!(result.is_err(), "should revert when factory contract is unset"); - } - - #[test] - fn settle_default_liquidation_reverts_for_wrong_caller() { - let env = Env::default(); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - let factory = Address::generate(&env); - let borrower = Address::generate(&env); - let credit_contract = Address::generate(&env); - let auction_id = Symbol::new(&env, "wrong_caller"); - - // 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.close_auction(&auction_id); - - // Attempt settlement with no auth provided — factory.require_auth() will reject - let result = catch_unwind(AssertUnwindSafe(|| { - // Create a fresh env without mock_all_auths so require_auth fails - let env2 = Env::default(); - let contract_id2 = env2.register(Auction, ()); - let client2 = AuctionClient::new(&env2, &contract_id2); - let factory2 = Address::generate(&env2); - let auction_id2 = Symbol::new(&env2, "wrong_caller2"); - // Setup with mocks - env2.mock_all_auths(); - client2.set_factory_contract(&factory2); - client2.init_auction(&auction_id2, &0, &1000, &50_i128); - client2.close_auction(&auction_id2); - // Call with only a non-factory address authorized - let wrong_caller = Address::generate(&env2); - client2 - .mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &wrong_caller, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id2, - fn_name: "settle_default_liquidation", - args: ( - auction_id2.clone(), - Address::generate(&env2), - Address::generate(&env2), - ) - .into_val(&env2), - sub_invokes: &[], - }, - }]) - .settle_default_liquidation( - &auction_id2, - &Address::generate(&env2), - &Address::generate(&env2), - ); - })); - - assert!(result.is_err(), "wrong caller should be rejected"); - } - - #[test] - fn bid_after_end_time_rejected() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(1001); // past end time - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - let bidder = Address::generate(&env); - let auction_id = Symbol::new(&env, "timed_out"); - - client.init_auction(&auction_id, &0, &1000, &50_i128, &0_u32); - - let attempt = client.try_place_bid(&auction_id, &bidder, &100_i128); - assert!(attempt.is_err(), "bid after end time should be rejected"); - } - - #[test] - fn settle_default_liquidation_requires_factory_contract_set() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - let bidder = Address::generate(&env); - let borrower = Address::generate(&env); - let credit_contract = Address::generate(&env); - let auction_id = Symbol::new(&env, "no_factory"); - - client.init_auction(&auction_id, &0, &1000, &50_i128); - client.place_bid(&auction_id, &bidder, &420_i128); - client.close_auction(&auction_id); - - let result = catch_unwind(AssertUnwindSafe(|| { - client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); - })); - - assert!(result.is_err(), "should panic if factory not set"); - } - - #[test] - fn settle_default_liquidation_requires_authorized_factory() { - 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.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. - let result = env.as_contract(&intruder, || { - catch_unwind(AssertUnwindSafe(|| { - client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); - })) - }); - - assert!(result.is_err(), "should panic if unauthorized caller"); - } - - #[test] - fn settle_default_liquidation_succeeds_with_factory() { - let env = Env::default(); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - let factory = 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, "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.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); - }); - - 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] - fn init_auction_rejects_increment_bps_above_10000() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - 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); - })); - assert!(result.is_err(), "bps > 10000 should be rejected at init"); - } - - #[test] - fn init_auction_accepts_zero_and_max_increment_bps() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(Auction, ()); - 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); - // 10_000 bps (100% increment) is the maximum valid value - client.init_auction(&Symbol::new(&env, "bps10k"), &0, &1000, &1_i128, &10_000_u32); - } - - // ── min_increment_bps: bid threshold enforcement ─────────────────────── - - #[test] - fn bid_just_below_increment_threshold_rejected() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - let auction_id = Symbol::new(&env, "inc_low"); - - let alice = Address::generate(&env); - 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.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"); - - // state must be unchanged - let state: crate::types::AuctionState = env - .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) - .unwrap(); - assert_eq!(state.highest_bid, 1_000_i128); - assert_eq!(state.highest_bidder.unwrap(), alice); - } - - #[test] - fn bid_at_increment_threshold_accepted() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - let auction_id = Symbol::new(&env, "inc_ok"); - - let alice = Address::generate(&env); - 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.place_bid(&auction_id, &alice, &1_000_i128); - client.place_bid(&auction_id, &bob, &1_010_i128); // exactly at threshold - - let state: crate::types::AuctionState = env - .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) - .unwrap(); - assert_eq!(state.highest_bid, 1_010_i128); - assert_eq!(state.highest_bidder.unwrap(), bob); - } - - #[test] - fn bid_increment_ceiling_rounding_non_divisible() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - let auction_id = Symbol::new(&env, "inc_ceil"); - - let alice = Address::generate(&env); - let bob = Address::generate(&env); - 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.place_bid(&auction_id, &alice, &1_000_i128); - - let just_below = catch_unwind(AssertUnwindSafe(|| { - client.place_bid(&auction_id, &bob, &1_033_i128); // 1033 < 1034 - })); - assert!(just_below.is_err(), "bid below ceiling threshold must fail"); - - client.place_bid(&auction_id, &carol, &1_034_i128); // exactly at ceiling threshold - - let state: crate::types::AuctionState = env - .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) - .unwrap(); - assert_eq!(state.highest_bid, 1_034_i128); - assert_eq!(state.highest_bidder.unwrap(), carol); - } - - #[test] - fn bid_zero_increment_bps_requires_at_least_one_stroop_above() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - let auction_id = Symbol::new(&env, "inc_zero"); - - let alice = Address::generate(&env); - let bob = Address::generate(&env); - 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.place_bid(&auction_id, &alice, &500_i128); - - let equal = catch_unwind(AssertUnwindSafe(|| { - client.place_bid(&auction_id, &bob, &500_i128); - })); - assert!(equal.is_err(), "equal bid must be rejected even at 0 bps"); - - // exactly one stroop above is accepted - client.place_bid(&auction_id, &carol, &501_i128); - - let state: crate::types::AuctionState = env - .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(); - - 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 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); - - 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(); - - let winner = Address::generate(&env); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - 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); - - // 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"); -} - -#[test] -fn claim_before_close_fails_not_closed() { - let env = Env::default(); - env.mock_all_auths(); - - let winner = Address::generate(&env); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - 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 - - 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(); - - let borrower = Address::generate(&env); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - 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); - - let result = catch_unwind(AssertUnwindSafe(|| { - client.claim_auction(&auction_id); - })); - assert!(result.is_err(), "zero-bid claim should fail"); -} -} +#[cfg(test)] +mod tests { + extern crate std; + use super::super::*; + use crate::errors::AuctionError; + use core::convert::TryFrom; + use core::ops::Range; + 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::token::{Client as TokenClient, StellarAssetClient}; + use soroban_sdk::{Address, Env, IntoVal, Symbol, TryFromVal, TryIntoVal}; + + const REFUND_TOPIC: &str = "BID_RFDN"; + const SETTLEMENT_TOPIC: &str = "LIQ_SETL"; + const AUCTION_ID: &str = "inv_auc"; + const FUZZ_STEPS: usize = 64; + const MAX_INCREMENT: u64 = 500; + + fn advance_ledgers(env: &Env, ledgers: u32) { + env.ledger().with_mut(|li| { + li.sequence_number += ledgers; + li.timestamp += (ledgers as u64) * 5; + }); + } + + fn next_u64(state: &mut u64) -> u64 { + let mut x = *state; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + *state = x; + x + } + + fn pick_index(seed: &mut u64, range: Range) -> usize { + let len = range.end - range.start; + range.start + (next_u64(seed) as usize % len) + } + + fn next_amount_above(seed: &mut u64, current: i128) -> i128 { + current + i128::from((next_u64(seed) % MAX_INCREMENT) + 1) + } + + fn refunded_events(env: &Env) -> Vec { + let mut output = Vec::new(); + for (_contract, topics, data) in env.events().all().iter() { + let t0: Symbol = Symbol::try_from_val(env, &topics.get(0).unwrap()).unwrap(); + if t0 == Symbol::new(env, REFUND_TOPIC) { + let event_data: events::BidRefundedEvent = data.try_into_val(env).unwrap(); + output.push(event_data); + } + } + output + } + + fn settlement_events(env: &Env) -> Vec { + let mut output = Vec::new(); + for (_contract, topics, data) in env.events().all().iter() { + let t0: Symbol = Symbol::try_from_val(env, &topics.get(0).unwrap()).unwrap(); + if t0 == Symbol::new(env, SETTLEMENT_TOPIC) { + let event_data: events::DefaultLiquidationSettlementEvent = + data.try_into_val(env).unwrap(); + output.push(event_data); + } + } + output + } + + #[test] + fn bid_refunded_event_emitted_on_outbid() { + 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, "auc1"); + 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); + + let refund_events = refunded_events(&env); + assert_eq!(refund_events.len(), 1); + let event_data = refund_events.last().unwrap(); + assert_eq!(event_data.prev_bidder, alice); + assert_eq!(event_data.amount, 100_i128); + } + + #[test] + fn equal_to_highest_bid_rejected_as_bid_too_low() { + 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, "eq_highest"); + client.init_auction(&auction_id, &AuctionMode::English, &0, &1000, &50_i128, &0_u32, &None, &None); + + client.place_bid(&auction_id, &alice, &100_i128); + + let result = client.try_place_bid(&auction_id, &bob, &100_i128); + assert!(result.is_err(), "equal-to-highest bid must fail"); + let contract_err = result.unwrap_err().unwrap(); + assert_eq!( + contract_err, + AuctionError::BidTooLow.into(), + "equal-to-highest bid must return BidTooLow" + ); + + let stored_after: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + assert_eq!(stored_after.highest_bidder.unwrap(), alice); + assert_eq!(stored_after.highest_bid, 100_i128); + assert_eq!(refunded_events(&env).len(), 0); + } + + #[test] + fn fuzz_bid_sequence_invariants_deterministic() { + let env = Env::default(); + env.mock_all_auths(); + + let bidders: [Address; 5] = [ + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + ]; + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let auction_id = Symbol::new(&env, AUCTION_ID); + + 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; + + for _ in 0..FUZZ_STEPS { + let bidder_idx = pick_index(&mut seed, 0..bidders.len()); + let bidder = bidders[bidder_idx].clone(); + let amount = + next_amount_above(&mut seed, expected.as_ref().map(|(_, a)| *a).unwrap_or(0)); + + client.place_bid(&auction_id, &bidder, &amount); + + // In soroban-sdk v22, env.events() returns events from the most recent successful + // transaction only (not cumulative). Check that this bid emitted exactly one + // BID_RFDN event with the correct previous bidder and amount. + if let Some((prev_addr, prev_amount)) = expected.clone() { + let events = refunded_events(&env); + let evt = events.last().unwrap(); + assert_eq!(evt.prev_bidder, prev_addr); + assert_eq!(evt.amount, prev_amount); + } + + expected = Some((bidder.clone(), amount)); + + let stored: Option = + env.as_contract(&contract_id, || env.storage().persistent().get(&auction_id)); + assert!(stored.is_some(), "stored state must exist"); + let s = stored.unwrap(); + assert_eq!(s.highest_bidder.unwrap(), bidder); + assert_eq!(s.highest_bid, amount); + } + } + + #[test] + fn fuzz_refund_balance_invariant_deterministic() { + let env = Env::default(); + env.mock_all_auths(); + + let bidders: [Address; 4] = [ + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + ]; + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin); + let bid_token = token_id.address(); + + env.as_contract(&contract_id, || { + env.storage() + .instance() + .set(&Symbol::new(&env, "bid_token"), &bid_token); + }); + + let sac = StellarAssetClient::new(&env, &bid_token); + let token_client = TokenClient::new(&env, &bid_token); + + let initial_bidder_balance = 100_000_i128; + for bidder in bidders.iter() { + sac.mint(bidder, &initial_bidder_balance); + } + + let total_initial_balance = token_client.balance(&contract_id) + + bidders + .iter() + .map(|bidder| token_client.balance(bidder)) + .sum::(); + + let mut refunded_by_bidder = [0_i128; 4]; + let mut spent_by_bidder = [0_i128; 4]; + let mut expected: Option<(usize, i128)> = None; + let mut seed: u64 = 0x1234_5678_9abc_def0; + let auction_id = Symbol::new(&env, "refund_auc"); + + 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()); + let amount = + next_amount_above(&mut seed, expected.as_ref().map(|(_, a)| *a).unwrap_or(0)); + spent_by_bidder[bidder_idx] += amount; + client.place_bid(&auction_id, &bidders[bidder_idx], &amount); + + if let Some((prev_idx, prev_amount)) = expected { + refunded_by_bidder[prev_idx] += prev_amount; + + let events = refunded_events(&env); + let last = events.last().unwrap(); + assert_eq!(last.prev_bidder, bidders[prev_idx]); + assert_eq!(last.amount, prev_amount); + } + + let stored: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + assert_eq!( + token_client.balance(&contract_id), + stored.highest_bid, + "contract escrow must equal only the current highest bid" + ); + for idx in 0..bidders.len() { + assert_eq!( + token_client.balance(&bidders[idx]), + initial_bidder_balance - spent_by_bidder[idx] + refunded_by_bidder[idx], + "bidder balance must reflect exact deposits and refunds" + ); + } + + let total_balance = token_client.balance(&contract_id) + + bidders + .iter() + .map(|bidder| token_client.balance(bidder)) + .sum::(); + assert_eq!(total_balance, total_initial_balance); + + expected = Some((bidder_idx, amount)); + } + } + + #[test] + fn close_semantics_cannot_be_bypassed() { + let env = Env::default(); + env.mock_all_auths(); + + let bidders: [Address; 3] = [ + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + ]; + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let auction_id = Symbol::new(&env, "close_auc"); + + 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; + let mut highest = 0_i128; + for _ in 0..8 { + let idx = pick_index(&mut seed, 0..bidders.len()); + highest = next_amount_above(&mut seed, highest); + client.place_bid(&auction_id, &bidders[idx], &highest); + } + + let expected_state: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + let refunds_before_close = refunded_events(&env).len(); + + client.close_auction(&auction_id); + + for _ in 0..16 { + let idx = pick_index(&mut seed, 0..bidders.len()); + let attempted_amount = next_amount_above(&mut seed, expected_state.highest_bid); + + let attempt = client.try_place_bid(&auction_id, &bidders[idx], &attempted_amount); + assert!(attempt.is_err(), "closed auction accepted a new bid"); + + let stored_state: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + assert_eq!(stored_state.highest_bidder, expected_state.highest_bidder); + assert_eq!(stored_state.highest_bid, expected_state.highest_bid); + assert_eq!(stored_state.status, AuctionStatus::Closed); + assert_eq!(refunded_events(&env).len(), refunds_before_close); + } + } + + #[test] + fn settle_default_liquidation_requires_closed_auction() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let bidder = Address::generate(&env); + let factory = Address::generate(&env); + let auction_id = Symbol::new(&env, "liq_open"); + + client.set_factory_contract(&factory); + client.init_auction(&auction_id, &AuctionMode::English, &0, &1000, &50_i128, &0_u32, &None, &None); + client.init_auction(&auction_id, &0, &1000, &50_i128, &0_u32); + client.place_bid(&auction_id, &bidder, &100_i128); + + let result = client.try_settle_default_liquidation( + &auction_id, + &Address::generate(&env), + &Address::generate(&env), + ); + assert!(result.is_err(), "open auction should not settle"); + } + + #[test] + fn settle_default_liquidation_emits_once_after_close() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let bidder = Address::generate(&env); + let borrower = Address::generate(&env); + let credit_contract = Address::generate(&env); + let factory = Address::generate(&env); + let auction_id = Symbol::new(&env, "liq_closed"); + + client.set_factory_contract(&factory); + client.init_auction(&auction_id, &AuctionMode::English, &0, &1000, &50_i128, &0_u32, &None, &None); + client.init_auction(&auction_id, &0, &1000, &50_i128, &0_u32); + client.place_bid(&auction_id, &bidder, &420_i128); + client.close_auction(&auction_id); + client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); + + let events = settlement_events(&env); + assert_eq!(events.len(), 1); + let evt = events.last().unwrap(); + assert_eq!(evt.auction_id, auction_id); + assert_eq!(evt.credit_contract, credit_contract); + assert_eq!(evt.borrower, borrower); + assert_eq!(evt.winner, bidder); + assert_eq!(evt.recovered_amount, 420_i128); + } + + #[test] + #[should_panic] + fn settle_default_liquidation_replay_reverts() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let factory = Address::generate(&env); + let borrower = Address::generate(&env); + let credit_contract = Address::generate(&env); + let auction_id = Symbol::new(&env, "liq_replay"); + + client.set_factory_contract(&factory); + 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 + client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); + let replay = + client.try_settle_default_liquidation(&auction_id, &credit_contract, &borrower); + assert!(replay.is_err(), "settlement replay should panic"); + } + + #[test] + fn zero_bid_auction_settles_with_borrower_as_winner() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let borrower = Address::generate(&env); + let credit_contract = Address::generate(&env); + let factory = Address::generate(&env); + let auction_id = Symbol::new(&env, "zero_bid"); + + client.set_factory_contract(&factory); + client.init_auction(&auction_id, &AuctionMode::English, &0, &1000, &50_i128, &0_u32, &None, &None); + client.init_auction(&auction_id, &0, &1000, &50_i128, &0_u32); + // no bids + client.close_auction(&auction_id); + client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); + + let events = settlement_events(&env); + assert_eq!(events.len(), 1); + let evt = events.last().unwrap(); + assert_eq!(evt.winner, borrower); + assert_eq!(evt.recovered_amount, 0_i128); + } + + // --- factory auth negative tests --- + + #[test] + fn settle_default_liquidation_reverts_when_factory_unset() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let auction_id = Symbol::new(&env, "no_factory"); + + // No set_factory_contract call — factory is unset + 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(|| { + client.settle_default_liquidation( + &auction_id, + &Address::generate(&env), + &Address::generate(&env), + ); + })); + + assert!(result.is_err(), "should revert when factory contract is unset"); + } + + #[test] + fn settle_default_liquidation_reverts_for_wrong_caller() { + let env = Env::default(); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let factory = Address::generate(&env); + let borrower = Address::generate(&env); + let credit_contract = Address::generate(&env); + let auction_id = Symbol::new(&env, "wrong_caller"); + + // Setup: register factory and close an auction + env.mock_all_auths(); + client.set_factory_contract(&factory); + 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 + let result = catch_unwind(AssertUnwindSafe(|| { + // Create a fresh env without mock_all_auths so require_auth fails + let env2 = Env::default(); + let contract_id2 = env2.register(Auction, ()); + let client2 = AuctionClient::new(&env2, &contract_id2); + let factory2 = Address::generate(&env2); + let auction_id2 = Symbol::new(&env2, "wrong_caller2"); + // Setup with mocks + env2.mock_all_auths(); + client2.set_factory_contract(&factory2); + client2.init_auction(&auction_id2, &0, &1000, &50_i128); + client2.close_auction(&auction_id2); + // Call with only a non-factory address authorized + let wrong_caller = Address::generate(&env2); + client2 + .mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &wrong_caller, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id2, + fn_name: "settle_default_liquidation", + args: ( + auction_id2.clone(), + Address::generate(&env2), + Address::generate(&env2), + ) + .into_val(&env2), + sub_invokes: &[], + }, + }]) + .settle_default_liquidation( + &auction_id2, + &Address::generate(&env2), + &Address::generate(&env2), + ); + })); + + assert!(result.is_err(), "wrong caller should be rejected"); + } + + #[test] + fn bid_after_end_time_rejected() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1001); // past end time + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let bidder = Address::generate(&env); + let auction_id = Symbol::new(&env, "timed_out"); + + client.init_auction(&auction_id, &0, &1000, &50_i128, &0_u32); + + let attempt = client.try_place_bid(&auction_id, &bidder, &100_i128); + assert!(attempt.is_err(), "bid after end time should be rejected"); + } + + #[test] + fn settle_default_liquidation_requires_factory_contract_set() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let bidder = Address::generate(&env); + let borrower = Address::generate(&env); + let credit_contract = Address::generate(&env); + let auction_id = Symbol::new(&env, "no_factory"); + + 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); + + let result = catch_unwind(AssertUnwindSafe(|| { + client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); + })); + + assert!(result.is_err(), "should panic if factory not set"); + } + + #[test] + fn settle_default_liquidation_requires_authorized_factory() { + 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, &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. + let result = env.as_contract(&intruder, || { + catch_unwind(AssertUnwindSafe(|| { + client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); + })) + }); + + assert!(result.is_err(), "should panic if unauthorized caller"); + } + + #[test] + fn settle_default_liquidation_succeeds_with_factory() { + let env = Env::default(); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let factory = 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, "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, &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); + }); + + 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] + fn init_auction_rejects_increment_bps_above_10000() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + 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); + })); + assert!(result.is_err(), "bps > 10000 should be rejected at init"); + } + + #[test] + fn init_auction_accepts_zero_and_max_increment_bps() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Auction, ()); + 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); + // 10_000 bps (100% increment) is the maximum valid value + client.init_auction(&Symbol::new(&env, "bps10k"), &0, &1000, &1_i128, &10_000_u32); + } + + // ── min_increment_bps: bid threshold enforcement ─────────────────────── + + #[test] + fn bid_just_below_increment_threshold_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let auction_id = Symbol::new(&env, "inc_low"); + + let alice = Address::generate(&env); + 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.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"); + + // state must be unchanged + let state: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + assert_eq!(state.highest_bid, 1_000_i128); + assert_eq!(state.highest_bidder.unwrap(), alice); + } + + #[test] + fn bid_at_increment_threshold_accepted() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let auction_id = Symbol::new(&env, "inc_ok"); + + let alice = Address::generate(&env); + 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.place_bid(&auction_id, &alice, &1_000_i128); + client.place_bid(&auction_id, &bob, &1_010_i128); // exactly at threshold + + let state: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + assert_eq!(state.highest_bid, 1_010_i128); + assert_eq!(state.highest_bidder.unwrap(), bob); + } + + #[test] + fn bid_increment_ceiling_rounding_non_divisible() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let auction_id = Symbol::new(&env, "inc_ceil"); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + 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.place_bid(&auction_id, &alice, &1_000_i128); + + let just_below = catch_unwind(AssertUnwindSafe(|| { + client.place_bid(&auction_id, &bob, &1_033_i128); // 1033 < 1034 + })); + assert!(just_below.is_err(), "bid below ceiling threshold must fail"); + + client.place_bid(&auction_id, &carol, &1_034_i128); // exactly at ceiling threshold + + let state: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + assert_eq!(state.highest_bid, 1_034_i128); + assert_eq!(state.highest_bidder.unwrap(), carol); + } + + #[test] + fn bid_zero_increment_bps_requires_at_least_one_stroop_above() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let auction_id = Symbol::new(&env, "inc_zero"); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let carol = Address::generate(&env); + + // 0 bps: any strictly higher bid is accepted; equal bid must be rejected + 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(|| { + client.place_bid(&auction_id, &bob, &500_i128); + })); + assert!(equal.is_err(), "equal bid must be rejected even at 0 bps"); + + // exactly one stroop above is accepted + client.place_bid(&auction_id, &carol, &501_i128); + + let state: crate::types::AuctionState = env + .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(); + + 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 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); + + 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(); + + let winner = Address::generate(&env); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + 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); + + // 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"); +} + +#[test] +fn claim_before_close_fails_not_closed() { + let env = Env::default(); + env.mock_all_auths(); + + let winner = Address::generate(&env); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + 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 + + 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(); + + let borrower = Address::generate(&env); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + 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); + + 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"); + + // Initialize Dutch auction: start 1000, end 2000, start_price 500, floor_price 100 + client.init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &50_i128, + &0_u32, + &Some(500_i128), + &Some(100_i128), + ); + + // At start (time 1000), price should be 500 + env.ledger().with_mut(|li| li.timestamp = 1000); + + // Bid at start price should succeed + 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, "Dutch auction should close on first bid"); + 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"); + + // Initialize Dutch auction: start 1000, end 2000 (duration 1000), start_price 500, floor_price 100 + client.init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &50_i128, + &0_u32, + &Some(500_i128), + &Some(100_i128), + ); + + // At midpoint (time 1500), price should be 300 (500 - (500-100)*(500/1000) = 500 - 200 = 300) + env.ledger().with_mut(|li| li.timestamp = 1500); + + // Bid at mid price should succeed + 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, "Dutch auction should close on first bid"); + 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"); + + // Initialize Dutch auction: start 1000, end 2000, start_price 500, floor_price 100 + client.init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &50_i128, + &0_u32, + &Some(500_i128), + &Some(100_i128), + ); + + // At end (time 2000), price should be floor price 100 + env.ledger().with_mut(|li| li.timestamp = 2000); + + // Bid at floor price should succeed + 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, "Dutch auction should close on first bid"); + 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"); + + // Initialize Dutch auction: start 1000, end 2000, start_price 500, floor_price 100 + client.init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &50_i128, + &0_u32, + &Some(500_i128), + &Some(100_i128), + ); + + // At midpoint (time 1500), current price should be 300 + env.ledger().with_mut(|li| li.timestamp = 1500); + + // Bid below current price should fail + let result = client.try_place_bid(&auction_id, &alice, &250_i128); + assert!(result.is_err(), "bid below current price should fail"); +} + +#[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"); + + // Initialize Dutch auction + 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); + + // First bid should settle the auction + 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, "Auction should be closed"); + + // Second bid should fail because auction is closed + let result = client.try_place_bid(&auction_id, &bob, &400_i128); + assert!(result.is_err(), "second bid should fail on closed auction"); +} + +#[test] +fn dutch_auction_price_clamps_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_clamp"); + + // Initialize Dutch auction: start 1000, end 2000, start_price 500, floor_price 100 + client.init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &50_i128, + &0_u32, + &Some(500_i128), + &Some(100_i128), + ); + + // Past end time (time 3000), price should still be floor price 100 + env.ledger().with_mut(|li| li.timestamp = 3000); + + // Bid at floor price should succeed even past end time + 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.highest_bid, 100_i128, "price should clamp at floor"); +} + +#[test] +fn dutch_auction_requires_start_and_floor_price() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let auction_id = Symbol::new(&env, "dutch_missing_params"); + + // Dutch auction without start_price should fail + let result = client.try_init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &50_i128, + &0_u32, + &None, + &Some(100_i128), + ); + assert!(result.is_err(), "Dutch auction requires start_price"); + + // Dutch auction without floor_price should fail + let result = client.try_init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &50_i128, + &0_u32, + &Some(500_i128), + &None, + ); + assert!(result.is_err(), "Dutch auction requires floor_price"); +} + +#[test] +fn dutch_auction_validates_start_greater_than_floor() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let auction_id = Symbol::new(&env, "dutch_invalid_range"); + + // Dutch auction with start_price < floor_price should fail + let result = client.try_init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &50_i128, + &0_u32, + &Some(100_i128), + &Some(500_i128), + ); + assert!(result.is_err(), "start_price must be >= floor_price"); +} + +#[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"); + + // Initialize English auction with new signature + client.init_auction( + &auction_id, + &AuctionMode::English, + &0, + &1000, + &50_i128, + &0_u32, + &None, + &None, + ); + + // English auction behavior should be unchanged + 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, "English auction should remain open"); + assert_eq!(stored.highest_bidder.unwrap(), bob); + assert_eq!(stored.highest_bid, 200_i128); +} + +#[test] +fn dutch_auction_respects_min_bid() { + 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_min_bid"); + + // Initialize Dutch auction with min_bid 150, start_price 500, floor_price 100 + client.init_auction( + &auction_id, + &AuctionMode::Dutch, + &1000, + &2000, + &150_i128, + &0_u32, + &Some(500_i128), + &Some(100_i128), + ); + + // At floor (time 2000), Dutch price is 100, but min_bid is 150 + env.ledger().with_mut(|li| li.timestamp = 2000); + + // Bid below min_bid should fail even if above Dutch price + let result = client.try_place_bid(&auction_id, &alice, &120_i128); + assert!(result.is_err(), "bid must meet min_bid requirement"); + + // Bid at min_bid should succeed + client.place_bid(&auction_id, &alice, &150_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_bid, 150_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] From bd87e7e98677e8c6120b9b10ebf51b1fed381a29 Mon Sep 17 00:00:00 2001 From: olaleyeolajide81-sketch Date: Fri, 29 May 2026 17:39:32 +0100 Subject: [PATCH 2/4] fix: properly add Dutch auction tests inside tests module and update all test calls --- .../contracts/auction_contract/src/test.rs | 2328 ++++++++--------- 1 file changed, 1082 insertions(+), 1246 deletions(-) diff --git a/gateway-contract/contracts/auction_contract/src/test.rs b/gateway-contract/contracts/auction_contract/src/test.rs index a466065..abf4681 100644 --- a/gateway-contract/contracts/auction_contract/src/test.rs +++ b/gateway-contract/contracts/auction_contract/src/test.rs @@ -1,1246 +1,1082 @@ -#[cfg(test)] -mod tests { - extern crate std; - use super::super::*; - use crate::errors::AuctionError; - use core::convert::TryFrom; - use core::ops::Range; - 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::token::{Client as TokenClient, StellarAssetClient}; - use soroban_sdk::{Address, Env, IntoVal, Symbol, TryFromVal, TryIntoVal}; - - const REFUND_TOPIC: &str = "BID_RFDN"; - const SETTLEMENT_TOPIC: &str = "LIQ_SETL"; - const AUCTION_ID: &str = "inv_auc"; - const FUZZ_STEPS: usize = 64; - const MAX_INCREMENT: u64 = 500; - - fn advance_ledgers(env: &Env, ledgers: u32) { - env.ledger().with_mut(|li| { - li.sequence_number += ledgers; - li.timestamp += (ledgers as u64) * 5; - }); - } - - fn next_u64(state: &mut u64) -> u64 { - let mut x = *state; - x ^= x << 13; - x ^= x >> 7; - x ^= x << 17; - *state = x; - x - } - - fn pick_index(seed: &mut u64, range: Range) -> usize { - let len = range.end - range.start; - range.start + (next_u64(seed) as usize % len) - } - - fn next_amount_above(seed: &mut u64, current: i128) -> i128 { - current + i128::from((next_u64(seed) % MAX_INCREMENT) + 1) - } - - fn refunded_events(env: &Env) -> Vec { - let mut output = Vec::new(); - for (_contract, topics, data) in env.events().all().iter() { - let t0: Symbol = Symbol::try_from_val(env, &topics.get(0).unwrap()).unwrap(); - if t0 == Symbol::new(env, REFUND_TOPIC) { - let event_data: events::BidRefundedEvent = data.try_into_val(env).unwrap(); - output.push(event_data); - } - } - output - } - - fn settlement_events(env: &Env) -> Vec { - let mut output = Vec::new(); - for (_contract, topics, data) in env.events().all().iter() { - let t0: Symbol = Symbol::try_from_val(env, &topics.get(0).unwrap()).unwrap(); - if t0 == Symbol::new(env, SETTLEMENT_TOPIC) { - let event_data: events::DefaultLiquidationSettlementEvent = - data.try_into_val(env).unwrap(); - output.push(event_data); - } - } - output - } - - #[test] - fn bid_refunded_event_emitted_on_outbid() { - 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, "auc1"); - 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); - - let refund_events = refunded_events(&env); - assert_eq!(refund_events.len(), 1); - let event_data = refund_events.last().unwrap(); - assert_eq!(event_data.prev_bidder, alice); - assert_eq!(event_data.amount, 100_i128); - } - - #[test] - fn equal_to_highest_bid_rejected_as_bid_too_low() { - 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, "eq_highest"); - client.init_auction(&auction_id, &AuctionMode::English, &0, &1000, &50_i128, &0_u32, &None, &None); - - client.place_bid(&auction_id, &alice, &100_i128); - - let result = client.try_place_bid(&auction_id, &bob, &100_i128); - assert!(result.is_err(), "equal-to-highest bid must fail"); - let contract_err = result.unwrap_err().unwrap(); - assert_eq!( - contract_err, - AuctionError::BidTooLow.into(), - "equal-to-highest bid must return BidTooLow" - ); - - let stored_after: crate::types::AuctionState = env - .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) - .unwrap(); - assert_eq!(stored_after.highest_bidder.unwrap(), alice); - assert_eq!(stored_after.highest_bid, 100_i128); - assert_eq!(refunded_events(&env).len(), 0); - } - - #[test] - fn fuzz_bid_sequence_invariants_deterministic() { - let env = Env::default(); - env.mock_all_auths(); - - let bidders: [Address; 5] = [ - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - ]; - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - let auction_id = Symbol::new(&env, AUCTION_ID); - - 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; - - for _ in 0..FUZZ_STEPS { - let bidder_idx = pick_index(&mut seed, 0..bidders.len()); - let bidder = bidders[bidder_idx].clone(); - let amount = - next_amount_above(&mut seed, expected.as_ref().map(|(_, a)| *a).unwrap_or(0)); - - client.place_bid(&auction_id, &bidder, &amount); - - // In soroban-sdk v22, env.events() returns events from the most recent successful - // transaction only (not cumulative). Check that this bid emitted exactly one - // BID_RFDN event with the correct previous bidder and amount. - if let Some((prev_addr, prev_amount)) = expected.clone() { - let events = refunded_events(&env); - let evt = events.last().unwrap(); - assert_eq!(evt.prev_bidder, prev_addr); - assert_eq!(evt.amount, prev_amount); - } - - expected = Some((bidder.clone(), amount)); - - let stored: Option = - env.as_contract(&contract_id, || env.storage().persistent().get(&auction_id)); - assert!(stored.is_some(), "stored state must exist"); - let s = stored.unwrap(); - assert_eq!(s.highest_bidder.unwrap(), bidder); - assert_eq!(s.highest_bid, amount); - } - } - - #[test] - fn fuzz_refund_balance_invariant_deterministic() { - let env = Env::default(); - env.mock_all_auths(); - - let bidders: [Address; 4] = [ - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - ]; - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - let token_admin = Address::generate(&env); - let token_id = env.register_stellar_asset_contract_v2(token_admin); - let bid_token = token_id.address(); - - env.as_contract(&contract_id, || { - env.storage() - .instance() - .set(&Symbol::new(&env, "bid_token"), &bid_token); - }); - - let sac = StellarAssetClient::new(&env, &bid_token); - let token_client = TokenClient::new(&env, &bid_token); - - let initial_bidder_balance = 100_000_i128; - for bidder in bidders.iter() { - sac.mint(bidder, &initial_bidder_balance); - } - - let total_initial_balance = token_client.balance(&contract_id) - + bidders - .iter() - .map(|bidder| token_client.balance(bidder)) - .sum::(); - - let mut refunded_by_bidder = [0_i128; 4]; - let mut spent_by_bidder = [0_i128; 4]; - let mut expected: Option<(usize, i128)> = None; - let mut seed: u64 = 0x1234_5678_9abc_def0; - let auction_id = Symbol::new(&env, "refund_auc"); - - 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()); - let amount = - next_amount_above(&mut seed, expected.as_ref().map(|(_, a)| *a).unwrap_or(0)); - spent_by_bidder[bidder_idx] += amount; - client.place_bid(&auction_id, &bidders[bidder_idx], &amount); - - if let Some((prev_idx, prev_amount)) = expected { - refunded_by_bidder[prev_idx] += prev_amount; - - let events = refunded_events(&env); - let last = events.last().unwrap(); - assert_eq!(last.prev_bidder, bidders[prev_idx]); - assert_eq!(last.amount, prev_amount); - } - - let stored: crate::types::AuctionState = env - .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) - .unwrap(); - assert_eq!( - token_client.balance(&contract_id), - stored.highest_bid, - "contract escrow must equal only the current highest bid" - ); - for idx in 0..bidders.len() { - assert_eq!( - token_client.balance(&bidders[idx]), - initial_bidder_balance - spent_by_bidder[idx] + refunded_by_bidder[idx], - "bidder balance must reflect exact deposits and refunds" - ); - } - - let total_balance = token_client.balance(&contract_id) - + bidders - .iter() - .map(|bidder| token_client.balance(bidder)) - .sum::(); - assert_eq!(total_balance, total_initial_balance); - - expected = Some((bidder_idx, amount)); - } - } - - #[test] - fn close_semantics_cannot_be_bypassed() { - let env = Env::default(); - env.mock_all_auths(); - - let bidders: [Address; 3] = [ - Address::generate(&env), - Address::generate(&env), - Address::generate(&env), - ]; - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - let auction_id = Symbol::new(&env, "close_auc"); - - 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; - let mut highest = 0_i128; - for _ in 0..8 { - let idx = pick_index(&mut seed, 0..bidders.len()); - highest = next_amount_above(&mut seed, highest); - client.place_bid(&auction_id, &bidders[idx], &highest); - } - - let expected_state: crate::types::AuctionState = env - .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) - .unwrap(); - let refunds_before_close = refunded_events(&env).len(); - - client.close_auction(&auction_id); - - for _ in 0..16 { - let idx = pick_index(&mut seed, 0..bidders.len()); - let attempted_amount = next_amount_above(&mut seed, expected_state.highest_bid); - - let attempt = client.try_place_bid(&auction_id, &bidders[idx], &attempted_amount); - assert!(attempt.is_err(), "closed auction accepted a new bid"); - - let stored_state: crate::types::AuctionState = env - .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) - .unwrap(); - assert_eq!(stored_state.highest_bidder, expected_state.highest_bidder); - assert_eq!(stored_state.highest_bid, expected_state.highest_bid); - assert_eq!(stored_state.status, AuctionStatus::Closed); - assert_eq!(refunded_events(&env).len(), refunds_before_close); - } - } - - #[test] - fn settle_default_liquidation_requires_closed_auction() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - let bidder = Address::generate(&env); - let factory = Address::generate(&env); - let auction_id = Symbol::new(&env, "liq_open"); - - client.set_factory_contract(&factory); - client.init_auction(&auction_id, &AuctionMode::English, &0, &1000, &50_i128, &0_u32, &None, &None); - client.init_auction(&auction_id, &0, &1000, &50_i128, &0_u32); - client.place_bid(&auction_id, &bidder, &100_i128); - - let result = client.try_settle_default_liquidation( - &auction_id, - &Address::generate(&env), - &Address::generate(&env), - ); - assert!(result.is_err(), "open auction should not settle"); - } - - #[test] - fn settle_default_liquidation_emits_once_after_close() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - let bidder = Address::generate(&env); - let borrower = Address::generate(&env); - let credit_contract = Address::generate(&env); - let factory = Address::generate(&env); - let auction_id = Symbol::new(&env, "liq_closed"); - - client.set_factory_contract(&factory); - client.init_auction(&auction_id, &AuctionMode::English, &0, &1000, &50_i128, &0_u32, &None, &None); - client.init_auction(&auction_id, &0, &1000, &50_i128, &0_u32); - client.place_bid(&auction_id, &bidder, &420_i128); - client.close_auction(&auction_id); - client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); - - let events = settlement_events(&env); - assert_eq!(events.len(), 1); - let evt = events.last().unwrap(); - assert_eq!(evt.auction_id, auction_id); - assert_eq!(evt.credit_contract, credit_contract); - assert_eq!(evt.borrower, borrower); - assert_eq!(evt.winner, bidder); - assert_eq!(evt.recovered_amount, 420_i128); - } - - #[test] - #[should_panic] - fn settle_default_liquidation_replay_reverts() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - let factory = Address::generate(&env); - let borrower = Address::generate(&env); - let credit_contract = Address::generate(&env); - let auction_id = Symbol::new(&env, "liq_replay"); - - client.set_factory_contract(&factory); - 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 - client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); - let replay = - client.try_settle_default_liquidation(&auction_id, &credit_contract, &borrower); - assert!(replay.is_err(), "settlement replay should panic"); - } - - #[test] - fn zero_bid_auction_settles_with_borrower_as_winner() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - let borrower = Address::generate(&env); - let credit_contract = Address::generate(&env); - let factory = Address::generate(&env); - let auction_id = Symbol::new(&env, "zero_bid"); - - client.set_factory_contract(&factory); - client.init_auction(&auction_id, &AuctionMode::English, &0, &1000, &50_i128, &0_u32, &None, &None); - client.init_auction(&auction_id, &0, &1000, &50_i128, &0_u32); - // no bids - client.close_auction(&auction_id); - client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); - - let events = settlement_events(&env); - assert_eq!(events.len(), 1); - let evt = events.last().unwrap(); - assert_eq!(evt.winner, borrower); - assert_eq!(evt.recovered_amount, 0_i128); - } - - // --- factory auth negative tests --- - - #[test] - fn settle_default_liquidation_reverts_when_factory_unset() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - let auction_id = Symbol::new(&env, "no_factory"); - - // No set_factory_contract call — factory is unset - 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(|| { - client.settle_default_liquidation( - &auction_id, - &Address::generate(&env), - &Address::generate(&env), - ); - })); - - assert!(result.is_err(), "should revert when factory contract is unset"); - } - - #[test] - fn settle_default_liquidation_reverts_for_wrong_caller() { - let env = Env::default(); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - let factory = Address::generate(&env); - let borrower = Address::generate(&env); - let credit_contract = Address::generate(&env); - let auction_id = Symbol::new(&env, "wrong_caller"); - - // Setup: register factory and close an auction - env.mock_all_auths(); - client.set_factory_contract(&factory); - 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 - let result = catch_unwind(AssertUnwindSafe(|| { - // Create a fresh env without mock_all_auths so require_auth fails - let env2 = Env::default(); - let contract_id2 = env2.register(Auction, ()); - let client2 = AuctionClient::new(&env2, &contract_id2); - let factory2 = Address::generate(&env2); - let auction_id2 = Symbol::new(&env2, "wrong_caller2"); - // Setup with mocks - env2.mock_all_auths(); - client2.set_factory_contract(&factory2); - client2.init_auction(&auction_id2, &0, &1000, &50_i128); - client2.close_auction(&auction_id2); - // Call with only a non-factory address authorized - let wrong_caller = Address::generate(&env2); - client2 - .mock_auths(&[soroban_sdk::testutils::MockAuth { - address: &wrong_caller, - invoke: &soroban_sdk::testutils::MockAuthInvoke { - contract: &contract_id2, - fn_name: "settle_default_liquidation", - args: ( - auction_id2.clone(), - Address::generate(&env2), - Address::generate(&env2), - ) - .into_val(&env2), - sub_invokes: &[], - }, - }]) - .settle_default_liquidation( - &auction_id2, - &Address::generate(&env2), - &Address::generate(&env2), - ); - })); - - assert!(result.is_err(), "wrong caller should be rejected"); - } - - #[test] - fn bid_after_end_time_rejected() { - let env = Env::default(); - env.mock_all_auths(); - env.ledger().set_timestamp(1001); // past end time - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - let bidder = Address::generate(&env); - let auction_id = Symbol::new(&env, "timed_out"); - - client.init_auction(&auction_id, &0, &1000, &50_i128, &0_u32); - - let attempt = client.try_place_bid(&auction_id, &bidder, &100_i128); - assert!(attempt.is_err(), "bid after end time should be rejected"); - } - - #[test] - fn settle_default_liquidation_requires_factory_contract_set() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - let bidder = Address::generate(&env); - let borrower = Address::generate(&env); - let credit_contract = Address::generate(&env); - let auction_id = Symbol::new(&env, "no_factory"); - - 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); - - let result = catch_unwind(AssertUnwindSafe(|| { - client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); - })); - - assert!(result.is_err(), "should panic if factory not set"); - } - - #[test] - fn settle_default_liquidation_requires_authorized_factory() { - 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, &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. - let result = env.as_contract(&intruder, || { - catch_unwind(AssertUnwindSafe(|| { - client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); - })) - }); - - assert!(result.is_err(), "should panic if unauthorized caller"); - } - - #[test] - fn settle_default_liquidation_succeeds_with_factory() { - let env = Env::default(); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - let factory = 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, "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, &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); - }); - - 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] - fn init_auction_rejects_increment_bps_above_10000() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - 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); - })); - assert!(result.is_err(), "bps > 10000 should be rejected at init"); - } - - #[test] - fn init_auction_accepts_zero_and_max_increment_bps() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(Auction, ()); - 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); - // 10_000 bps (100% increment) is the maximum valid value - client.init_auction(&Symbol::new(&env, "bps10k"), &0, &1000, &1_i128, &10_000_u32); - } - - // ── min_increment_bps: bid threshold enforcement ─────────────────────── - - #[test] - fn bid_just_below_increment_threshold_rejected() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - let auction_id = Symbol::new(&env, "inc_low"); - - let alice = Address::generate(&env); - 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.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"); - - // state must be unchanged - let state: crate::types::AuctionState = env - .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) - .unwrap(); - assert_eq!(state.highest_bid, 1_000_i128); - assert_eq!(state.highest_bidder.unwrap(), alice); - } - - #[test] - fn bid_at_increment_threshold_accepted() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - let auction_id = Symbol::new(&env, "inc_ok"); - - let alice = Address::generate(&env); - 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.place_bid(&auction_id, &alice, &1_000_i128); - client.place_bid(&auction_id, &bob, &1_010_i128); // exactly at threshold - - let state: crate::types::AuctionState = env - .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) - .unwrap(); - assert_eq!(state.highest_bid, 1_010_i128); - assert_eq!(state.highest_bidder.unwrap(), bob); - } - - #[test] - fn bid_increment_ceiling_rounding_non_divisible() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - let auction_id = Symbol::new(&env, "inc_ceil"); - - let alice = Address::generate(&env); - let bob = Address::generate(&env); - 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.place_bid(&auction_id, &alice, &1_000_i128); - - let just_below = catch_unwind(AssertUnwindSafe(|| { - client.place_bid(&auction_id, &bob, &1_033_i128); // 1033 < 1034 - })); - assert!(just_below.is_err(), "bid below ceiling threshold must fail"); - - client.place_bid(&auction_id, &carol, &1_034_i128); // exactly at ceiling threshold - - let state: crate::types::AuctionState = env - .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) - .unwrap(); - assert_eq!(state.highest_bid, 1_034_i128); - assert_eq!(state.highest_bidder.unwrap(), carol); - } - - #[test] - fn bid_zero_increment_bps_requires_at_least_one_stroop_above() { - let env = Env::default(); - env.mock_all_auths(); - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - let auction_id = Symbol::new(&env, "inc_zero"); - - let alice = Address::generate(&env); - let bob = Address::generate(&env); - let carol = Address::generate(&env); - - // 0 bps: any strictly higher bid is accepted; equal bid must be rejected - 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(|| { - client.place_bid(&auction_id, &bob, &500_i128); - })); - assert!(equal.is_err(), "equal bid must be rejected even at 0 bps"); - - // exactly one stroop above is accepted - client.place_bid(&auction_id, &carol, &501_i128); - - let state: crate::types::AuctionState = env - .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(); - - 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 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); - - 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(); - - let winner = Address::generate(&env); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - 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); - - // 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"); -} - -#[test] -fn claim_before_close_fails_not_closed() { - let env = Env::default(); - env.mock_all_auths(); - - let winner = Address::generate(&env); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - 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 - - 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(); - - let borrower = Address::generate(&env); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - 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); - - 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"); - - // Initialize Dutch auction: start 1000, end 2000, start_price 500, floor_price 100 - client.init_auction( - &auction_id, - &AuctionMode::Dutch, - &1000, - &2000, - &50_i128, - &0_u32, - &Some(500_i128), - &Some(100_i128), - ); - - // At start (time 1000), price should be 500 - env.ledger().with_mut(|li| li.timestamp = 1000); - - // Bid at start price should succeed - 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, "Dutch auction should close on first bid"); - 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"); - - // Initialize Dutch auction: start 1000, end 2000 (duration 1000), start_price 500, floor_price 100 - client.init_auction( - &auction_id, - &AuctionMode::Dutch, - &1000, - &2000, - &50_i128, - &0_u32, - &Some(500_i128), - &Some(100_i128), - ); - - // At midpoint (time 1500), price should be 300 (500 - (500-100)*(500/1000) = 500 - 200 = 300) - env.ledger().with_mut(|li| li.timestamp = 1500); - - // Bid at mid price should succeed - 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, "Dutch auction should close on first bid"); - 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"); - - // Initialize Dutch auction: start 1000, end 2000, start_price 500, floor_price 100 - client.init_auction( - &auction_id, - &AuctionMode::Dutch, - &1000, - &2000, - &50_i128, - &0_u32, - &Some(500_i128), - &Some(100_i128), - ); - - // At end (time 2000), price should be floor price 100 - env.ledger().with_mut(|li| li.timestamp = 2000); - - // Bid at floor price should succeed - 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, "Dutch auction should close on first bid"); - 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"); - - // Initialize Dutch auction: start 1000, end 2000, start_price 500, floor_price 100 - client.init_auction( - &auction_id, - &AuctionMode::Dutch, - &1000, - &2000, - &50_i128, - &0_u32, - &Some(500_i128), - &Some(100_i128), - ); - - // At midpoint (time 1500), current price should be 300 - env.ledger().with_mut(|li| li.timestamp = 1500); - - // Bid below current price should fail - let result = client.try_place_bid(&auction_id, &alice, &250_i128); - assert!(result.is_err(), "bid below current price should fail"); -} - -#[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"); - - // Initialize Dutch auction - 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); - - // First bid should settle the auction - 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, "Auction should be closed"); - - // Second bid should fail because auction is closed - let result = client.try_place_bid(&auction_id, &bob, &400_i128); - assert!(result.is_err(), "second bid should fail on closed auction"); -} - -#[test] -fn dutch_auction_price_clamps_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_clamp"); - - // Initialize Dutch auction: start 1000, end 2000, start_price 500, floor_price 100 - client.init_auction( - &auction_id, - &AuctionMode::Dutch, - &1000, - &2000, - &50_i128, - &0_u32, - &Some(500_i128), - &Some(100_i128), - ); - - // Past end time (time 3000), price should still be floor price 100 - env.ledger().with_mut(|li| li.timestamp = 3000); - - // Bid at floor price should succeed even past end time - 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.highest_bid, 100_i128, "price should clamp at floor"); -} - -#[test] -fn dutch_auction_requires_start_and_floor_price() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - let auction_id = Symbol::new(&env, "dutch_missing_params"); - - // Dutch auction without start_price should fail - let result = client.try_init_auction( - &auction_id, - &AuctionMode::Dutch, - &1000, - &2000, - &50_i128, - &0_u32, - &None, - &Some(100_i128), - ); - assert!(result.is_err(), "Dutch auction requires start_price"); - - // Dutch auction without floor_price should fail - let result = client.try_init_auction( - &auction_id, - &AuctionMode::Dutch, - &1000, - &2000, - &50_i128, - &0_u32, - &Some(500_i128), - &None, - ); - assert!(result.is_err(), "Dutch auction requires floor_price"); -} - -#[test] -fn dutch_auction_validates_start_greater_than_floor() { - let env = Env::default(); - env.mock_all_auths(); - - let contract_id = env.register(Auction, ()); - let client = AuctionClient::new(&env, &contract_id); - - let auction_id = Symbol::new(&env, "dutch_invalid_range"); - - // Dutch auction with start_price < floor_price should fail - let result = client.try_init_auction( - &auction_id, - &AuctionMode::Dutch, - &1000, - &2000, - &50_i128, - &0_u32, - &Some(100_i128), - &Some(500_i128), - ); - assert!(result.is_err(), "start_price must be >= floor_price"); -} - -#[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"); - - // Initialize English auction with new signature - client.init_auction( - &auction_id, - &AuctionMode::English, - &0, - &1000, - &50_i128, - &0_u32, - &None, - &None, - ); - - // English auction behavior should be unchanged - 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, "English auction should remain open"); - assert_eq!(stored.highest_bidder.unwrap(), bob); - assert_eq!(stored.highest_bid, 200_i128); -} - -#[test] -fn dutch_auction_respects_min_bid() { - 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_min_bid"); - - // Initialize Dutch auction with min_bid 150, start_price 500, floor_price 100 - client.init_auction( - &auction_id, - &AuctionMode::Dutch, - &1000, - &2000, - &150_i128, - &0_u32, - &Some(500_i128), - &Some(100_i128), - ); - - // At floor (time 2000), Dutch price is 100, but min_bid is 150 - env.ledger().with_mut(|li| li.timestamp = 2000); - - // Bid below min_bid should fail even if above Dutch price - let result = client.try_place_bid(&auction_id, &alice, &120_i128); - assert!(result.is_err(), "bid must meet min_bid requirement"); - - // Bid at min_bid should succeed - client.place_bid(&auction_id, &alice, &150_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_bid, 150_i128); -} +#[cfg(test)] +mod tests { + extern crate std; + use super::super::*; + use crate::errors::AuctionError; + use core::convert::TryFrom; + use core::ops::Range; + 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::token::{Client as TokenClient, StellarAssetClient}; + use soroban_sdk::{Address, Env, IntoVal, Symbol, TryFromVal, TryIntoVal}; + + const REFUND_TOPIC: &str = "BID_RFDN"; + const SETTLEMENT_TOPIC: &str = "LIQ_SETL"; + const AUCTION_ID: &str = "inv_auc"; + const FUZZ_STEPS: usize = 64; + const MAX_INCREMENT: u64 = 500; + + fn advance_ledgers(env: &Env, ledgers: u32) { + env.ledger().with_mut(|li| { + li.sequence_number += ledgers; + li.timestamp += (ledgers as u64) * 5; + }); + } + + fn next_u64(state: &mut u64) -> u64 { + let mut x = *state; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + *state = x; + x + } + + fn pick_index(seed: &mut u64, range: Range) -> usize { + let len = range.end - range.start; + range.start + (next_u64(seed) as usize % len) + } + + fn next_amount_above(seed: &mut u64, current: i128) -> i128 { + current + i128::from((next_u64(seed) % MAX_INCREMENT) + 1) + } + + fn refunded_events(env: &Env) -> Vec { + let mut output = Vec::new(); + for (_contract, topics, data) in env.events().all().iter() { + let t0: Symbol = Symbol::try_from_val(env, &topics.get(0).unwrap()).unwrap(); + if t0 == Symbol::new(env, REFUND_TOPIC) { + let event_data: events::BidRefundedEvent = data.try_into_val(env).unwrap(); + output.push(event_data); + } + } + output + } + + fn settlement_events(env: &Env) -> Vec { + let mut output = Vec::new(); + for (_contract, topics, data) in env.events().all().iter() { + let t0: Symbol = Symbol::try_from_val(env, &topics.get(0).unwrap()).unwrap(); + if t0 == Symbol::new(env, SETTLEMENT_TOPIC) { + let event_data: events::DefaultLiquidationSettlementEvent = + data.try_into_val(env).unwrap(); + output.push(event_data); + } + } + output + } + + #[test] + fn bid_refunded_event_emitted_on_outbid() { + 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, "auc1"); + 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); + + let refund_events = refunded_events(&env); + assert_eq!(refund_events.len(), 1); + let event_data = refund_events.last().unwrap(); + assert_eq!(event_data.prev_bidder, alice); + assert_eq!(event_data.amount, 100_i128); + } + + #[test] + fn equal_to_highest_bid_rejected_as_bid_too_low() { + 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, "eq_highest"); + client.init_auction(&auction_id, &AuctionMode::English, &0, &1000, &50_i128, &0_u32, &None, &None); + + client.place_bid(&auction_id, &alice, &100_i128); + + let result = client.try_place_bid(&auction_id, &bob, &100_i128); + assert!(result.is_err(), "equal-to-highest bid must fail"); + let contract_err = result.unwrap_err().unwrap(); + assert_eq!( + contract_err, + AuctionError::BidTooLow.into(), + "equal-to-highest bid must return BidTooLow" + ); + + let stored_after: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + assert_eq!(stored_after.highest_bidder.unwrap(), alice); + assert_eq!(stored_after.highest_bid, 100_i128); + assert_eq!(refunded_events(&env).len(), 0); + } + + #[test] + fn fuzz_bid_sequence_invariants_deterministic() { + let env = Env::default(); + env.mock_all_auths(); + + let bidders: [Address; 5] = [ + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + ]; + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let auction_id = Symbol::new(&env, AUCTION_ID); + + 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; + + for _ in 0..FUZZ_STEPS { + let bidder_idx = pick_index(&mut seed, 0..bidders.len()); + let bidder = bidders[bidder_idx].clone(); + let amount = + next_amount_above(&mut seed, expected.as_ref().map(|(_, a)| *a).unwrap_or(0)); + + client.place_bid(&auction_id, &bidder, &amount); + + // In soroban-sdk v22, env.events() returns events from the most recent successful + // transaction only (not cumulative). Check that this bid emitted exactly one + // BID_RFDN event with the correct previous bidder and amount. + if let Some((prev_addr, prev_amount)) = expected.clone() { + let events = refunded_events(&env); + let evt = events.last().unwrap(); + assert_eq!(evt.prev_bidder, prev_addr); + assert_eq!(evt.amount, prev_amount); + } + + expected = Some((bidder.clone(), amount)); + + let stored: Option = + env.as_contract(&contract_id, || env.storage().persistent().get(&auction_id)); + assert!(stored.is_some(), "stored state must exist"); + let s = stored.unwrap(); + assert_eq!(s.highest_bidder.unwrap(), bidder); + assert_eq!(s.highest_bid, amount); + } + } + + #[test] + fn fuzz_refund_balance_invariant_deterministic() { + let env = Env::default(); + env.mock_all_auths(); + + let bidders: [Address; 4] = [ + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + ]; + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let token_admin = Address::generate(&env); + let token_id = env.register_stellar_asset_contract_v2(token_admin); + let bid_token = token_id.address(); + + env.as_contract(&contract_id, || { + env.storage() + .instance() + .set(&Symbol::new(&env, "bid_token"), &bid_token); + }); + + let sac = StellarAssetClient::new(&env, &bid_token); + let token_client = TokenClient::new(&env, &bid_token); + + let initial_bidder_balance = 100_000_i128; + for bidder in bidders.iter() { + sac.mint(bidder, &initial_bidder_balance); + } + + let total_initial_balance = token_client.balance(&contract_id) + + bidders + .iter() + .map(|bidder| token_client.balance(bidder)) + .sum::(); + + let mut refunded_by_bidder = [0_i128; 4]; + let mut spent_by_bidder = [0_i128; 4]; + let mut expected: Option<(usize, i128)> = None; + let mut seed: u64 = 0x1234_5678_9abc_def0; + let auction_id = Symbol::new(&env, "refund_auc"); + + 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()); + let amount = + next_amount_above(&mut seed, expected.as_ref().map(|(_, a)| *a).unwrap_or(0)); + spent_by_bidder[bidder_idx] += amount; + client.place_bid(&auction_id, &bidders[bidder_idx], &amount); + + if let Some((prev_idx, prev_amount)) = expected { + refunded_by_bidder[prev_idx] += prev_amount; + + let events = refunded_events(&env); + let last = events.last().unwrap(); + assert_eq!(last.prev_bidder, bidders[prev_idx]); + assert_eq!(last.amount, prev_amount); + } + + let stored: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + assert_eq!( + token_client.balance(&contract_id), + stored.highest_bid, + "contract escrow must equal only the current highest bid" + ); + for idx in 0..bidders.len() { + assert_eq!( + token_client.balance(&bidders[idx]), + initial_bidder_balance - spent_by_bidder[idx] + refunded_by_bidder[idx], + "bidder balance must reflect exact deposits and refunds" + ); + } + + let total_balance = token_client.balance(&contract_id) + + bidders + .iter() + .map(|bidder| token_client.balance(bidder)) + .sum::(); + assert_eq!(total_balance, total_initial_balance); + + expected = Some((bidder_idx, amount)); + } + } + + #[test] + fn close_semantics_cannot_be_bypassed() { + let env = Env::default(); + env.mock_all_auths(); + + let bidders: [Address; 3] = [ + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + ]; + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let auction_id = Symbol::new(&env, "close_auc"); + + 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; + let mut highest = 0_i128; + for _ in 0..8 { + let idx = pick_index(&mut seed, 0..bidders.len()); + highest = next_amount_above(&mut seed, highest); + client.place_bid(&auction_id, &bidders[idx], &highest); + } + + let expected_state: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + let refunds_before_close = refunded_events(&env).len(); + + client.close_auction(&auction_id); + + for _ in 0..16 { + let idx = pick_index(&mut seed, 0..bidders.len()); + let attempted_amount = next_amount_above(&mut seed, expected_state.highest_bid); + + let attempt = client.try_place_bid(&auction_id, &bidders[idx], &attempted_amount); + assert!(attempt.is_err(), "closed auction accepted a new bid"); + + let stored_state: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + assert_eq!(stored_state.highest_bidder, expected_state.highest_bidder); + assert_eq!(stored_state.highest_bid, expected_state.highest_bid); + assert_eq!(stored_state.status, AuctionStatus::Closed); + assert_eq!(refunded_events(&env).len(), refunds_before_close); + } + } + + #[test] + fn settle_default_liquidation_requires_closed_auction() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let bidder = Address::generate(&env); + let factory = Address::generate(&env); + let auction_id = Symbol::new(&env, "liq_open"); + + client.set_factory_contract(&factory); + 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( + &auction_id, + &Address::generate(&env), + &Address::generate(&env), + ); + assert!(result.is_err(), "open auction should not settle"); + } + + #[test] + fn settle_default_liquidation_emits_once_after_close() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let bidder = Address::generate(&env); + let borrower = Address::generate(&env); + let credit_contract = Address::generate(&env); + let factory = Address::generate(&env); + let auction_id = Symbol::new(&env, "liq_closed"); + + client.set_factory_contract(&factory); + 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); + + let events = settlement_events(&env); + assert_eq!(events.len(), 1); + let evt = events.last().unwrap(); + assert_eq!(evt.auction_id, auction_id); + assert_eq!(evt.credit_contract, credit_contract); + assert_eq!(evt.borrower, borrower); + assert_eq!(evt.winner, bidder); + assert_eq!(evt.recovered_amount, 420_i128); + } + + #[test] + #[should_panic] + fn settle_default_liquidation_replay_reverts() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let factory = Address::generate(&env); + let borrower = Address::generate(&env); + let credit_contract = Address::generate(&env); + let auction_id = Symbol::new(&env, "liq_replay"); + + client.set_factory_contract(&factory); + 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 + client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); + let replay = + client.try_settle_default_liquidation(&auction_id, &credit_contract, &borrower); + assert!(replay.is_err(), "settlement replay should panic"); + } + + #[test] + fn zero_bid_auction_settles_with_borrower_as_winner() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let borrower = Address::generate(&env); + let credit_contract = Address::generate(&env); + let factory = Address::generate(&env); + let auction_id = Symbol::new(&env, "zero_bid"); + + client.set_factory_contract(&factory); + 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); + + let events = settlement_events(&env); + assert_eq!(events.len(), 1); + let evt = events.last().unwrap(); + assert_eq!(evt.winner, borrower); + assert_eq!(evt.recovered_amount, 0_i128); + } + + // --- factory auth negative tests --- + + #[test] + fn settle_default_liquidation_reverts_when_factory_unset() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let auction_id = Symbol::new(&env, "no_factory"); + + // No set_factory_contract call — factory is unset + 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(|| { + client.settle_default_liquidation( + &auction_id, + &Address::generate(&env), + &Address::generate(&env), + ); + })); + + assert!(result.is_err(), "should revert when factory contract is unset"); + } + + #[test] + fn settle_default_liquidation_reverts_for_wrong_caller() { + let env = Env::default(); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let factory = Address::generate(&env); + let borrower = Address::generate(&env); + let credit_contract = Address::generate(&env); + let auction_id = Symbol::new(&env, "wrong_caller"); + + // Setup: register factory and close an auction + env.mock_all_auths(); + client.set_factory_contract(&factory); + 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 + let result = catch_unwind(AssertUnwindSafe(|| { + // Create a fresh env without mock_all_auths so require_auth fails + let env2 = Env::default(); + let contract_id2 = env2.register(Auction, ()); + let client2 = AuctionClient::new(&env2, &contract_id2); + let factory2 = Address::generate(&env2); + let auction_id2 = Symbol::new(&env2, "wrong_caller2"); + // Setup with mocks + env2.mock_all_auths(); + client2.set_factory_contract(&factory2); + 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); + client2 + .mock_auths(&[soroban_sdk::testutils::MockAuth { + address: &wrong_caller, + invoke: &soroban_sdk::testutils::MockAuthInvoke { + contract: &contract_id2, + fn_name: "settle_default_liquidation", + args: ( + auction_id2.clone(), + Address::generate(&env2), + Address::generate(&env2), + ) + .into_val(&env2), + sub_invokes: &[], + }, + }]) + .settle_default_liquidation( + &auction_id2, + &Address::generate(&env2), + &Address::generate(&env2), + ); + })); + + assert!(result.is_err(), "wrong caller should be rejected"); + } + + #[test] + fn bid_after_end_time_rejected() { + let env = Env::default(); + env.mock_all_auths(); + env.ledger().set_timestamp(1001); // past end time + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let bidder = Address::generate(&env); + let auction_id = Symbol::new(&env, "timed_out"); + + 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"); + } + + #[test] + fn settle_default_liquidation_requires_factory_contract_set() { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let bidder = Address::generate(&env); + let borrower = Address::generate(&env); + let credit_contract = Address::generate(&env); + let auction_id = Symbol::new(&env, "no_factory"); + + 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); + + let result = catch_unwind(AssertUnwindSafe(|| { + client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); + })); + + assert!(result.is_err(), "should panic if factory not set"); + } + + #[test] + fn settle_default_liquidation_requires_authorized_factory() { + 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, &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. + let result = env.as_contract(&intruder, || { + catch_unwind(AssertUnwindSafe(|| { + client.settle_default_liquidation(&auction_id, &credit_contract, &borrower); + })) + }); + + assert!(result.is_err(), "should panic if unauthorized caller"); + } + + #[test] + fn settle_default_liquidation_succeeds_with_factory() { + let env = Env::default(); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let factory = 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, "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, &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); + }); + + let events = settlement_events(&env); + assert_eq!(events.len(), 1); + } + client.init_auction(&auction_id, &AuctionMode::English, &0, &1000, &50_i128, &0_u32, &None, &None); + 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] + fn init_auction_rejects_increment_bps_above_10000() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let auction_id = Symbol::new(&env, "bad_bps"); + + let result = catch_unwind(AssertUnwindSafe(|| { + 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"); + } + + #[test] + fn init_auction_accepts_zero_and_max_increment_bps() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + // 0 bps (no percentage requirement) is valid + 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"), &AuctionMode::English, &0, &1000, &1_i128, &10_000_u32, &None, &None); + } + + // ── min_increment_bps: bid threshold enforcement ─────────────────────── + + #[test] + fn bid_just_below_increment_threshold_rejected() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let auction_id = Symbol::new(&env, "inc_low"); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + // 100 bps = 1%; threshold after 1000 = 1000 + ceil(1000*100/10000) = 1010 + 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"); + + // state must be unchanged + let state: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + assert_eq!(state.highest_bid, 1_000_i128); + assert_eq!(state.highest_bidder.unwrap(), alice); + } + + #[test] + fn bid_at_increment_threshold_accepted() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let auction_id = Symbol::new(&env, "inc_ok"); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + + // 100 bps = 1%; threshold after 1000 = 1010 + 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 + + let state: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + assert_eq!(state.highest_bid, 1_010_i128); + assert_eq!(state.highest_bidder.unwrap(), bob); + } + + #[test] + fn bid_increment_ceiling_rounding_non_divisible() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let auction_id = Symbol::new(&env, "inc_ceil"); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + 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, &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(|| { + client.place_bid(&auction_id, &bob, &1_033_i128); // 1033 < 1034 + })); + assert!(just_below.is_err(), "bid below ceiling threshold must fail"); + + client.place_bid(&auction_id, &carol, &1_034_i128); // exactly at ceiling threshold + + let state: crate::types::AuctionState = env + .as_contract(&contract_id, || env.storage().persistent().get(&auction_id)) + .unwrap(); + assert_eq!(state.highest_bid, 1_034_i128); + assert_eq!(state.highest_bidder.unwrap(), carol); + } + + #[test] + fn bid_zero_increment_bps_requires_at_least_one_stroop_above() { + let env = Env::default(); + env.mock_all_auths(); + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + let auction_id = Symbol::new(&env, "inc_zero"); + + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let carol = Address::generate(&env); + + // 0 bps: any strictly higher bid is accepted; equal bid must be rejected + 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(|| { + client.place_bid(&auction_id, &bob, &500_i128); + })); + assert!(equal.is_err(), "equal bid must be rejected even at 0 bps"); + + // exactly one stroop above is accepted + client.place_bid(&auction_id, &carol, &501_i128); + + let state: crate::types::AuctionState = env + .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(); + + 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 auction_id = Symbol::new(&env, "claim_non_winner"); + + 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"); +} + +#[test] +fn claim_double_claim_fails_already_claimed() { + let env = Env::default(); + env.mock_all_auths(); + + let winner = Address::generate(&env); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let auction_id = Symbol::new(&env, "claim_double"); + + 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"); + + // 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(); + + let winner = Address::generate(&env); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let auction_id = Symbol::new(&env, "claim_not_closed"); + + 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"); +} + +#[test] +fn claim_zero_bid_auction_fails_not_winner() { + let env = Env::default(); + env.mock_all_auths(); + + let borrower = Address::generate(&env); + + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); + + let auction_id = Symbol::new(&env, "zero_bid_claim"); + + 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"); +} + +// === 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); +} +} From 24f08c66c585d5af2021ac26f36914c4e6f2a7d1 Mon Sep 17 00:00:00 2001 From: olaleyeolajide81-sketch Date: Fri, 29 May 2026 18:38:26 +0100 Subject: [PATCH 3/4] fix: resolve test.rs syntax errors and formatting issues --- .../contracts/auction_contract/src/lib.rs | 58 +- .../contracts/auction_contract/src/storage.rs | 8 +- .../contracts/auction_contract/src/test.rs | 914 +++++++++++------- 3 files changed, 626 insertions(+), 354 deletions(-) diff --git a/gateway-contract/contracts/auction_contract/src/lib.rs b/gateway-contract/contracts/auction_contract/src/lib.rs index 86477d8..a3746c7 100644 --- a/gateway-contract/contracts/auction_contract/src/lib.rs +++ b/gateway-contract/contracts/auction_contract/src/lib.rs @@ -55,21 +55,21 @@ fn compute_dutch_price( 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 @@ -77,12 +77,12 @@ fn compute_dutch_price( .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) } @@ -117,7 +117,7 @@ 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"); @@ -129,7 +129,7 @@ impl Auction { panic!("dutch_start_price must be >= min_bid"); } } - + let config = AuctionConfig { mode, username_hash: BytesN::from_array(&env, &[0; 32]), @@ -226,7 +226,7 @@ impl Auction { 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); @@ -252,29 +252,41 @@ impl Auction { .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); - + + 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); + publish_auction_closed_event( + &env, + auction_id.clone(), + state.highest_bidder.clone(), + state.highest_bid, + ); } } @@ -294,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); } @@ -353,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 abf4681..52285e5 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, &AuctionMode::English, &0, &1000, &50_i128, &0_u32, &None, &None); // 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, &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, &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, &AuctionMode::English, &0, &u64::MAX, &1_i128, &0_u32, &None, &None); // 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, &AuctionMode::English, &0, &u64::MAX, &1_i128, &0_u32, &None, &None); + 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, &AuctionMode::English, &0, &u64::MAX, &1_i128, &0_u32, &None, &None); + 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, &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.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, &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.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, &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.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, &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.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, &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.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, &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.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, &AuctionMode::English, &0, &1000, &50_i128, &0_u32, &None, &None); + 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, &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, + ); 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, &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); @@ -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, &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); - + // 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, &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); - + // 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, &AuctionMode::English, &0, &1000, &50_i128, &0_u32, &None, &None); - 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, &AuctionMode::English, &0, &1000, &50_i128, &10_001_u32, &None, &None); + 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"), &AuctionMode::English, &0, &1000, &1_i128, &0_u32, &None, &None); + 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"), &AuctionMode::English, &0, &1000, &1_i128, &10_000_u32, &None, &None); + 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, &AuctionMode::English, &0, &u64::MAX, &1_i128, &100_u32, &None, &None); + 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, &AuctionMode::English, &0, &u64::MAX, &1_i128, &100_u32, &None, &None); + 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, &AuctionMode::English, &0, &u64::MAX, &1_i128, &333_u32, &None, &None); + 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, &AuctionMode::English, &0, &u64::MAX, &1_i128, &0_u32, &None, &None); + 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,311 +995,346 @@ 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, &AuctionMode::English, &0, &u64::MAX, &1_i128, &0_u32, &None, &None); - 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, &AuctionMode::English, &0, &u64::MAX, &1_i128, &0_u32, &None, &None); - 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, &AuctionMode::English, &0, &u64::MAX, &1_i128, &0_u32, &None, &None); - 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, &AuctionMode::English, &0, &u64::MAX, &1_i128, &0_u32, &None, &None); - // 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); -} + // === Dutch Auction Tests === -#[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_start() { + let env = Env::default(); + env.mock_all_auths(); -#[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); -} + let alice = Address::generate(&env); -#[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()); -} + let contract_id = env.register(Auction, ()); + let client = AuctionClient::new(&env, &contract_id); -#[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()); -} + let auction_id = Symbol::new(&env, "dutch_start"); -#[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); -} + 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); + } } From 39b9262051bc82dea19d6d196aeaec3217b9163d Mon Sep 17 00:00:00 2001 From: olaleyeolajide81-sketch Date: Fri, 29 May 2026 18:45:47 +0100 Subject: [PATCH 4/4] feat: add comprehensive Dutch auction tests --- .../contracts/auction_contract/src/test.rs | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/gateway-contract/contracts/auction_contract/src/test.rs b/gateway-contract/contracts/auction_contract/src/test.rs index 52285e5..69ec8fe 100644 --- a/gateway-contract/contracts/auction_contract/src/test.rs +++ b/gateway-contract/contracts/auction_contract/src/test.rs @@ -1337,4 +1337,140 @@ mod tests { 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); + } }