diff --git a/gateway-contract/contracts/auction_contract/src/lib.rs b/gateway-contract/contracts/auction_contract/src/lib.rs
index dc0e0a3..437c1ac 100644
--- a/gateway-contract/contracts/auction_contract/src/lib.rs
+++ b/gateway-contract/contracts/auction_contract/src/lib.rs
@@ -1,180 +1,578 @@
#![no_std]
-use soroban_sdk::{contract, contractimpl, vec, Address, BytesN, Env, IntoVal, Symbol};
-
-pub mod errors;
-pub mod events;
-pub mod storage;
-pub mod types;
-
-// Ensure event symbols are linked from the main contract entrypoint module.
-use crate::events::{AUCTION_CLOSED, AUCTION_CREATED, BID_PLACED, BID_REFUNDED, USERNAME_CLAIMED};
-
-#[allow(dead_code)]
-fn _touch_event_symbols() {
- let _ = (
- AUCTION_CREATED,
- BID_PLACED,
- AUCTION_CLOSED,
- USERNAME_CLAIMED,
- BID_REFUNDED,
- );
+
+mod storage;
+mod types;
+
+pub use storage::{
+ 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, 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>,
}
-#[cfg(test)]
-mod test;
+// ---------------------------------------------------------------------------
+// Contract
+// ---------------------------------------------------------------------------
#[contract]
pub struct AuctionContract;
#[contractimpl]
impl AuctionContract {
- pub fn close_auction(
+ // -----------------------------------------------------------------------
+ // Auction lifecycle
+ // -----------------------------------------------------------------------
+
+ /// Create a new auction identified by `username_hash`.
+ ///
+ /// The hash must not already be in use. The `creator` must authorise
+ /// the call.
+ ///
+ /// # Panics
+ /// * `"auction already exists"` — `username_hash` is already registered.
+ pub fn create_auction(
env: Env,
+ creator: Address,
username_hash: BytesN<32>,
- ) -> Result<(), crate::errors::AuctionError> {
- let status = storage::get_status(&env);
+ start_time: u64,
+ end_time: u64,
+ reserve_price: i128,
+ ) {
+ creator.require_auth();
+ assert!(
+ !has_auction(&env, &username_hash),
+ "auction already exists"
+ );
- // Reject if status is not Open
- if status != types::AuctionStatus::Open {
- return Err(crate::errors::AuctionError::AuctionNotOpen);
- }
+ let state = AuctionState {
+ creator,
+ start_time,
+ end_time,
+ reserve_price,
+ highest_bid: 0,
+ highest_bidder: None,
+ is_settled: false,
+ };
+ set_auction(&env, &username_hash, &state);
+ }
+
+ /// 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`.
+ ///
+ /// # 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);
+ }
+
+ // -----------------------------------------------------------------------
+ // 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) ─────────────────
- // Get current ledger timestamp and end time
- let current_time = env.ledger().timestamp();
- let end_time = storage::get_end_time(&env);
+ 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;
+ }
+ }
- // Reject if timestamp < end_time
- if current_time < end_time {
- return Err(crate::errors::AuctionError::AuctionNotClosed);
+ // 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 on auction `username_hash`.
+ ///
+ /// * The auction must exist and must not be settled.
+ /// * `amount` must strictly exceed the current `highest_bid`.
+ /// * `bidder` must authorise the call.
+ ///
+ /// # 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();
- // Set status to Closed
- storage::set_status(&env, types::AuctionStatus::Closed);
+ let mut state = get_auction(&env, &username_hash).expect("auction not found");
- // Get winner and winning bid
- let winner = storage::get_highest_bidder(&env);
- let winning_bid = storage::get_highest_bid(&env);
+ assert!(!state.is_settled, "auction already settled");
+ assert!(amount > state.highest_bid, "bid must exceed highest bid");
- // Emit AUCTION_CLOSED event with winner and winning bid
- events::emit_auction_closed(&env, &username_hash, winner.clone(), winning_bid);
+ let bid = Bid {
+ bidder: bidder.clone(),
+ amount,
+ timestamp: env.ledger().timestamp(),
+ };
- Ok(())
+ // 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, &username_hash, bidder.clone());
+ set_bid(&env, &username_hash, &bidder, &bid);
+
+ state.highest_bid = amount;
+ state.highest_bidder = Some(bidder);
+ 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")
}
- pub fn claim_username(
+ /// 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)
+ }
+
+ /// 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)
+ }
+
+ /// 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,
- username_hash: BytesN<32>,
- claimer: Address,
- ) -> Result<(), crate::errors::AuctionError> {
- claimer.require_auth();
+ client: AuctionContractClient<'static>,
+ creator: Address,
+ alice: Address,
+ bob: Address,
+ charlie: Address,
+ hash: BytesN<32>,
+ }
- let status = storage::get_status(&env);
+ impl Setup {
+ fn new() -> Self {
+ let env = Env::default();
+ env.mock_all_auths();
- if status == types::AuctionStatus::Claimed {
- return Err(crate::errors::AuctionError::AlreadyClaimed);
- }
+ 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);
- if status != types::AuctionStatus::Closed {
- return Err(crate::errors::AuctionError::NotClosed);
+ client.create_auction(
+ &creator,
+ &hash,
+ &0u64, // start_time
+ &1000u64, // end_time
+ &100i128, // reserve_price
+ );
+
+ Setup { env, client, creator, alice, bob, charlie, hash }
}
- let highest_bidder = storage::get_highest_bidder(&env);
- if !highest_bidder.map(|h| h == claimer).unwrap_or(false) {
- return Err(crate::errors::AuctionError::NotWinner);
+ /// 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);
}
+ }
- // Set status to Claimed
- storage::set_status(&env, types::AuctionStatus::Claimed);
+ // ── close_auction ─────────────────────────────────────────────────────
- // Call factory_contract.deploy_username(username_hash, claimer)
- let factory = storage::get_factory_contract(&env);
- if factory.is_none() {
- return Err(crate::errors::AuctionError::NoFactoryContract);
- }
+ #[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
- let factory_addr = factory.ok_or(crate::errors::AuctionError::NoFactoryContract)?;
- env.invoke_contract::<()>(
- &factory_addr,
- &Symbol::new(&env, "deploy_username"),
- vec![&env, username_hash.into_val(&env), claimer.into_val(&env)],
+ // 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
- // Emit USERNAME_CLAIMED event
- events::emit_username_claimed(&env, &username_hash, &claimer);
+ s.close();
+ s.client.refund_losers(&s.hash);
- Ok(())
+ // 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()));
}
-}
-#[contractimpl]
-impl AuctionContract {
- pub fn create_auction(
- env: Env,
- id: u32,
- seller: Address,
- asset: Address,
- min_bid: i128,
- end_time: u64,
- ) {
- seller.require_auth();
- if storage::auction_exists(&env, id) {
- soroban_sdk::panic_with_error!(&env, errors::AuctionError::AuctionNotOpen);
- }
- storage::auction_set_seller(&env, id, &seller);
- storage::auction_set_asset(&env, id, &asset);
- storage::auction_set_min_bid(&env, id, min_bid);
- storage::auction_set_end_time(&env, id, end_time);
- storage::auction_set_status(&env, id, types::AuctionStatus::Open);
+ // ── 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");
}
- pub fn place_bid(env: Env, id: u32, bidder: Address, amount: i128) {
- bidder.require_auth();
- let end_time = storage::auction_get_end_time(&env, id);
- if env.ledger().timestamp() >= end_time {
- soroban_sdk::panic_with_error!(&env, errors::AuctionError::AuctionNotOpen);
- }
- let min_bid = storage::auction_get_min_bid(&env, id);
- let highest_bid = storage::auction_get_highest_bid(&env, id);
- if amount < min_bid || amount <= highest_bid {
- soroban_sdk::panic_with_error!(&env, errors::AuctionError::BidTooLow);
- }
- let asset = storage::auction_get_asset(&env, id);
- let token = soroban_sdk::token::Client::new(&env, &asset);
- token.transfer(&bidder, env.current_contract_address(), &amount);
- if let Some(prev_bidder) = storage::auction_get_highest_bidder(&env, id) {
- token.transfer(&env.current_contract_address(), &prev_bidder, &highest_bid);
- }
- storage::auction_set_highest_bidder(&env, id, &bidder);
- storage::auction_set_highest_bid(&env, id, amount);
+ // ── 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");
}
- pub fn close_auction_by_id(env: Env, id: u32) {
- let end_time = storage::auction_get_end_time(&env, id);
- if env.ledger().timestamp() < end_time {
- soroban_sdk::panic_with_error!(&env, errors::AuctionError::AuctionNotClosed);
- }
- storage::auction_set_status(&env, id, types::AuctionStatus::Closed);
+ // ── 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);
}
- pub fn claim(env: Env, id: u32, claimant: Address) {
- claimant.require_auth();
- let status = storage::auction_get_status(&env, id);
- if status != types::AuctionStatus::Closed {
- soroban_sdk::panic_with_error!(&env, errors::AuctionError::NotClosed);
- }
- if storage::auction_is_claimed(&env, id) {
- soroban_sdk::panic_with_error!(&env, errors::AuctionError::AlreadyClaimed);
- }
- let winner = storage::auction_get_highest_bidder(&env, id);
- if winner.as_ref().map(|w| w == &claimant).unwrap_or(false) {
- let asset = storage::auction_get_asset(&env, id);
- let token = soroban_sdk::token::Client::new(&env, &asset);
- let winning_bid = storage::auction_get_highest_bid(&env, id);
- let seller = storage::auction_get_seller(&env, id);
- token.transfer(&env.current_contract_address(), &seller, &winning_bid);
- storage::auction_set_claimed(&env, id);
- } else {
- soroban_sdk::panic_with_error!(&env, errors::AuctionError::NotWinner);
- }
+ // ── 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);
}
}
diff --git a/gateway-contract/contracts/auction_contract/src/storage.rs b/gateway-contract/contracts/auction_contract/src/storage.rs
index 5fea299..8a7fbcd 100644
--- a/gateway-contract/contracts/auction_contract/src/storage.rs
+++ b/gateway-contract/contracts/auction_contract/src/storage.rs
@@ -1,162 +1,262 @@
-use crate::types::{AuctionStatus, DataKey};
-use soroban_sdk::{Address, Env};
+//! 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.
-pub fn get_status(env: &Env) -> AuctionStatus {
- env.storage()
- .instance()
- .get(&DataKey::Status)
- .unwrap_or(AuctionStatus::Open)
-}
-
-pub fn set_status(env: &Env, status: AuctionStatus) {
- env.storage().instance().set(&DataKey::Status, &status);
-}
-
-pub fn get_highest_bidder(env: &Env) -> Option {
- env.storage().instance().get(&DataKey::HighestBidder)
-}
-
-pub fn set_highest_bidder(env: &Env, bidder: &Address) {
- env.storage()
- .instance()
- .set(&DataKey::HighestBidder, bidder);
-}
-
-pub fn get_factory_contract(env: &Env) -> Option {
- env.storage().instance().get(&DataKey::FactoryContract)
-}
-
-pub fn set_factory_contract(env: &Env, factory: &Address) {
- env.storage()
- .instance()
- .set(&DataKey::FactoryContract, factory);
-}
+use soroban_sdk::{Address, BytesN, Env, Vec};
-pub fn get_end_time(env: &Env) -> u64 {
- env.storage().instance().get(&DataKey::EndTime).unwrap_or(0)
-}
+use crate::types::{AuctionState, Bid};
-pub fn set_end_time(env: &Env, end_time: u64) {
- env.storage().instance().set(&DataKey::EndTime, &end_time);
-}
+// ---------------------------------------------------------------------------
+// TTL policy
+// ---------------------------------------------------------------------------
-pub fn get_highest_bid(env: &Env) -> u128 {
- env.storage()
- .instance()
- .get(&DataKey::HighestBid)
- .unwrap_or(0)
-}
-
-pub fn set_highest_bid(env: &Env, bid: u128) {
- env.storage().instance().set(&DataKey::HighestBid, &bid);
-}
+/// 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
-// --- id-scoped auction storage ---
-use crate::types::AuctionKey;
-
-pub fn auction_exists(env: &Env, id: u32) -> bool {
- env.storage().persistent().has(&AuctionKey::Status(id))
-}
-
-pub fn auction_get_status(env: &Env, id: u32) -> crate::types::AuctionStatus {
- env.storage()
- .persistent()
- .get(&AuctionKey::Status(id))
- .unwrap_or(crate::types::AuctionStatus::Open)
-}
+/// Target TTL to extend to on every touch (≈ 60 days).
+const LEDGER_BUMP: u32 = 1_036_800;
-pub fn auction_set_status(env: &Env, id: u32, status: crate::types::AuctionStatus) {
- env.storage()
- .persistent()
- .set(&AuctionKey::Status(id), &status);
-}
+// ---------------------------------------------------------------------------
+// DataKey — the canonical key enum for this contract
+// ---------------------------------------------------------------------------
-pub fn auction_get_seller(env: &Env, id: u32) -> Address {
- env.storage()
- .persistent()
- .get(&AuctionKey::Seller(id))
- .unwrap()
+/// 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>),
}
-pub fn auction_set_seller(env: &Env, id: u32, seller: &Address) {
- env.storage()
- .persistent()
- .set(&AuctionKey::Seller(id), seller);
-}
+// ---------------------------------------------------------------------------
+// Auction helpers
+// ---------------------------------------------------------------------------
-pub fn auction_get_asset(env: &Env, id: u32) -> Address {
- env.storage()
- .persistent()
- .get(&AuctionKey::Asset(id))
- .unwrap()
+/// 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
}
-pub fn auction_set_asset(env: &Env, id: u32, asset: &Address) {
+/// 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()
- .set(&AuctionKey::Asset(id), asset);
+ .extend_ttl(&key, LEDGER_THRESHOLD, LEDGER_BUMP);
}
-pub fn auction_get_min_bid(env: &Env, id: u32) -> i128 {
+/// 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()
- .get(&AuctionKey::MinBid(id))
- .unwrap_or(0)
+ .has(&DataKey::Auction(hash.clone()))
}
-pub fn auction_set_min_bid(env: &Env, id: u32, min_bid: i128) {
- env.storage()
- .persistent()
- .set(&AuctionKey::MinBid(id), &min_bid);
-}
+// ---------------------------------------------------------------------------
+// Bid helpers
+// ---------------------------------------------------------------------------
-pub fn auction_get_end_time(env: &Env, id: u32) -> u64 {
- env.storage()
- .persistent()
- .get(&AuctionKey::EndTime(id))
- .unwrap_or(0)
+/// 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
}
-pub fn auction_set_end_time(env: &Env, id: u32, end_time: u64) {
+/// 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()
- .set(&AuctionKey::EndTime(id), &end_time);
+ .extend_ttl(&key, LEDGER_THRESHOLD, LEDGER_BUMP);
}
-pub fn auction_get_highest_bidder(env: &Env, id: u32) -> Option {
- env.storage()
- .persistent()
- .get(&AuctionKey::HighestBidder(id))
+/// 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);
}
-pub fn auction_set_highest_bidder(env: &Env, id: u32, bidder: &Address) {
- env.storage()
- .persistent()
- .set(&AuctionKey::HighestBidder(id), bidder);
-}
+// ---------------------------------------------------------------------------
+// Bidder list helpers
+// ---------------------------------------------------------------------------
-pub fn auction_get_highest_bid(env: &Env, id: u32) -> i128 {
- env.storage()
+/// 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(&AuctionKey::HighestBid(id))
- .unwrap_or(0)
+ .get(&key)
+ .unwrap_or_else(|| Vec::new(env));
+ if !result.is_empty() {
+ env.storage()
+ .persistent()
+ .extend_ttl(&key, LEDGER_THRESHOLD, LEDGER_BUMP);
+ }
+ result
}
-pub fn auction_set_highest_bid(env: &Env, id: u32, bid: i128) {
- env.storage()
+/// 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()
- .set(&AuctionKey::HighestBid(id), &bid);
-}
+ .get(&key)
+ .unwrap_or_else(|| Vec::new(env));
-pub fn auction_is_claimed(env: &Env, id: u32) -> bool {
- env.storage()
- .persistent()
- .get(&AuctionKey::Claimed(id))
- .unwrap_or(false)
-}
+ // Deduplication: only append if the address is not already in the list.
+ for existing in bidders.iter() {
+ if existing == bidder {
+ return;
+ }
+ }
-pub fn auction_set_claimed(env: &Env, id: u32) {
+ bidders.push_back(bidder);
+ env.storage().persistent().set(&key, &bidders);
env.storage()
.persistent()
- .set(&AuctionKey::Claimed(id), &true);
+ .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 6d9815f..99e9713 100644
--- a/gateway-contract/contracts/auction_contract/src/types.rs
+++ b/gateway-contract/contracts/auction_contract/src/types.rs
@@ -1,32 +1,48 @@
-use soroban_sdk::contracttype;
+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 enum AuctionStatus {
- Open,
- Closed,
- Claimed,
+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 enum DataKey {
- Status,
- HighestBidder,
- FactoryContract,
- EndTime,
- HighestBid,
-}
-
-#[contracttype]
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum AuctionKey {
- Seller(u32),
- Asset(u32),
- MinBid(u32),
- EndTime(u32),
- HighestBidder(u32),
- HighestBid(u32),
- Status(u32),
- Claimed(u32),
+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,
}