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
88 changes: 88 additions & 0 deletions contract/contracts/event_registry/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand Down Expand Up @@ -1828,3 +1913,6 @@ mod test_e2e;

#[cfg(test)]
mod test_multisig;

#[cfg(test)]
mod test_free_ticket;
302 changes: 302 additions & 0 deletions contract/contracts/event_registry/src/test_free_ticket.rs
Original file line number Diff line number Diff line change
@@ -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<String, TicketTier> {
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);
}
Loading