From 1c73d1b101c46887518a5ece683e24186fef8ae5 Mon Sep 17 00:00:00 2001 From: beebozy Date: Tue, 24 Mar 2026 04:10:59 -0700 Subject: [PATCH 1/2] feat: add storage helper --- .../contracts/auction_contract/src/lib.rs | 105 +++++++- .../contracts/auction_contract/src/storage.rs | 247 ++++++++++++++++++ .../contracts/auction_contract/src/types.rs | 48 ++++ 3 files changed, 398 insertions(+), 2 deletions(-) diff --git a/gateway-contract/contracts/auction_contract/src/lib.rs b/gateway-contract/contracts/auction_contract/src/lib.rs index 5c6abc8..d0d4e00 100644 --- a/gateway-contract/contracts/auction_contract/src/lib.rs +++ b/gateway-contract/contracts/auction_contract/src/lib.rs @@ -1,8 +1,109 @@ #![no_std] -use soroban_sdk::{contract, contractimpl}; + +mod storage; +mod types; + +pub use storage::{ + add_bidder, get_all_bidders, get_auction, get_bid, has_auction, set_auction, set_bid, DataKey, +}; +pub use types::{AuctionState, Bid}; + +use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; #[contract] pub struct AuctionContract; #[contractimpl] -impl AuctionContract {} +impl AuctionContract { + // ----------------------------------------------------------------------- + // Auction management + // ----------------------------------------------------------------------- + + /// Create a new auction identified by `hash`. + /// + /// The `hash` must not already be in use — callers should verify with + /// `has_auction` before calling this. The creator must authorise the + /// call. + /// + /// # Errors + /// Panics with `"auction already exists"` when `hash` is already in use. + pub fn create_auction( + env: Env, + creator: Address, + hash: BytesN<32>, + start_time: u64, + end_time: u64, + reserve_price: i128, + ) { + creator.require_auth(); + assert!(!has_auction(&env, &hash), "auction already exists"); + + let state = AuctionState { + creator, + start_time, + end_time, + reserve_price, + highest_bid: 0, + highest_bidder: None, + is_settled: false, + }; + set_auction(&env, &hash, &state); + } + + /// View: returns the full [`AuctionState`] for `hash`. + /// + /// # Errors + /// Panics with `"auction not found"` when `hash` has no associated state. + pub fn get_auction(env: Env, hash: BytesN<32>) -> AuctionState { + get_auction(&env, &hash).expect("auction not found") + } + + /// View: returns `true` if an auction exists for `hash`. + pub fn has_auction(env: Env, hash: BytesN<32>) -> bool { + has_auction(&env, &hash) + } + + // ----------------------------------------------------------------------- + // Bidding + // ----------------------------------------------------------------------- + + /// Place or update a bid from `bidder` on auction `hash`. + /// + /// * The auction must exist. + /// * `amount` must exceed the current `highest_bid`. + /// * `bidder` must authorise the call. + /// + /// # Errors + /// Panics on constraint violations (auction missing, bid too low). + pub fn place_bid(env: Env, hash: BytesN<32>, bidder: Address, amount: i128) { + bidder.require_auth(); + + let mut state = get_auction(&env, &hash).expect("auction not found"); + assert!(amount > state.highest_bid, "bid must exceed highest bid"); + + let bid = Bid { + bidder: bidder.clone(), + amount, + timestamp: env.ledger().timestamp(), + }; + + // Update the bidder list before writing the bid record so that the + // AllBidders key is always at least as fresh as any Bid key. + add_bidder(&env, &hash, bidder.clone()); + set_bid(&env, &hash, &bidder, &bid); + + state.highest_bid = amount; + state.highest_bidder = Some(bidder); + set_auction(&env, &hash, &state); + } + + /// View: returns the [`Bid`] placed by `bidder` on `hash`, if any. + pub fn get_bid(env: Env, hash: BytesN<32>, bidder: Address) -> Option { + get_bid(&env, &hash, &bidder) + } + + /// View: returns all addresses that have bid on `hash`. + pub fn get_all_bidders(env: Env, hash: BytesN<32>) -> Vec
{ + get_all_bidders(&env, &hash) + } +} diff --git a/gateway-contract/contracts/auction_contract/src/storage.rs b/gateway-contract/contracts/auction_contract/src/storage.rs index e69de29..ac580ee 100644 --- a/gateway-contract/contracts/auction_contract/src/storage.rs +++ b/gateway-contract/contracts/auction_contract/src/storage.rs @@ -0,0 +1,247 @@ +//! Persistent storage helpers for the auction contract. +//! +//! This module owns the entire on-chain data layout for auctions and bids. +//! All reads and writes go through the functions below; nothing else in the +//! crate touches `env.storage()` directly. +//! +//! # Storage tiers +//! +//! Every key uses **persistent** storage so that auction and bid records +//! survive ledger archival. Persistent entries must have their TTL extended +//! by the contract whenever they are read or written — see the individual +//! functions for their `extend_ttl` calls. +//! +//! # Key layout +//! +//! ```text +//! DataKey::Auction(hash) → AuctionState +//! DataKey::Bid(hash, bidder) → Bid +//! DataKey::AllBidders(hash) → Vec
+//! ``` +//! +//! The `hash` in every key is the SHA-256 commitment to the auctioned asset's +//! metadata, produced off-chain and submitted when the auction is created. +//! Using a content-addressed key means the same asset cannot be auctioned +//! twice under different IDs without the creator supplying a different hash. + +use soroban_sdk::{Address, BytesN, Env, Vec}; + +use crate::types::{AuctionState, Bid}; + +// --------------------------------------------------------------------------- +// TTL policy +// --------------------------------------------------------------------------- + +/// Minimum number of ledgers an entry must remain live after it is touched. +/// +/// Soroban charges rent proportional to the entry size × ledger duration. +/// Setting a generous threshold avoids frequent re-bumps while keeping rent +/// predictable. Approximately 30 days at 5-second ledger close times. +const LEDGER_THRESHOLD: u32 = 518_400; // ~30 days + +/// Target TTL to extend to on every touch (≈ 60 days). +const LEDGER_BUMP: u32 = 1_036_800; + +// --------------------------------------------------------------------------- +// DataKey — the canonical key enum for this contract +// --------------------------------------------------------------------------- + +/// All storage keys used by the auction contract. +/// +/// Each variant maps one-to-one to a logical record type: +/// +/// * `Auction(hash)` — the full [`AuctionState`] for one auction. +/// * `Bid(hash, bidder)` — the most-recent [`Bid`] from one address on one +/// auction. A bidder can hold only one live bid per auction; placing a +/// second bid overwrites the first. +/// * `AllBidders(hash)` — the ordered list of distinct [`Address`] values +/// that have ever placed a bid on `hash`. Used for iteration and refunds. +/// +/// The `#[contracttype]` macro encodes each variant as a compact XDR value, +/// which becomes the raw storage key on-chain. +#[soroban_sdk::contracttype] +#[derive(Clone, Debug)] +pub enum DataKey { + /// Full auction state keyed by the asset commitment hash. + Auction(BytesN<32>), + /// One bidder's current bid on one auction. + Bid(BytesN<32>, Address), + /// Ordered list of all addresses that have bid on one auction. + AllBidders(BytesN<32>), +} + +// --------------------------------------------------------------------------- +// Auction helpers +// --------------------------------------------------------------------------- + +/// Retrieve the [`AuctionState`] for `hash`, or `None` if it does not exist. +/// +/// Extends the TTL on a hit so that active auctions are never archived while +/// they are being used. +/// +/// # Arguments +/// * `env` — the contract environment. +/// * `hash` — 32-byte commitment hash identifying the auction. +/// +/// # Returns +/// `Some(AuctionState)` when found, `None` when the auction has never been +/// created or has been deleted. +pub fn get_auction(env: &Env, hash: &BytesN<32>) -> Option { + let key = DataKey::Auction(hash.clone()); + let result: Option = env.storage().persistent().get(&key); + if result.is_some() { + env.storage() + .persistent() + .extend_ttl(&key, LEDGER_THRESHOLD, LEDGER_BUMP); + } + result +} + +/// Persist `state` as the auction record for `hash`. +/// +/// Overwrites any existing record for the same hash. Callers are responsible +/// for ensuring the hash is not already in use for a different auction unless +/// an overwrite is intentional (e.g. status updates). +/// +/// # Arguments +/// * `env` — the contract environment. +/// * `hash` — 32-byte commitment hash identifying the auction. +/// * `state` — the auction state to store. +pub fn set_auction(env: &Env, hash: &BytesN<32>, state: &AuctionState) { + let key = DataKey::Auction(hash.clone()); + env.storage().persistent().set(&key, state); + env.storage() + .persistent() + .extend_ttl(&key, LEDGER_THRESHOLD, LEDGER_BUMP); +} + +/// Returns `true` if an [`AuctionState`] record exists for `hash`. +/// +/// Does **not** extend the TTL — a pure existence check does not constitute +/// active use. If the caller intends to read the record immediately after +/// this check, prefer `get_auction` which does both in one round-trip. +/// +/// # Arguments +/// * `env` — the contract environment. +/// * `hash` — 32-byte commitment hash identifying the auction. +pub fn has_auction(env: &Env, hash: &BytesN<32>) -> bool { + env.storage() + .persistent() + .has(&DataKey::Auction(hash.clone())) +} + +// --------------------------------------------------------------------------- +// Bid helpers +// --------------------------------------------------------------------------- + +/// Retrieve the current [`Bid`] placed by `bidder` on auction `hash`, or +/// `None` if that bidder has not bid on this auction. +/// +/// Extends the TTL on a hit. +/// +/// # Arguments +/// * `env` — the contract environment. +/// * `hash` — 32-byte commitment hash of the auction. +/// * `bidder` — address of the bidder. +/// +/// # Returns +/// `Some(Bid)` when the bidder has a live bid, `None` otherwise. +pub fn get_bid(env: &Env, hash: &BytesN<32>, bidder: &Address) -> Option { + let key = DataKey::Bid(hash.clone(), bidder.clone()); + let result: Option = env.storage().persistent().get(&key); + if result.is_some() { + env.storage() + .persistent() + .extend_ttl(&key, LEDGER_THRESHOLD, LEDGER_BUMP); + } + result +} + +/// Persist `bid` as the current bid from `bidder` on auction `hash`. +/// +/// Any previously stored bid from the same `(hash, bidder)` pair is silently +/// overwritten. The caller must update the bidder list via [`add_bidder`] if +/// this is the bidder's first bid on this auction. +/// +/// # Arguments +/// * `env` — the contract environment. +/// * `hash` — 32-byte commitment hash of the auction. +/// * `bidder` — address of the bidder. +/// * `bid` — the bid record to store. +pub fn set_bid(env: &Env, hash: &BytesN<32>, bidder: &Address, bid: &Bid) { + let key = DataKey::Bid(hash.clone(), bidder.clone()); + env.storage().persistent().set(&key, bid); + env.storage() + .persistent() + .extend_ttl(&key, LEDGER_THRESHOLD, LEDGER_BUMP); +} + +// --------------------------------------------------------------------------- +// Bidder list helpers +// --------------------------------------------------------------------------- + +/// Returns the ordered list of all addresses that have ever bid on auction +/// `hash`. +/// +/// The returned `Vec` preserves insertion order — the first element is the +/// first bidder. Duplicate addresses are never added; see [`add_bidder`]. +/// +/// Returns an empty `Vec` (not `None`) when no bids have been placed, so +/// callers can iterate unconditionally without an `Option` unwrap. +/// +/// Extends the TTL on a non-empty result. +/// +/// # Arguments +/// * `env` — the contract environment. +/// * `hash` — 32-byte commitment hash of the auction. +pub fn get_all_bidders(env: &Env, hash: &BytesN<32>) -> Vec
{ + let key = DataKey::AllBidders(hash.clone()); + let result: Vec
= env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Vec::new(env)); + if !result.is_empty() { + env.storage() + .persistent() + .extend_ttl(&key, LEDGER_THRESHOLD, LEDGER_BUMP); + } + result +} + +/// Append `bidder` to the bidder list for auction `hash` if they are not +/// already present. +/// +/// This function is idempotent — calling it multiple times with the same +/// `(hash, bidder)` pair is safe and adds the address at most once. +/// +/// # Arguments +/// * `env` — the contract environment. +/// * `hash` — 32-byte commitment hash of the auction. +/// * `bidder` — address to add. +/// +/// # Implementation note +/// The deduplication check is O(n) in the number of existing bidders. This +/// is acceptable because the number of bidders per auction is bounded by +/// `MAX_BATCH_SIZE` enforced at the call site in `lib.rs`. +pub fn add_bidder(env: &Env, hash: &BytesN<32>, bidder: Address) { + let key = DataKey::AllBidders(hash.clone()); + let mut bidders: Vec
= env + .storage() + .persistent() + .get(&key) + .unwrap_or_else(|| Vec::new(env)); + + // Deduplication: only append if the address is not already in the list. + for existing in bidders.iter() { + if existing == bidder { + return; + } + } + + bidders.push_back(bidder); + env.storage().persistent().set(&key, &bidders); + env.storage() + .persistent() + .extend_ttl(&key, LEDGER_THRESHOLD, LEDGER_BUMP); +} diff --git a/gateway-contract/contracts/auction_contract/src/types.rs b/gateway-contract/contracts/auction_contract/src/types.rs index e69de29..99e9713 100644 --- a/gateway-contract/contracts/auction_contract/src/types.rs +++ b/gateway-contract/contracts/auction_contract/src/types.rs @@ -0,0 +1,48 @@ +use soroban_sdk::{contracttype, Address, BytesN, Env, Vec}; + +/// The complete on-chain state of one auction. +/// +/// An auction is uniquely identified by its `hash` (`BytesN<32>`), which is +/// used as the key in `DataKey::Auction(hash)`. The hash is typically the +/// SHA-256 of the auctioned-asset metadata committed off-chain. +/// +/// # Status transitions +/// +/// ```text +/// Created ──► Active ──► Ended ──► Settled +/// └──► Cancelled +/// ``` +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuctionState { + /// Address that created and owns this auction. + pub creator: Address, + /// Unix timestamp (seconds) at which bidding opens. + pub start_time: u64, + /// Unix timestamp (seconds) at which bidding closes. + pub end_time: u64, + /// Minimum bid accepted in the token's base units. + pub reserve_price: i128, + /// Highest bid seen so far; `0` when no bids have been placed. + pub highest_bid: i128, + /// Address of the current highest bidder; `None` when no bids placed. + pub highest_bidder: Option
, + /// Whether the auction creator has closed the auction. + pub is_settled: bool, +} + +/// A single bid placed by one address on one auction. +/// +/// Stored under `DataKey::Bid(auction_hash, bidder)`. +/// Each bidder can hold exactly one active bid per auction; placing a new bid +/// overwrites the previous record. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Bid { + /// The bidder's address (redundant but useful for event payloads). + pub bidder: Address, + /// Bid amount in the token's base units. + pub amount: i128, + /// Ledger timestamp at which this bid was accepted. + pub timestamp: u64, +} From 85cb7f7cb5160c6cf1b5a9b389ede63cc3b1fe72 Mon Sep 17 00:00:00 2001 From: beebozy Date: Tue, 24 Mar 2026 04:36:25 -0700 Subject: [PATCH 2/2] implement refund losers --- .../contracts/auction_contract/src/lib.rs | 543 ++++++++++++++++-- .../contracts/auction_contract/src/storage.rs | 17 +- 2 files changed, 522 insertions(+), 38 deletions(-) diff --git a/gateway-contract/contracts/auction_contract/src/lib.rs b/gateway-contract/contracts/auction_contract/src/lib.rs index d0d4e00..7f3d967 100644 --- a/gateway-contract/contracts/auction_contract/src/lib.rs +++ b/gateway-contract/contracts/auction_contract/src/lib.rs @@ -4,11 +4,47 @@ mod storage; mod types; pub use storage::{ - add_bidder, get_all_bidders, get_auction, get_bid, has_auction, set_auction, set_bid, DataKey, + add_bidder, get_all_bidders, get_auction, get_bid, has_auction, remove_bid, set_auction, + set_bid, DataKey, }; pub use types::{AuctionState, Bid}; -use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, Vec}; +use soroban_sdk::{ + contract, contractimpl, contracttype, symbol_short, token, Address, BytesN, Env, Symbol, Vec, +}; + +// --------------------------------------------------------------------------- +// Event symbols +// --------------------------------------------------------------------------- + +/// Topic symbol for the per-loser refund event. +/// +/// Topics: `[BID_REFUNDED, username_hash]` +/// Data : [`BidRefundedEvent`] +const BID_REFUNDED: Symbol = symbol_short!("BID_RFND"); + +// --------------------------------------------------------------------------- +// Event payload types +// --------------------------------------------------------------------------- + +/// Data payload emitted with every `BID_RFND` event. +/// +/// Published once per outbid bidder when `refund_losers` is called so that +/// off-chain indexers can reconcile balances without replaying every ledger. +#[contracttype] +#[derive(Clone, Debug)] +pub struct BidRefundedEvent { + /// Address that received the refund. + pub bidder: Address, + /// Exact XLM amount (stroops) returned. + pub amount: i128, + /// Auction the bid belonged to. + pub auction_hash: BytesN<32>, +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- #[contract] pub struct AuctionContract; @@ -16,27 +52,29 @@ pub struct AuctionContract; #[contractimpl] impl AuctionContract { // ----------------------------------------------------------------------- - // Auction management + // Auction lifecycle // ----------------------------------------------------------------------- - /// Create a new auction identified by `hash`. + /// Create a new auction identified by `username_hash`. /// - /// The `hash` must not already be in use — callers should verify with - /// `has_auction` before calling this. The creator must authorise the - /// call. + /// The hash must not already be in use. The `creator` must authorise + /// the call. /// - /// # Errors - /// Panics with `"auction already exists"` when `hash` is already in use. + /// # Panics + /// * `"auction already exists"` — `username_hash` is already registered. pub fn create_auction( env: Env, creator: Address, - hash: BytesN<32>, + username_hash: BytesN<32>, start_time: u64, end_time: u64, reserve_price: i128, ) { creator.require_auth(); - assert!(!has_auction(&env, &hash), "auction already exists"); + assert!( + !has_auction(&env, &username_hash), + "auction already exists" + ); let state = AuctionState { creator, @@ -47,38 +85,166 @@ impl AuctionContract { highest_bidder: None, is_settled: false, }; - set_auction(&env, &hash, &state); + set_auction(&env, &username_hash, &state); } - /// View: returns the full [`AuctionState`] for `hash`. + /// Mark an auction as settled so that `refund_losers` becomes callable. + /// + /// Only the auction creator may close it, and only after `end_time` has + /// been reached. Once closed the auction is permanently settled — + /// `is_settled` cannot be reset to `false`. /// - /// # Errors - /// Panics with `"auction not found"` when `hash` has no associated state. - pub fn get_auction(env: Env, hash: BytesN<32>) -> AuctionState { - get_auction(&env, &hash).expect("auction not found") + /// # Panics + /// * `"auction not found"` — unknown `username_hash`. + /// * `"not authorised"` — caller is not the auction `creator`. + /// * `"auction not yet ended"` — ledger timestamp < `end_time`. + /// * `"auction already settled"` — already closed. + pub fn close_auction(env: Env, caller: Address, username_hash: BytesN<32>) { + caller.require_auth(); + + let mut state = get_auction(&env, &username_hash).expect("auction not found"); + + assert!(caller == state.creator, "not authorised"); + assert!( + env.ledger().timestamp() >= state.end_time, + "auction not yet ended" + ); + assert!(!state.is_settled, "auction already settled"); + + state.is_settled = true; + set_auction(&env, &username_hash, &state); } - /// View: returns `true` if an auction exists for `hash`. - pub fn has_auction(env: Env, hash: BytesN<32>) -> bool { - has_auction(&env, &hash) + // ----------------------------------------------------------------------- + // Refunds + // ----------------------------------------------------------------------- + + /// Refund every non-winning bidder their full XLM after the auction closes. + /// + /// **Trustless** — no authentication required; anyone may trigger refunds. + /// This allows a keeper, a cron job, or any third party to ensure losers + /// are made whole without relying on the auction creator. + /// + /// ## Eligibility + /// An auction is eligible for `refund_losers` when `is_settled == true` + /// (set by `close_auction`). Calling before the auction is closed panics + /// with `"auction not closed"`. + /// + /// ## Per-bidder logic + /// For every address in `AllBidders`: + /// 1. Skip if the address is `highest_bidder` (the winner). + /// 2. Skip if no `Bid` record exists — they were already refunded in a + /// prior call (idempotency guard). + /// 3. **Effect**: delete the `Bid` record from storage. + /// 4. **Interaction**: transfer `bid.amount` XLM from the contract to the + /// bidder via the native token client. + /// 5. **Event**: emit `BID_RFND` with a [`BidRefundedEvent`] payload. + /// + /// Steps 3–5 follow the Checks–Effects–Interactions pattern to prevent + /// reentrancy from causing a double-refund. + /// + /// ## Repeated calls + /// Safe to call multiple times. Bidders whose records were already + /// removed are silently skipped, so the second and subsequent calls + /// transfer nothing and emit no events. + /// + /// # Arguments + /// * `env` — the contract environment. + /// * `username_hash` — 32-byte commitment hash identifying the auction. + /// + /// # Panics + /// * `"auction not found"` — no auction exists for `username_hash`. + /// * `"auction not closed"` — auction is still active (`is_settled == false`). + pub fn refund_losers(env: Env, username_hash: BytesN<32>) { + // ── CHECKS ────────────────────────────────────────────────────────── + + let state = get_auction(&env, &username_hash).expect("auction not found"); + + // Reject if auction is still open — losers cannot be determined yet. + assert!(state.is_settled, "auction not closed"); + + // Capture winner before iterating; None when no bids were placed. + let winner: Option
= state.highest_bidder.clone(); + + // Snapshot the bidder list. Iterating over this Vec while we remove + // individual Bid keys underneath is safe — AllBidders is not mutated. + let bidders: Vec
= get_all_bidders(&env, &username_hash); + + // Native XLM client — the Stellar native asset contract is addressed + // by the network's built-in token address. In the gateway contract + // this address is stored at init time; here we derive it the standard + // Soroban way. + let native_client = token::StellarAssetClient::new( + &env, + &env.current_contract_address(), + ); + + // ── EFFECTS + INTERACTIONS (CEI applied per-bidder) ───────────────── + + for bidder in bidders.iter() { + // Skip the winner — their bid stays locked until the claim step. + if let Some(ref w) = winner { + if bidder == *w { + continue; + } + } + + // Read the bid. A None here means this bidder was already + // refunded in a previous call to refund_losers — skip to avoid + // a double-refund. + let bid = match get_bid(&env, &username_hash, &bidder) { + Some(b) => b, + None => continue, + }; + + let refund_amount = bid.amount; + + // EFFECT: remove the bid record before the transfer. If the + // native_client.transfer panicked and the transaction was + // reverted, the remove would also be reverted, leaving the bid + // intact for a retry. On success, the record is gone and a + // second call finds None above. + remove_bid(&env, &username_hash, &bidder); + + // INTERACTION: return XLM to the bidder. + native_client.transfer( + &env.current_contract_address(), + &bidder, + &refund_amount, + ); + + // EVENT: one emission per refunded loser. + env.events().publish( + (BID_REFUNDED, username_hash.clone()), + BidRefundedEvent { + bidder: bidder.clone(), + amount: refund_amount, + auction_hash: username_hash.clone(), + }, + ); + } } // ----------------------------------------------------------------------- // Bidding // ----------------------------------------------------------------------- - /// Place or update a bid from `bidder` on auction `hash`. + /// Place or update a bid on auction `username_hash`. /// - /// * The auction must exist. - /// * `amount` must exceed the current `highest_bid`. + /// * The auction must exist and must not be settled. + /// * `amount` must strictly exceed the current `highest_bid`. /// * `bidder` must authorise the call. /// - /// # Errors - /// Panics on constraint violations (auction missing, bid too low). - pub fn place_bid(env: Env, hash: BytesN<32>, bidder: Address, amount: i128) { + /// # Panics + /// * `"auction not found"` — unknown hash. + /// * `"auction already settled"` — bidding is closed. + /// * `"bid must exceed highest bid"` — `amount` is not strictly greater. + pub fn place_bid(env: Env, username_hash: BytesN<32>, bidder: Address, amount: i128) { bidder.require_auth(); - let mut state = get_auction(&env, &hash).expect("auction not found"); + let mut state = get_auction(&env, &username_hash).expect("auction not found"); + + assert!(!state.is_settled, "auction already settled"); assert!(amount > state.highest_bid, "bid must exceed highest bid"); let bid = Bid { @@ -87,23 +253,326 @@ impl AuctionContract { timestamp: env.ledger().timestamp(), }; - // Update the bidder list before writing the bid record so that the + // Maintain bidder list before writing the bid record so the // AllBidders key is always at least as fresh as any Bid key. - add_bidder(&env, &hash, bidder.clone()); - set_bid(&env, &hash, &bidder, &bid); + add_bidder(&env, &username_hash, bidder.clone()); + set_bid(&env, &username_hash, &bidder, &bid); state.highest_bid = amount; state.highest_bidder = Some(bidder); - set_auction(&env, &hash, &state); + set_auction(&env, &username_hash, &state); + } + + // ----------------------------------------------------------------------- + // View helpers + // ----------------------------------------------------------------------- + + /// Returns the full [`AuctionState`] for `username_hash`. + /// + /// # Panics + /// `"auction not found"` when the hash is unknown. + pub fn get_auction(env: Env, username_hash: BytesN<32>) -> AuctionState { + get_auction(&env, &username_hash).expect("auction not found") + } + + /// Returns `true` if an auction record exists for `username_hash`. + pub fn has_auction(env: Env, username_hash: BytesN<32>) -> bool { + has_auction(&env, &username_hash) } - /// View: returns the [`Bid`] placed by `bidder` on `hash`, if any. - pub fn get_bid(env: Env, hash: BytesN<32>, bidder: Address) -> Option { - get_bid(&env, &hash, &bidder) + /// Returns the [`Bid`] placed by `bidder` on `username_hash`, or `None`. + pub fn get_bid(env: Env, username_hash: BytesN<32>, bidder: Address) -> Option { + get_bid(&env, &username_hash, &bidder) } - /// View: returns all addresses that have bid on `hash`. - pub fn get_all_bidders(env: Env, hash: BytesN<32>) -> Vec
{ - get_all_bidders(&env, &hash) + /// Returns every address that has bid on `username_hash`. + pub fn get_all_bidders(env: Env, username_hash: BytesN<32>) -> Vec
{ + get_all_bidders(&env, &username_hash) } } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod test { + use super::*; + use soroban_sdk::{ + testutils::{Address as _, Events, Ledger, LedgerInfo}, + Address, BytesN, Env, IntoVal, + }; + + // ── helpers ────────────────────────────────────────────────────────────── + + fn test_hash(env: &Env) -> BytesN<32> { + BytesN::from_array(env, &[1u8; 32]) + } + + /// Standard fixture: env + client + four addresses + one open auction. + struct Setup { + env: Env, + client: AuctionContractClient<'static>, + creator: Address, + alice: Address, + bob: Address, + charlie: Address, + hash: BytesN<32>, + } + + impl Setup { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + let creator = Address::generate(&env); + let alice = Address::generate(&env); + let bob = Address::generate(&env); + let charlie = Address::generate(&env); + + let contract_id = env.register_contract(None, AuctionContract); + let client = AuctionContractClient::new(&env, &contract_id); + let hash = test_hash(&env); + + client.create_auction( + &creator, + &hash, + &0u64, // start_time + &1000u64, // end_time + &100i128, // reserve_price + ); + + Setup { env, client, creator, alice, bob, charlie, hash } + } + + /// Advance ledger past end_time and mark the auction settled. + fn close(&self) { + self.env.ledger().set(LedgerInfo { + timestamp: 1001, + ..self.env.ledger().get() + }); + self.client.close_auction(&self.creator, &self.hash); + } + } + + // ── close_auction ───────────────────────────────────────────────────── + + #[test] + fn close_sets_is_settled_true() { + let s = Setup::new(); + s.close(); + assert!(s.client.get_auction(&s.hash).is_settled); + } + + #[test] + #[should_panic(expected = "auction not yet ended")] + fn close_before_end_time_panics() { + let s = Setup::new(); + // ledger timestamp is 0 — below end_time 1000. + s.client.close_auction(&s.creator, &s.hash); + } + + #[test] + #[should_panic(expected = "auction already settled")] + fn close_twice_panics() { + let s = Setup::new(); + s.close(); + s.client.close_auction(&s.creator, &s.hash); // second call + } + + // ── refund_losers — guard checks ────────────────────────────────────── + + #[test] + #[should_panic(expected = "auction not found")] + fn refund_losers_unknown_hash_panics() { + let s = Setup::new(); + let bad = BytesN::from_array(&s.env, &[9u8; 32]); + s.client.refund_losers(&bad); + } + + #[test] + #[should_panic(expected = "auction not closed")] + fn refund_losers_before_close_panics() { + let s = Setup::new(); + // is_settled is false — must be rejected. + s.client.refund_losers(&s.hash); + } + + // ── refund_losers — no-bid edge case ───────────────────────────────── + + #[test] + fn refund_losers_with_no_bids_is_noop() { + let s = Setup::new(); + s.close(); + s.client.refund_losers(&s.hash); // must not panic + + // No BID_RFND events expected. + let rfnd_count = s + .env + .events() + .all() + .iter() + .filter(|e| { + e.1.get(0) + .map(|t| { + let sym: Symbol = t.into_val(&s.env); + sym == BID_REFUNDED + }) + .unwrap_or(false) + }) + .count(); + assert_eq!(rfnd_count, 0); + } + + // ── refund_losers — winner is not refunded ──────────────────────────── + + #[test] + fn winner_bid_record_is_not_removed() { + let s = Setup::new(); + s.client.place_bid(&s.hash, &s.alice, &500i128); + s.close(); + s.client.refund_losers(&s.hash); + + // Alice's bid record must still exist. + assert!( + s.client.get_bid(&s.hash, &s.alice).is_some(), + "winner bid record was incorrectly removed" + ); + } + + // ── refund_losers — multi-bidder: all losers refunded ───────────────── + + #[test] + fn all_losers_refunded_winner_kept() { + let s = Setup::new(); + + s.client.place_bid(&s.hash, &s.alice, &200i128); // loser + s.client.place_bid(&s.hash, &s.bob, &300i128); // loser + s.client.place_bid(&s.hash, &s.charlie, &400i128); // winner + + s.close(); + s.client.refund_losers(&s.hash); + + // Losers' bid records must be deleted. + assert!( + s.client.get_bid(&s.hash, &s.alice).is_none(), + "alice bid should be gone after refund" + ); + assert!( + s.client.get_bid(&s.hash, &s.bob).is_none(), + "bob bid should be gone after refund" + ); + + // Winner's record must remain. + assert!( + s.client.get_bid(&s.hash, &s.charlie).is_some(), + "winner bid must not be removed" + ); + + // Auction state must still name Charlie as highest_bidder. + let state = s.client.get_auction(&s.hash); + assert_eq!(state.highest_bidder, Some(s.charlie.clone())); + } + + // ── refund_losers — correct event count ─────────────────────────────── + + #[test] + fn emits_one_event_per_loser() { + let s = Setup::new(); + + s.client.place_bid(&s.hash, &s.alice, &200i128); // loser → event + s.client.place_bid(&s.hash, &s.bob, &300i128); // loser → event + s.client.place_bid(&s.hash, &s.charlie, &400i128); // winner → no event + + s.close(); + s.client.refund_losers(&s.hash); + + let rfnd_count = s + .env + .events() + .all() + .iter() + .filter(|e| { + e.1.get(0) + .map(|t| { + let sym: Symbol = t.into_val(&s.env); + sym == BID_REFUNDED + }) + .unwrap_or(false) + }) + .count(); + + assert_eq!(rfnd_count, 2, "expected exactly 2 BID_RFND events"); + } + + // ── refund_losers — idempotency ─────────────────────────────────────── + + #[test] + fn second_call_is_noop_no_extra_events() { + let s = Setup::new(); + + s.client.place_bid(&s.hash, &s.alice, &200i128); // loser + s.client.place_bid(&s.hash, &s.charlie, &400i128); // winner + + s.close(); + s.client.refund_losers(&s.hash); // first call — refunds Alice + s.client.refund_losers(&s.hash); // second call — must be a no-op + + // Still only one BID_RFND event total. + let rfnd_count = s + .env + .events() + .all() + .iter() + .filter(|e| { + e.1.get(0) + .map(|t| { + let sym: Symbol = t.into_val(&s.env); + sym == BID_REFUNDED + }) + .unwrap_or(false) + }) + .count(); + + assert_eq!(rfnd_count, 1, "second call must not produce extra events"); + } + + // ── refund_losers — trustless: no auth required ─────────────────────── + + #[test] + fn refund_losers_requires_no_auth() { + // Build a closed auction with mocked auth. + let env = Env::default(); + env.mock_all_auths(); + + let creator = Address::generate(&env); + let contract_id = env.register_contract(None, AuctionContract); + let client = AuctionContractClient::new(&env, &contract_id); + let hash = test_hash(&env); + + client.create_auction(&creator, &hash, &0u64, &1000u64, &100i128); + env.ledger().set(LedgerInfo { + timestamp: 1001, + ..env.ledger().get() + }); + client.close_auction(&creator, &hash); + + // Clear the auth mock so only explicit require_auth calls would fail. + // refund_losers has no require_auth — it must still succeed. + let env2 = Env::default(); + let client2 = AuctionContractClient::new(&env2, &contract_id); + // Calling on the same contract address without mock_all_auths. + // Any require_auth inside refund_losers would panic here. + client2.refund_losers(&hash); + } + + // ── place_bid — cannot bid on settled auction ───────────────────────── + + #[test] + #[should_panic(expected = "auction already settled")] + fn place_bid_after_close_panics() { + let s = Setup::new(); + s.close(); + s.client.place_bid(&s.hash, &s.alice, &200i128); + } +} \ No newline at end of file diff --git a/gateway-contract/contracts/auction_contract/src/storage.rs b/gateway-contract/contracts/auction_contract/src/storage.rs index ac580ee..1a3b43d 100644 --- a/gateway-contract/contracts/auction_contract/src/storage.rs +++ b/gateway-contract/contracts/auction_contract/src/storage.rs @@ -176,6 +176,21 @@ pub fn set_bid(env: &Env, hash: &BytesN<32>, bidder: &Address, bid: &Bid) { .extend_ttl(&key, LEDGER_THRESHOLD, LEDGER_BUMP); } +/// Delete the [`Bid`] record for `bidder` on auction `hash`. +/// +/// Called after a refund is issued so that the same bidder cannot be refunded +/// twice if `refund_losers` is invoked more than once. A no-op when the key +/// does not exist. +/// +/// # Arguments +/// * `env` — the contract environment. +/// * `hash` — 32-byte commitment hash of the auction. +/// * `bidder` — address whose bid record should be removed. +pub fn remove_bid(env: &Env, hash: &BytesN<32>, bidder: &Address) { + let key = DataKey::Bid(hash.clone(), bidder.clone()); + env.storage().persistent().remove(&key); +} + // --------------------------------------------------------------------------- // Bidder list helpers // --------------------------------------------------------------------------- @@ -244,4 +259,4 @@ pub fn add_bidder(env: &Env, hash: &BytesN<32>, bidder: Address) { env.storage() .persistent() .extend_ttl(&key, LEDGER_THRESHOLD, LEDGER_BUMP); -} +} \ No newline at end of file