Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 123 additions & 2 deletions contracts/auction/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#![no_std]

use soroban_sdk::{contract, contractimpl, Env};
use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env};

/// Bitmap of supported auction capabilities.
/// Clients can read this to detect feature deltas across deployments.
Expand All @@ -11,6 +11,58 @@ use soroban_sdk::{contract, contractimpl, Env};
/// bit 3: cancellable auctions
pub const CAPABILITIES: u64 = 0b1111;

/// Minimum remaining ledgers an auction record may have before it is
/// refreshed on a hot read. At ~5 s/ledger, 7 days ≈ 120_960 ledgers.
///
/// Keeping this well above zero means a record is bumped on read long
/// before it is at risk of archival eviction, even if reads cluster right
/// before expiry.
pub const AUCTION_TTL_THRESHOLD: u32 = 17_280 * 7;

/// Ledgers to extend an auction record's TTL to when it is bumped, either on
/// creation or on a hot read below [`AUCTION_TTL_THRESHOLD`]. At ~5 s/ledger,
/// 30 days ≈ 518_400 ledgers.
///
/// Uses `extend_ttl()` semantics, which only ever extend a TTL forward, so
/// bumping is idempotent and safe to call unconditionally on every read.
pub const AUCTION_TTL_EXTEND_TO: u32 = 17_280 * 30;

/// Persistent storage keys used by the Auction contract.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DataKey {
/// An individual auction record, keyed by auction id.
Auction(u64),
/// Counter used to allocate the next auction id.
NextAuctionId,
}

/// Data describing a single auction.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AuctionData {
/// The address that created the auction and receives sale proceeds.
pub seller: Address,
/// Minimum acceptable sale price. Must be strictly positive.
pub reserve_price: i128,
/// Ledger timestamp (seconds) at which the auction was created.
pub created_at: u64,
/// Whether the auction is still open.
pub active: bool,
}

/// Errors returned by the Auction contract.
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum Error {
/// No auction exists for the given id.
AuctionNotFound = 1,
/// `reserve_price` was not strictly positive.
InvalidReservePrice = 2,
/// The auction id counter would overflow `u64`.
AuctionIdOverflow = 3,
}

#[contract]
pub struct Auction;

Expand All @@ -21,8 +73,77 @@ impl Auction {
pub fn capabilities(_env: Env) -> u64 {
CAPABILITIES
}

/// Creates a new auction owned by `seller` with the given `reserve_price`.
///
/// The new record is written to persistent storage with its TTL set to
/// [`AUCTION_TTL_EXTEND_TO`] ledgers so it starts with a full lifetime.
///
/// # Authorization
/// `seller` must authenticate via `require_auth()`.
///
/// # Errors
/// - [`Error::InvalidReservePrice`] if `reserve_price` is not strictly positive.
/// - [`Error::AuctionIdOverflow`] if the auction id counter has been exhausted.
pub fn create_auction(env: Env, seller: Address, reserve_price: i128) -> Result<u64, Error> {
seller.require_auth();

if reserve_price <= 0 {
return Err(Error::InvalidReservePrice);
}

let next_id: u64 = env
.storage()
.instance()
.get(&DataKey::NextAuctionId)
.unwrap_or(0);
let following_id = next_id.checked_add(1).ok_or(Error::AuctionIdOverflow)?;
env.storage()
.instance()
.set(&DataKey::NextAuctionId, &following_id);

let data = AuctionData {
seller,
reserve_price,
created_at: env.ledger().timestamp(),
active: true,
};

let key = DataKey::Auction(next_id);
env.storage().persistent().set(&key, &data);
env.storage()
.persistent()
.extend_ttl(&key, AUCTION_TTL_THRESHOLD, AUCTION_TTL_EXTEND_TO);

Ok(next_id)
}

/// Returns the data for the auction identified by `auction_id`.
///
/// This is the contract's hot read path: bidders and off-chain indexers
/// are expected to call it far more often than auctions are created or
/// modified. Every call bumps the record's persistent TTL back up to
/// [`AUCTION_TTL_EXTEND_TO`] ledgers whenever it has dropped below
/// [`AUCTION_TTL_THRESHOLD`], so a frequently-read, rarely-written
/// auction never falls prey to archival eviction.
///
/// # Errors
/// - [`Error::AuctionNotFound`] if no auction exists for `auction_id`.
pub fn get_auction(env: Env, auction_id: u64) -> Result<AuctionData, Error> {
let key = DataKey::Auction(auction_id);
let data: AuctionData = env
.storage()
.persistent()
.get(&key)
.ok_or(Error::AuctionNotFound)?;

env.storage()
.persistent()
.extend_ttl(&key, AUCTION_TTL_THRESHOLD, AUCTION_TTL_EXTEND_TO);

Ok(data)
}
}

#[cfg(test)]
mod test;

153 changes: 151 additions & 2 deletions contracts/auction/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
#![cfg(test)]

use super::*;
use soroban_sdk::testutils::storage::Persistent as _;
use soroban_sdk::testutils::{Address as _, Ledger as _};
use soroban_sdk::Env;

fn setup(env: &Env) -> soroban_sdk::Address {
env.register(Auction, ())
}

#[test]
fn test_capabilities_returns_bitmap() {
let env = Env::default();
Expand All @@ -26,3 +30,148 @@ fn test_capabilities_is_read_only() {
assert_eq!(a, b, "capabilities must be idempotent");
}

#[test]
fn test_create_auction_requires_seller_auth() {
let env = Env::default();
env.mock_all_auths();
let contract_id = setup(&env);
let client = AuctionClient::new(&env, &contract_id);
let seller = soroban_sdk::Address::generate(&env);

let id = client.create_auction(&seller, &1_000);
assert_eq!(id, 0);
assert_eq!(
env.auths()[0].0,
seller,
"create_auction must require the seller's authorization"
);
}

#[test]
fn test_create_auction_rejects_non_positive_reserve_price() {
let env = Env::default();
env.mock_all_auths();
let contract_id = setup(&env);
let client = AuctionClient::new(&env, &contract_id);
let seller = soroban_sdk::Address::generate(&env);

let zero_result = client.try_create_auction(&seller, &0);
assert_eq!(zero_result, Err(Ok(Error::InvalidReservePrice)));

let negative_result = client.try_create_auction(&seller, &-1);
assert_eq!(negative_result, Err(Ok(Error::InvalidReservePrice)));
}

#[test]
fn test_auction_ids_increment_across_creations() {
let env = Env::default();
env.mock_all_auths();
let contract_id = setup(&env);
let client = AuctionClient::new(&env, &contract_id);
let seller = soroban_sdk::Address::generate(&env);

let first = client.create_auction(&seller, &500);
let second = client.create_auction(&seller, &750);
assert_eq!(first, 0);
assert_eq!(second, 1);
}

#[test]
fn test_get_auction_returns_stored_data() {
let env = Env::default();
env.mock_all_auths();
let contract_id = setup(&env);
let client = AuctionClient::new(&env, &contract_id);
let seller = soroban_sdk::Address::generate(&env);

let id = client.create_auction(&seller, &1_234);
let data = client.get_auction(&id);

assert_eq!(data.seller, seller);
assert_eq!(data.reserve_price, 1_234);
assert!(data.active);
}

#[test]
fn test_get_auction_not_found() {
let env = Env::default();
let contract_id = setup(&env);
let client = AuctionClient::new(&env, &contract_id);

let result = client.try_get_auction(&42);
assert_eq!(result, Err(Ok(Error::AuctionNotFound)));
}

#[test]
fn test_create_auction_rejects_id_overflow() {
let env = Env::default();
env.mock_all_auths();
let contract_id = setup(&env);
let client = AuctionClient::new(&env, &contract_id);
let seller = soroban_sdk::Address::generate(&env);

env.as_contract(&contract_id, || {
env.storage()
.instance()
.set(&DataKey::NextAuctionId, &u64::MAX);
});

let result = client.try_create_auction(&seller, &10);
assert_eq!(result, Err(Ok(Error::AuctionIdOverflow)));
}

#[test]
fn test_get_auction_bumps_ttl_when_below_threshold() {
let env = Env::default();
env.mock_all_auths();
let contract_id = setup(&env);
let client = AuctionClient::new(&env, &contract_id);
let seller = soroban_sdk::Address::generate(&env);

let id = client.create_auction(&seller, &1_000);

env.as_contract(&contract_id, || {
let key = DataKey::Auction(id);
assert_eq!(
env.storage().persistent().get_ttl(&key),
AUCTION_TTL_EXTEND_TO
);

env.ledger().with_mut(|li| {
li.sequence_number += AUCTION_TTL_EXTEND_TO - AUCTION_TTL_THRESHOLD + 1;
});
assert!(env.storage().persistent().get_ttl(&key) < AUCTION_TTL_THRESHOLD);
});

client.get_auction(&id);

env.as_contract(&contract_id, || {
let key = DataKey::Auction(id);
assert_eq!(
env.storage().persistent().get_ttl(&key),
AUCTION_TTL_EXTEND_TO,
"a hot read below the threshold must restore the full TTL"
);
});
}

#[test]
fn test_get_auction_does_not_shrink_ttl_when_above_threshold() {
let env = Env::default();
env.mock_all_auths();
let contract_id = setup(&env);
let client = AuctionClient::new(&env, &contract_id);
let seller = soroban_sdk::Address::generate(&env);

let id = client.create_auction(&seller, &1_000);
client.get_auction(&id);

env.as_contract(&contract_id, || {
let key = DataKey::Auction(id);
assert_eq!(
env.storage().persistent().get_ttl(&key),
AUCTION_TTL_EXTEND_TO,
"extend_ttl only ever extends, never shortens"
);
});
}
Loading