From 5a25475dd566fbbf8672be44d83078cb0e680c08 Mon Sep 17 00:00:00 2001 From: Nathaniel Nanle Date: Tue, 26 May 2026 18:34:48 +0100 Subject: [PATCH] feat(contracts): add issuance summary and claim-expiry accessor Closes #703 - issuance_summary(voucher_type_id): returns total_issued, max_supply, remaining (u64::MAX when uncapped), paused - claim_expiry(voucher_id): returns expires_at_ledger, claimed, is_expired (computed live against current ledger at read time) - Unknown ids return exists=false with zeroed fields - 9 unit tests: success, uncapped, paused, missing, expired, claimed, supply exhaustion --- contracts/Cargo.toml | 1 + contracts/voucher-minter/Cargo.toml | 14 ++ contracts/voucher-minter/src/lib.rs | 228 ++++++++++++++++++++++++ contracts/voucher-minter/src/storage.rs | 39 ++++ contracts/voucher-minter/src/test.rs | 185 +++++++++++++++++++ contracts/voucher-minter/src/types.rs | 57 ++++++ 6 files changed, 524 insertions(+) create mode 100644 contracts/voucher-minter/Cargo.toml create mode 100644 contracts/voucher-minter/src/lib.rs create mode 100644 contracts/voucher-minter/src/storage.rs create mode 100644 contracts/voucher-minter/src/test.rs create mode 100644 contracts/voucher-minter/src/types.rs diff --git a/contracts/Cargo.toml b/contracts/Cargo.toml index 6a00bd4a..77db3733 100644 --- a/contracts/Cargo.toml +++ b/contracts/Cargo.toml @@ -109,6 +109,7 @@ members = [ "sponsor-pool", "sponsorship-ledger", "treasury-safeguard", + "voucher-minter", ] exclude = [ diff --git a/contracts/voucher-minter/Cargo.toml b/contracts/voucher-minter/Cargo.toml new file mode 100644 index 00000000..0f148496 --- /dev/null +++ b/contracts/voucher-minter/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "stellarcade-voucher-minter" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +soroban-sdk = "25.1.1" + +[dev-dependencies] +soroban-sdk = { version = "25.1.1", features = ["testutils"] } + +[lib] +crate-type = ["cdylib"] diff --git a/contracts/voucher-minter/src/lib.rs b/contracts/voucher-minter/src/lib.rs new file mode 100644 index 00000000..f114b155 --- /dev/null +++ b/contracts/voucher-minter/src/lib.rs @@ -0,0 +1,228 @@ +//! Stellarcade Voucher Minter Contract +//! +//! Manages voucher issuance with supply tracking and per-voucher claim-expiry. +//! +//! ## Read-only accessors +//! - `issuance_summary(voucher_type_id)` — total issued, remaining supply, paused state. +//! - `claim_expiry(voucher_id)` — expiry ledger, claimed flag, live expiry status. +//! +//! ## Zero-state behaviour +//! Both accessors return `exists = false` with zeroed numeric fields for +//! unknown ids so callers never need to handle a missing-key error. +//! +//! ## Rounding / supply conventions +//! - `remaining` is `u64::MAX` when `max_supply == 0` (uncapped). +//! - `is_expired` is computed against the current ledger sequence at read time. + +#![no_std] +#![allow(unexpected_cfgs)] + +mod storage; +mod types; + +use soroban_sdk::{contract, contracterror, contractimpl, contracttype, Address, Env}; + +pub use types::{ClaimExpiry, IssuanceSummary, VoucherRecord, VoucherTypeRecord}; + +// --------------------------------------------------------------------------- +// Storage keys +// --------------------------------------------------------------------------- + +#[contracttype] +#[derive(Clone)] +pub enum DataKey { + Admin, + VoucherType(u32), + Voucher(u64), +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +#[contracterror] +#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] +#[repr(u32)] +pub enum Error { + AlreadyInitialized = 1, + NotInitialized = 2, + NotAuthorized = 3, + SupplyExhausted = 4, + VoucherTypePaused = 5, + VoucherTypeNotFound = 6, +} + +// --------------------------------------------------------------------------- +// Contract +// --------------------------------------------------------------------------- + +#[contract] +pub struct VoucherMinter; + +#[contractimpl] +impl VoucherMinter { + /// Initialize the contract. May only be called once. + pub fn init(env: Env, admin: Address) -> Result<(), Error> { + if env.storage().instance().has(&DataKey::Admin) { + return Err(Error::AlreadyInitialized); + } + admin.require_auth(); + env.storage().instance().set(&DataKey::Admin, &admin); + Ok(()) + } + + /// Define or update a voucher type. Admin only. + /// + /// `max_supply = 0` means uncapped. Existing `total_issued` is preserved + /// on update so supply counters remain consistent. + pub fn upsert_voucher_type( + env: Env, + admin: Address, + type_id: u32, + max_supply: u64, + paused: bool, + ) -> Result<(), Error> { + require_admin(&env, &admin)?; + let total_issued = storage::get_voucher_type(&env, type_id) + .map(|r| r.total_issued) + .unwrap_or(0); + storage::set_voucher_type( + &env, + type_id, + &VoucherTypeRecord { + max_supply, + total_issued, + paused, + }, + ); + Ok(()) + } + + /// Issue a voucher instance. Admin only. + /// + /// Increments `total_issued` on the parent type and writes the per-voucher + /// record. Fails if the type is paused or supply is exhausted. + pub fn issue_voucher( + env: Env, + admin: Address, + voucher_id: u64, + type_id: u32, + expires_at_ledger: u32, + ) -> Result<(), Error> { + require_admin(&env, &admin)?; + let mut vtype = storage::get_voucher_type(&env, type_id) + .ok_or(Error::VoucherTypeNotFound)?; + if vtype.paused { + return Err(Error::VoucherTypePaused); + } + if vtype.max_supply > 0 && vtype.total_issued >= vtype.max_supply { + return Err(Error::SupplyExhausted); + } + vtype.total_issued = vtype.total_issued.saturating_add(1); + storage::set_voucher_type(&env, type_id, &vtype); + storage::set_voucher( + &env, + voucher_id, + &VoucherRecord { + voucher_type_id: type_id, + expires_at_ledger, + claimed: false, + }, + ); + Ok(()) + } + + /// Mark a voucher as claimed. Admin only. + pub fn claim_voucher(env: Env, admin: Address, voucher_id: u64) -> Result<(), Error> { + require_admin(&env, &admin)?; + let mut record = storage::get_voucher(&env, voucher_id) + .ok_or(Error::VoucherTypeNotFound)?; + record.claimed = true; + storage::set_voucher(&env, voucher_id, &record); + Ok(()) + } + + /// Return an issuance summary for `voucher_type_id`. + /// + /// Unknown type ids return `exists = false` with zeroed fields. + /// `remaining` is `u64::MAX` when the type is uncapped (`max_supply == 0`). + pub fn issuance_summary(env: Env, voucher_type_id: u32) -> IssuanceSummary { + match storage::get_voucher_type(&env, voucher_type_id) { + Some(record) => { + let remaining = if record.max_supply == 0 { + u64::MAX + } else { + record.max_supply.saturating_sub(record.total_issued) + }; + IssuanceSummary { + voucher_type_id, + exists: true, + total_issued: record.total_issued, + max_supply: record.max_supply, + remaining, + paused: record.paused, + } + } + None => IssuanceSummary { + voucher_type_id, + exists: false, + total_issued: 0, + max_supply: 0, + remaining: 0, + paused: false, + }, + } + } + + /// Return claim-expiry details for `voucher_id`. + /// + /// Unknown voucher ids return `exists = false` with zeroed fields. + /// `is_expired` is computed against the current ledger sequence at read + /// time: `current_ledger >= expires_at_ledger`. + pub fn claim_expiry(env: Env, voucher_id: u64) -> ClaimExpiry { + match storage::get_voucher(&env, voucher_id) { + Some(record) => { + let current_ledger = env.ledger().sequence(); + let is_expired = current_ledger >= record.expires_at_ledger; + ClaimExpiry { + voucher_id, + exists: true, + expires_at_ledger: record.expires_at_ledger, + claimed: record.claimed, + is_expired, + } + } + None => ClaimExpiry { + voucher_id, + exists: false, + expires_at_ledger: 0, + claimed: false, + is_expired: false, + }, + } + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn require_admin(env: &Env, caller: &Address) -> Result<(), Error> { + let admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(Error::NotInitialized)?; + caller.require_auth(); + if caller != &admin { + return Err(Error::NotAuthorized); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod test; diff --git a/contracts/voucher-minter/src/storage.rs b/contracts/voucher-minter/src/storage.rs new file mode 100644 index 00000000..bfdbde93 --- /dev/null +++ b/contracts/voucher-minter/src/storage.rs @@ -0,0 +1,39 @@ +use soroban_sdk::Env; + +use crate::{DataKey, types::{VoucherRecord, VoucherTypeRecord}}; + +pub const PERSISTENT_BUMP_LEDGERS: u32 = 518_400; + +pub fn get_voucher_type(env: &Env, type_id: u32) -> Option { + env.storage() + .persistent() + .get(&DataKey::VoucherType(type_id)) +} + +pub fn set_voucher_type(env: &Env, type_id: u32, record: &VoucherTypeRecord) { + env.storage() + .persistent() + .set(&DataKey::VoucherType(type_id), record); + env.storage().persistent().extend_ttl( + &DataKey::VoucherType(type_id), + PERSISTENT_BUMP_LEDGERS, + PERSISTENT_BUMP_LEDGERS, + ); +} + +pub fn get_voucher(env: &Env, voucher_id: u64) -> Option { + env.storage() + .persistent() + .get(&DataKey::Voucher(voucher_id)) +} + +pub fn set_voucher(env: &Env, voucher_id: u64, record: &VoucherRecord) { + env.storage() + .persistent() + .set(&DataKey::Voucher(voucher_id), record); + env.storage().persistent().extend_ttl( + &DataKey::Voucher(voucher_id), + PERSISTENT_BUMP_LEDGERS, + PERSISTENT_BUMP_LEDGERS, + ); +} diff --git a/contracts/voucher-minter/src/test.rs b/contracts/voucher-minter/src/test.rs new file mode 100644 index 00000000..ef71837d --- /dev/null +++ b/contracts/voucher-minter/src/test.rs @@ -0,0 +1,185 @@ +#![cfg(test)] + +use soroban_sdk::{testutils::{Address as _, Ledger as _}, Address, Env}; + +use super::*; + +fn setup(env: &Env) -> (VoucherMinterClient<'_>, Address) { + let admin = Address::generate(env); + let contract_id = env.register(VoucherMinter, ()); + let client = VoucherMinterClient::new(env, &contract_id); + env.mock_all_auths(); + client.init(&admin); + (client, admin) +} + +// --------------------------------------------------------------------------- +// issuance_summary — success path +// --------------------------------------------------------------------------- + +#[test] +fn test_issuance_summary_success() { + let env = Env::default(); + let (client, admin) = setup(&env); + + client.upsert_voucher_type(&admin, &1, &1000, &false); + client.issue_voucher(&admin, &101, &1, &5_000_000); + client.issue_voucher(&admin, &102, &1, &5_000_000); + + let summary = client.issuance_summary(&1); + assert!(summary.exists); + assert_eq!(summary.voucher_type_id, 1); + assert_eq!(summary.total_issued, 2); + assert_eq!(summary.max_supply, 1000); + assert_eq!(summary.remaining, 998); + assert!(!summary.paused); +} + +// --------------------------------------------------------------------------- +// issuance_summary — uncapped type +// --------------------------------------------------------------------------- + +#[test] +fn test_issuance_summary_uncapped() { + let env = Env::default(); + let (client, admin) = setup(&env); + + client.upsert_voucher_type(&admin, &2, &0, &false); + + let summary = client.issuance_summary(&2); + assert!(summary.exists); + assert_eq!(summary.max_supply, 0); + assert_eq!(summary.remaining, u64::MAX); +} + +// --------------------------------------------------------------------------- +// issuance_summary — paused type +// --------------------------------------------------------------------------- + +#[test] +fn test_issuance_summary_paused() { + let env = Env::default(); + let (client, admin) = setup(&env); + + client.upsert_voucher_type(&admin, &3, &500, &true); + + let summary = client.issuance_summary(&3); + assert!(summary.exists); + assert!(summary.paused); + + // Issuing against a paused type must fail + let result = client.try_issue_voucher(&admin, &201, &3, &9_000_000); + assert!(result.is_err()); +} + +// --------------------------------------------------------------------------- +// issuance_summary — missing type returns zero-state +// --------------------------------------------------------------------------- + +#[test] +fn test_issuance_summary_missing() { + let env = Env::default(); + let (client, _) = setup(&env); + + let summary = client.issuance_summary(&999); + assert!(!summary.exists); + assert_eq!(summary.total_issued, 0); + assert_eq!(summary.max_supply, 0); + assert_eq!(summary.remaining, 0); + assert!(!summary.paused); +} + +// --------------------------------------------------------------------------- +// claim_expiry — success path (not yet expired) +// --------------------------------------------------------------------------- + +#[test] +fn test_claim_expiry_not_expired() { + let env = Env::default(); + env.ledger().set_sequence_number(100); + let (client, admin) = setup(&env); + + client.upsert_voucher_type(&admin, &1, &0, &false); + client.issue_voucher(&admin, &301, &1, &200); + + let expiry = client.claim_expiry(&301); + assert!(expiry.exists); + assert_eq!(expiry.voucher_id, 301); + assert_eq!(expiry.expires_at_ledger, 200); + assert!(!expiry.claimed); + assert!(!expiry.is_expired); +} + +// --------------------------------------------------------------------------- +// claim_expiry — expired voucher +// --------------------------------------------------------------------------- + +#[test] +fn test_claim_expiry_expired() { + let env = Env::default(); + env.ledger().set_sequence_number(300); + let (client, admin) = setup(&env); + + client.upsert_voucher_type(&admin, &1, &0, &false); + client.issue_voucher(&admin, &302, &1, &200); + + let expiry = client.claim_expiry(&302); + assert!(expiry.exists); + assert!(expiry.is_expired); + assert!(!expiry.claimed); +} + +// --------------------------------------------------------------------------- +// claim_expiry — claimed voucher +// --------------------------------------------------------------------------- + +#[test] +fn test_claim_expiry_claimed() { + let env = Env::default(); + env.ledger().set_sequence_number(100); + let (client, admin) = setup(&env); + + client.upsert_voucher_type(&admin, &1, &0, &false); + client.issue_voucher(&admin, &303, &1, &500); + client.claim_voucher(&admin, &303); + + let expiry = client.claim_expiry(&303); + assert!(expiry.exists); + assert!(expiry.claimed); + assert!(!expiry.is_expired); +} + +// --------------------------------------------------------------------------- +// claim_expiry — missing voucher returns zero-state +// --------------------------------------------------------------------------- + +#[test] +fn test_claim_expiry_missing() { + let env = Env::default(); + let (client, _) = setup(&env); + + let expiry = client.claim_expiry(&9999); + assert!(!expiry.exists); + assert_eq!(expiry.expires_at_ledger, 0); + assert!(!expiry.claimed); + assert!(!expiry.is_expired); +} + +// --------------------------------------------------------------------------- +// supply exhaustion guard +// --------------------------------------------------------------------------- + +#[test] +fn test_supply_exhausted() { + let env = Env::default(); + let (client, admin) = setup(&env); + + client.upsert_voucher_type(&admin, &1, &1, &false); + client.issue_voucher(&admin, &401, &1, &1_000_000); + + let result = client.try_issue_voucher(&admin, &402, &1, &1_000_000); + assert!(result.is_err()); + + let summary = client.issuance_summary(&1); + assert_eq!(summary.remaining, 0); +} diff --git a/contracts/voucher-minter/src/types.rs b/contracts/voucher-minter/src/types.rs new file mode 100644 index 00000000..d784404b --- /dev/null +++ b/contracts/voucher-minter/src/types.rs @@ -0,0 +1,57 @@ +use soroban_sdk::contracttype; + +/// Summary of issuance activity for a voucher type. +/// +/// Returned by `issuance_summary`. When the voucher type has not been +/// configured, `exists` is `false` and all numeric fields are zero. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct IssuanceSummary { + pub voucher_type_id: u32, + /// `true` when the voucher type exists in storage. + pub exists: bool, + /// Total vouchers issued so far. + pub total_issued: u64, + /// Maximum vouchers that may be issued (0 = uncapped). + pub max_supply: u64, + /// Remaining issuable vouchers. `u64::MAX` when uncapped. + pub remaining: u64, + /// Whether new issuance is currently paused. + pub paused: bool, +} + +/// Claim-expiry details for a specific voucher instance. +/// +/// Returned by `claim_expiry`. When the voucher id is unknown, `exists` is +/// `false` and timing fields are zero. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ClaimExpiry { + pub voucher_id: u64, + /// `true` when the voucher id exists in storage. + pub exists: bool, + /// Ledger sequence after which the voucher can no longer be claimed. + pub expires_at_ledger: u32, + /// Whether the voucher has already been claimed. + pub claimed: bool, + /// Whether the voucher is currently expired (based on current ledger). + pub is_expired: bool, +} + +/// Persistent voucher-type record. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VoucherTypeRecord { + pub max_supply: u64, + pub total_issued: u64, + pub paused: bool, +} + +/// Persistent per-voucher record. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VoucherRecord { + pub voucher_type_id: u32, + pub expires_at_ledger: u32, + pub claimed: bool, +}