From ae72a5e3fcf1b90a382de8c6badf26949b5b0ce5 Mon Sep 17 00:00:00 2001 From: Coredevjay Date: Sun, 29 Mar 2026 00:18:56 +0100 Subject: [PATCH 1/2] docs(contract): add top-level //! doc comments to lib.rs Adds module-level documentation covering Overview, Architecture, and Usage sections as requested in issue #323. Explains the EventRegistry module structure, key data types, storage strategy, and how EventRegistry interacts with the TicketPayment contract for inventory management during purchase and refund flows. --- contract/contracts/event_registry/src/lib.rs | 85 ++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/contract/contracts/event_registry/src/lib.rs b/contract/contracts/event_registry/src/lib.rs index eb73a41..f436c25 100644 --- a/contract/contracts/event_registry/src/lib.rs +++ b/contract/contracts/event_registry/src/lib.rs @@ -1,3 +1,88 @@ +//! # Event Registry Contract +//! +//! ## Overview +//! +//! The `event-registry` crate is the central on-chain registry for the Agora Events platform, +//! deployed on the [Soroban](https://soroban.stellar.org) smart-contract runtime on Stellar. +//! It is the single source of truth for every event that exists on the platform: creation, +//! status transitions, inventory tracking, organizer management, and fee configuration all +//! live here. +//! +//! ## Architecture +//! +//! The crate is organised into five focused modules: +//! +//! | Module | Responsibility | +//! |--------|---------------| +//! | [`lib`](crate) | Public contract entry-points exposed via `#[contractimpl]` | +//! | [`types`] | All `#[contracttype]` structs and enums shared across modules | +//! | [`storage`] | Thin wrappers around `env.storage()` – one function per data key | +//! | [`events`] | Soroban event structs and the [`AgoraEvent`](crate::events::AgoraEvent) topic enum | +//! | [`error`] | The [`EventRegistryError`](crate::error::EventRegistryError) enum returned by every fallible function | +//! +//! ### Key data types +//! +//! * [`EventInfo`](crate::types::EventInfo) – the full on-chain record for a registered event, +//! including tiered pricing ([`TicketTier`](crate::types::TicketTier)), supply counters, +//! milestone plans, and status flags. +//! * [`MultiSigConfig`](crate::types::MultiSigConfig) – multi-admin governance configuration +//! with a configurable approval threshold. +//! * [`OrganizerStake`](crate::types::OrganizerStake) – collateral staked by organizers to +//! unlock *Verified* status and earn proportional staking rewards. +//! * [`GuestProfile`](crate::types::GuestProfile) – per-attendee loyalty score and spend history. +//! * [`SeriesRegistry`](crate::types::SeriesRegistry) / [`SeriesPass`](crate::types::SeriesPass) – +//! grouping of related events into a series with reusable season passes. +//! +//! ### Storage strategy +//! +//! All state is kept in **persistent** storage so that it survives ledger expiry. Large +//! per-organizer lists (event IDs, receipts) are sharded into fixed-size buckets of 50 entries +//! each to stay within Soroban's per-entry size limits. +//! +//! ## Usage +//! +//! ### Initialisation +//! +//! The contract must be initialised exactly once by calling [`EventRegistry::initialize`]: +//! +//! ```text +//! EventRegistry::initialize(env, admin, platform_wallet, platform_fee_bps, usdc_token) +//! ``` +//! +//! This sets the admin, platform wallet, default fee rate (in basis points), and automatically +//! whitelists the provided USDC token for payments. +//! +//! ### Registering an event +//! +//! Organizers call [`EventRegistry::register_event`] with an +//! [`EventRegistrationArgs`](crate::types::EventRegistrationArgs) struct that bundles the event +//! ID, payment address, IPFS metadata CID, supply cap, and one or more +//! [`TicketTier`](crate::types::TicketTier) entries. +//! +//! ### Interaction with TicketPayment +//! +//! `EventRegistry` and the companion `TicketPayment` contract work together to process ticket +//! sales while keeping inventory consistent: +//! +//! 1. **Registration** – the platform admin calls +//! [`EventRegistry::set_ticket_payment_contract`] once to record the `TicketPayment` +//! contract address on-chain. +//! 2. **Purchase flow** – when a buyer purchases a ticket, `TicketPayment` handles the token +//! transfer and fee split, then calls [`EventRegistry::increment_inventory`] to atomically +//! increment the per-tier and global supply counters. `EventRegistry` enforces that only +//! the registered `TicketPayment` address may call this function +//! (`ticket_payment_addr.require_auth()`), preventing unauthorised supply manipulation. +//! 3. **Refund flow** – when a refund is approved, `TicketPayment` calls +//! [`EventRegistry::decrement_inventory`] to roll back the supply counters, again gated +//! behind the same address check. +//! 4. **Payment info** – `TicketPayment` can query [`EventRegistry::get_event_payment_info`] +//! to retrieve the current fee rates and tier pricing for a given event before processing +//! a payment. +//! +//! This separation of concerns means `EventRegistry` never touches tokens directly; all +//! financial logic lives in `TicketPayment`, while `EventRegistry` remains the authoritative +//! registry for event state and inventory. + #![no_std] use crate::events::{ From 7017be640d433865c9fd1298563c3142b4752db5 Mon Sep 17 00:00:00 2001 From: Coredevjay Date: Sun, 29 Mar 2026 00:23:49 +0100 Subject: [PATCH 2/2] test(contract): add zero-price free ticket unit tests (#322) Adds test_free_ticket.rs covering the full free-ticket (price=0) surface: - increment_inventory succeeds and updates current_supply + current_sold - global tickets-sold counter is incremented - no token-transfer calls are made (registry never touches tokens) - bulk quantity purchase (qty > 1) works correctly - tier_limit cap is enforced for free tiers - max_supply cap is enforced for free tiers - decrement_inventory (refund path) rolls back supply counters - quantity=0 is rejected with InvalidQuantity - unauthorized caller is rejected (should_panic) --- contract/contracts/event_registry/src/lib.rs | 3 + .../event_registry/src/test_free_ticket.rs | 302 ++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 contract/contracts/event_registry/src/test_free_ticket.rs diff --git a/contract/contracts/event_registry/src/lib.rs b/contract/contracts/event_registry/src/lib.rs index f436c25..ca18236 100644 --- a/contract/contracts/event_registry/src/lib.rs +++ b/contract/contracts/event_registry/src/lib.rs @@ -1913,3 +1913,6 @@ mod test_e2e; #[cfg(test)] mod test_multisig; + +#[cfg(test)] +mod test_free_ticket; diff --git a/contract/contracts/event_registry/src/test_free_ticket.rs b/contract/contracts/event_registry/src/test_free_ticket.rs new file mode 100644 index 0000000..fb888f3 --- /dev/null +++ b/contract/contracts/event_registry/src/test_free_ticket.rs @@ -0,0 +1,302 @@ +//! # Free Ticket (Zero-Price) Unit Tests +//! +//! Covers issue #322: verify that the inventory system handles free tickets +//! (price = 0) correctly. +//! +//! Key invariants checked: +//! * `increment_inventory` succeeds for a zero-price tier. +//! * `current_supply` and per-tier `current_sold` are incremented correctly. +//! * The global tickets-sold counter is updated. +//! * No token-transfer calls are made (the registry never touches tokens). +//! * Capacity limits still apply even when the price is zero. +//! * `decrement_inventory` (refund path) works for a zero-price tier. +//! * Purchasing quantity > 1 in a single call works for a free tier. + +use super::*; +use crate::error::EventRegistryError; +use crate::types::{EventRegistrationArgs, TicketTier}; +use soroban_sdk::{testutils::Address as _, Address, Env, Map, String}; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +const VALID_CID: &str = "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"; +const FREE_TIER_ID: &str = "free"; + +fn setup(env: &Env) -> (EventRegistryClient<'static>, Address) { + let contract_id = env.register(EventRegistry, ()); + let client = EventRegistryClient::new(env, &contract_id); + let admin = Address::generate(env); + let platform_wallet = Address::generate(env); + let usdc_token = Address::generate(env); + client.initialize(&admin, &platform_wallet, &500, &usdc_token); + (client, admin) +} + +/// Register a ticket-payment contract and return its address. +fn register_ticket_payment(env: &Env, client: &EventRegistryClient) -> Address { + let tp_addr = Address::generate(env); + client.set_ticket_payment_contract(&tp_addr); + tp_addr +} + +/// Build a single free tier (price = 0) with the given limit. +fn free_tier(env: &Env, limit: i128) -> Map { + let mut tiers = Map::new(env); + tiers.set( + String::from_str(env, FREE_TIER_ID), + TicketTier { + name: String::from_str(env, "Free Admission"), + price: 0, + tier_limit: limit, + current_sold: 0, + is_refundable: true, + auction_config: soroban_sdk::vec![env], + }, + ); + tiers +} + +/// Register an event with a free tier and return its event_id string. +fn register_free_event( + env: &Env, + client: &EventRegistryClient, + organizer: &Address, + event_id: &str, + max_supply: i128, + tier_limit: i128, +) -> String { + let id = String::from_str(env, event_id); + client.register_event(&EventRegistrationArgs { + event_id: id.clone(), + organizer_address: organizer.clone(), + payment_address: Address::generate(env), + metadata_cid: String::from_str(env, VALID_CID), + max_supply, + milestone_plan: None, + tiers: free_tier(env, tier_limit), + refund_deadline: 0, + restocking_fee: 0, + resale_cap_bps: None, + min_sales_target: None, + target_deadline: None, + banner_cid: None, + tags: None, + }); + id +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +/// A single free-ticket purchase increments both the per-tier counter and the +/// event-level `current_supply`. +#[test] +fn test_free_ticket_increments_inventory() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _admin) = setup(&env); + let organizer = Address::generate(&env); + let _tp = register_ticket_payment(&env, &client); + + let event_id = register_free_event(&env, &client, &organizer, "free_evt_1", 100, 100); + let tier_id = String::from_str(&env, FREE_TIER_ID); + + // Before purchase + let before = client.get_event(&event_id).unwrap(); + assert_eq!(before.current_supply, 0); + assert_eq!( + before.tiers.get(tier_id.clone()).unwrap().current_sold, + 0 + ); + + // Simulate TicketPayment calling increment_inventory for qty = 1 + client.increment_inventory(&event_id, &tier_id, &1); + + // After purchase + let after = client.get_event(&event_id).unwrap(); + assert_eq!(after.current_supply, 1); + assert_eq!(after.tiers.get(tier_id).unwrap().current_sold, 1); +} + +/// The global tickets-sold counter is updated after a free-ticket purchase. +#[test] +fn test_free_ticket_updates_global_counter() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _admin) = setup(&env); + let organizer = Address::generate(&env); + let _tp = register_ticket_payment(&env, &client); + + let event_id = register_free_event(&env, &client, &organizer, "free_evt_global", 50, 50); + let tier_id = String::from_str(&env, FREE_TIER_ID); + + let before = client.get_global_tickets_sold(); + client.increment_inventory(&event_id, &tier_id, &1); + let after = client.get_global_tickets_sold(); + + assert_eq!(after, before + 1); +} + +/// The registry never calls token-transfer functions — verified by confirming +/// no token contract is invoked during a free-ticket increment. Because +/// `EventRegistry` never holds a token client, the absence of any token +/// address in the contract's storage after the call is sufficient evidence. +#[test] +fn test_free_ticket_no_token_transfer() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _admin) = setup(&env); + let organizer = Address::generate(&env); + let _tp = register_ticket_payment(&env, &client); + + let event_id = register_free_event(&env, &client, &organizer, "free_evt_no_transfer", 10, 10); + let tier_id = String::from_str(&env, FREE_TIER_ID); + + // increment_inventory must succeed without any token interaction. + // If the contract attempted a zero-value transfer it would panic because + // no token contract is deployed in this test environment. + client.increment_inventory(&event_id, &tier_id, &1); + + // Confirm supply was updated — proving the call completed successfully. + let info = client.get_event(&event_id).unwrap(); + assert_eq!(info.current_supply, 1); +} + +/// Purchasing multiple free tickets in a single call (quantity > 1) works. +#[test] +fn test_free_ticket_bulk_purchase() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _admin) = setup(&env); + let organizer = Address::generate(&env); + let _tp = register_ticket_payment(&env, &client); + + let event_id = register_free_event(&env, &client, &organizer, "free_evt_bulk", 200, 200); + let tier_id = String::from_str(&env, FREE_TIER_ID); + + client.increment_inventory(&event_id, &tier_id, &5); + + let info = client.get_event(&event_id).unwrap(); + assert_eq!(info.current_supply, 5); + assert_eq!(info.tiers.get(tier_id).unwrap().current_sold, 5); +} + +/// Capacity limits are enforced even when the ticket price is zero. +#[test] +fn test_free_ticket_respects_tier_limit() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _admin) = setup(&env); + let organizer = Address::generate(&env); + let _tp = register_ticket_payment(&env, &client); + + // Tier limit = 2 + let event_id = register_free_event(&env, &client, &organizer, "free_evt_cap", 10, 2); + let tier_id = String::from_str(&env, FREE_TIER_ID); + + client.increment_inventory(&event_id, &tier_id, &1); + client.increment_inventory(&event_id, &tier_id, &1); + + // Third ticket must be rejected + let result = client.try_increment_inventory(&event_id, &tier_id, &1); + assert_eq!(result, Err(Ok(EventRegistryError::TierSupplyExceeded))); +} + +/// max_supply cap is enforced for free tickets when set. +#[test] +fn test_free_ticket_respects_max_supply() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _admin) = setup(&env); + let organizer = Address::generate(&env); + let _tp = register_ticket_payment(&env, &client); + + // max_supply = 1, tier_limit = 100 + let event_id = register_free_event(&env, &client, &organizer, "free_evt_maxsup", 1, 100); + let tier_id = String::from_str(&env, FREE_TIER_ID); + + client.increment_inventory(&event_id, &tier_id, &1); + + let result = client.try_increment_inventory(&event_id, &tier_id, &1); + assert_eq!(result, Err(Ok(EventRegistryError::MaxSupplyExceeded))); +} + +/// Refunding a free ticket (decrement_inventory) rolls back the supply counters. +#[test] +fn test_free_ticket_decrement_on_refund() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _admin) = setup(&env); + let organizer = Address::generate(&env); + let _tp = register_ticket_payment(&env, &client); + + let event_id = register_free_event(&env, &client, &organizer, "free_evt_refund", 50, 50); + let tier_id = String::from_str(&env, FREE_TIER_ID); + + client.increment_inventory(&event_id, &tier_id, &1); + + let after_purchase = client.get_event(&event_id).unwrap(); + assert_eq!(after_purchase.current_supply, 1); + + client.decrement_inventory(&event_id, &tier_id); + + let after_refund = client.get_event(&event_id).unwrap(); + assert_eq!(after_refund.current_supply, 0); + assert_eq!( + after_refund.tiers.get(tier_id).unwrap().current_sold, + 0 + ); +} + +/// Calling increment_inventory with quantity = 0 is rejected. +#[test] +fn test_free_ticket_zero_quantity_rejected() { + let env = Env::default(); + env.mock_all_auths(); + + let (client, _admin) = setup(&env); + let organizer = Address::generate(&env); + let _tp = register_ticket_payment(&env, &client); + + let event_id = register_free_event(&env, &client, &organizer, "free_evt_qty0", 50, 50); + let tier_id = String::from_str(&env, FREE_TIER_ID); + + let result = client.try_increment_inventory(&event_id, &tier_id, &0); + assert_eq!(result, Err(Ok(EventRegistryError::InvalidQuantity))); +} + +/// Only the registered TicketPayment contract may call increment_inventory. +#[test] +#[should_panic] +fn test_free_ticket_unauthorized_caller_rejected() { + let env = Env::default(); + // Do NOT mock_all_auths — auth will fail for the wrong caller + let contract_id = env.register(EventRegistry, ()); + let client = EventRegistryClient::new(&env, &contract_id); + let admin = Address::generate(&env); + let platform_wallet = Address::generate(&env); + let usdc_token = Address::generate(&env); + + env.mock_all_auths_allowing_non_root_auth(); + client.initialize(&admin, &platform_wallet, &500, &usdc_token); + + let organizer = Address::generate(&env); + let tp_addr = Address::generate(&env); + client.set_ticket_payment_contract(&tp_addr); + + let event_id = register_free_event(&env, &client, &organizer, "free_evt_unauth", 50, 50); + let tier_id = String::from_str(&env, FREE_TIER_ID); + + // No auth mocked for tp_addr — should panic + client.increment_inventory(&event_id, &tier_id, &1); +}