From 171518b9c7849cd25a3eec499bfcd31152a8b34a Mon Sep 17 00:00:00 2001 From: Bhenzdizma Date: Fri, 19 Jun 2026 14:42:14 +0100 Subject: [PATCH] finalize_event: rank, split, and pay out the prize pool --- contracts/creator-event-manager/src/event.rs | 8 + .../creator-event-manager/src/finalize.rs | 186 +++++++ contracts/creator-event-manager/src/lib.rs | 44 ++ .../src/storage_types.rs | 4 + .../tests/finalize_tests.rs | 455 ++++++++++++++++++ 5 files changed, 697 insertions(+) create mode 100644 contracts/creator-event-manager/src/finalize.rs create mode 100644 contracts/creator-event-manager/tests/finalize_tests.rs diff --git a/contracts/creator-event-manager/src/event.rs b/contracts/creator-event-manager/src/event.rs index 185312ae..f5923097 100644 --- a/contracts/creator-event-manager/src/event.rs +++ b/contracts/creator-event-manager/src/event.rs @@ -45,6 +45,14 @@ pub enum EventError { InvalidRewardDistribution = 14, /// Creator's XLM balance is below the requested prize_pool. InsufficientPrizePoolFunds = 15, + /// finalize_event called before the event's end_time has passed. + EventNotEnded = 16, + /// finalize_event called while at least one match is still unresolved. + MatchesNotComplete = 17, + /// finalize_event called on an event that has already been finalized. + AlreadyFinalized = 18, + /// Operation rejected because the event has been cancelled. + EventCancelled = 19, } impl From for EventError { diff --git a/contracts/creator-event-manager/src/finalize.rs b/contracts/creator-event-manager/src/finalize.rs new file mode 100644 index 00000000..044ce822 --- /dev/null +++ b/contracts/creator-event-manager/src/finalize.rs @@ -0,0 +1,186 @@ +//! Prize-pool finalization (#... finalize_event). +//! +//! Once an event has ended and every match is resolved, [`finalize_event`] +//! ranks participants, splits the escrowed prize pool according to the event's +//! `reward_distribution`, and pays the top-N addresses. It is **permissionless**: +//! anyone may call it (it simply triggers the payout once all conditions are +//! met), mirroring the old `verify_event_winners` entry point. + +use soroban_sdk::{Address, Env, Symbol, Vec}; + +use crate::admin; +use crate::event::{self, EventError}; +use crate::leaderboard; +use crate::storage::{self, TTL_LEDGERS}; +use crate::storage_types::DataKey; +use crate::token::TokenHelper; + +// --------------------------------------------------------------------------- +// finalize_event +// --------------------------------------------------------------------------- + +/// Rank participants, split the prize pool, and pay out the top-N addresses. +/// +/// `caller.require_auth()` is enforced but the call is otherwise permissionless: +/// anyone may finalize an event once its conditions are met. +/// +/// # Checks (in order) +/// 1. Contract not paused ([`EventError::Paused`]). +/// 2. Event exists ([`EventError::EventNotFound`]). +/// 3. Event not cancelled ([`EventError::EventCancelled`]). +/// 4. Event not already finalized ([`EventError::AlreadyFinalized`]). +/// 5. Event has ended — `now >= end_time` ([`EventError::EventNotEnded`]). +/// 6. Every match resolved — each match's `result_submitted == true` +/// ([`EventError::MatchesNotComplete`]). +/// +/// # Payout +/// The leaderboard ([`leaderboard::get_event_leaderboard`]) is fully +/// deterministic (points → exact_scores → earliest prediction → address), so +/// there are **no shared ranks**: every participant has a distinct rank and +/// therefore a distinct (possibly zero) payout. There is intentionally no +/// "split the rank" logic here — determinism is handled upstream. +/// +/// For each paid rank `i` in `0..n.min(leaderboard.len())` (where +/// `n = reward_distribution.len()`): +/// `amount = prize_pool * reward_distribution[i] / 100`, transferred to +/// `leaderboard[i].user`. +/// +/// Any leftover — the unallocated percentage when there are fewer participants +/// than reward ranks, plus integer-division dust — is sent to `event.creator` +/// in a single transfer (`prize_pool - total_distributed`). With zero +/// participants the entire prize pool is refunded to the creator. After this +/// call no XLM is left stranded in the contract. +/// +/// On success the event is marked `is_finalized`, the payout vector is stored +/// under [`DataKey::EventPayouts`] for historical queries, a +/// `(event, finalized)` event is emitted with +/// `(event_id, winners_paid, total_distributed)`, and the payout vector is +/// returned. +pub fn finalize_event( + env: &Env, + caller: Address, + event_id: u64, +) -> Result, EventError> { + // Permissionless: anyone may trigger payout, but they must authorize. + caller.require_auth(); + + // 1. Not paused. + if admin::is_paused(env) { + return Err(EventError::Paused); + } + + // 2. Event exists. + let mut event = event::get_event(env, event_id)?; + + // 3. Not cancelled. + if event.is_cancelled { + return Err(EventError::EventCancelled); + } + + // 4. Not already finalized. + if event.is_finalized { + return Err(EventError::AlreadyFinalized); + } + + // 5. Event has ended. + let now = env.ledger().timestamp(); + if !event.has_ended(now) { + return Err(EventError::EventNotEnded); + } + + // 6. Every match resolved. + let match_ids = storage::get_event_matches(env, event_id); + for match_id in match_ids.iter() { + match storage::get_match(env, match_id) { + Ok(m) => { + if !m.result_submitted { + return Err(EventError::MatchesNotComplete); + } + } + // A missing match record is treated as unresolved. + Err(_) => return Err(EventError::MatchesNotComplete), + } + } + + // Ranked, deterministic leaderboard. The event was already loaded above, so + // the only residual error path here is an (effectively unreachable) points + // overflow; collapse it onto EventNotFound to stay within EventError. + let leaderboard = leaderboard::get_event_leaderboard(env, event_id) + .map_err(|_| EventError::EventNotFound)?; + + let xlm_token = admin::get_xlm_token(env).unwrap_or_else(|| panic!("not_initialized")); + + let prize_pool = event.prize_pool; + let n = event.reward_distribution.len(); + let paid_ranks = n.min(leaderboard.len()); + + let mut payouts: Vec<(Address, i128)> = Vec::new(env); + let mut total_distributed: i128 = 0; + + for i in 0..paid_ranks { + let percent = event.reward_distribution.get(i).unwrap(); + let entry = leaderboard.get(i).unwrap(); + let amount = prize_pool * percent as i128 / 100; + + // Skip zero-value transfers (the token client rejects amount <= 0), but + // still record the rank so the snapshot reflects every paid position. + if amount > 0 { + TokenHelper::distribute_winnings(env, &xlm_token, &entry.user, amount) + .map_err(|_| EventError::TransferFailed)?; + total_distributed += amount; + } + + payouts.push_back((entry.user.clone(), amount)); + } + + // Refund the unallocated percentage + integer-division dust to the creator + // in a single transfer. With zero participants this is the full prize pool. + let refund_to_creator = prize_pool - total_distributed; + if refund_to_creator > 0 { + TokenHelper::distribute_winnings(env, &xlm_token, &event.creator, refund_to_creator) + .map_err(|_| EventError::TransferFailed)?; + } + + // Mark finalized and persist. + event.is_finalized = true; + storage::set_event(env, event_id, &event); + + // Store the payout snapshot for historical queries. + let payouts_key = DataKey::EventPayouts(event_id); + env.storage().persistent().set(&payouts_key, &payouts); + env.storage() + .persistent() + .extend_ttl(&payouts_key, TTL_LEDGERS, TTL_LEDGERS); + + env.events().publish( + (Symbol::new(env, "event"), Symbol::new(env, "finalized")), + (event_id, payouts.len(), total_distributed), + ); + + Ok(payouts) +} + +// --------------------------------------------------------------------------- +// get_event_payouts +// --------------------------------------------------------------------------- + +/// Return the stored payout snapshot for an event. +/// +/// Returns the `Vec<(Address, i128)>` recorded by [`finalize_event`], or an +/// empty vector when the event has not been finalized (or does not exist). +pub fn get_event_payouts(env: &Env, event_id: u64) -> Vec<(Address, i128)> { + let key = DataKey::EventPayouts(event_id); + match env + .storage() + .persistent() + .get::>(&key) + { + Some(payouts) => { + env.storage() + .persistent() + .extend_ttl(&key, TTL_LEDGERS, TTL_LEDGERS); + payouts + } + None => Vec::new(env), + } +} diff --git a/contracts/creator-event-manager/src/lib.rs b/contracts/creator-event-manager/src/lib.rs index 8c363723..e47bec31 100644 --- a/contracts/creator-event-manager/src/lib.rs +++ b/contracts/creator-event-manager/src/lib.rs @@ -3,6 +3,7 @@ pub mod admin; mod event; mod fee; +mod finalize; mod invite; mod leaderboard; pub mod r#match; @@ -639,6 +640,49 @@ impl CreatorEventManagerContract { } } + // ========================================================================= + // Finalization / Payout + // ========================================================================= + + /// Finalize an event: rank participants, split the prize pool, and pay out. + /// + /// Permissionless — anyone may call this once the event has ended and every + /// match is resolved. Ranks participants via the deterministic leaderboard, + /// pays the top-N addresses per `reward_distribution`, refunds any + /// unallocated percentage and integer-division dust to the creator, marks + /// the event finalized, stores a payout snapshot, and emits a + /// `(event, finalized)` event. Returns the `(address, amount)` payout vector. + /// + /// # Panics + /// * `"contract_paused"` — the contract is paused. + /// * `"event_not_found"` — no event exists with the given ID. + /// * `"event_cancelled"` — the event has been cancelled. + /// * `"already_finalized"` — the event was already finalized. + /// * `"event_not_ended"` — current time is before the event's end_time. + /// * `"matches_not_complete"` — at least one match is unresolved. + /// * `"transfer_failed"` — a payout transfer failed. + pub fn finalize_event(env: Env, caller: Address, event_id: u64) -> Vec<(Address, i128)> { + match finalize::finalize_event(&env, caller, event_id) { + Ok(payouts) => payouts, + Err(EventError::Paused) => panic!("contract_paused"), + Err(EventError::EventNotFound) => panic!("event_not_found"), + Err(EventError::EventCancelled) => panic!("event_cancelled"), + Err(EventError::AlreadyFinalized) => panic!("already_finalized"), + Err(EventError::EventNotEnded) => panic!("event_not_ended"), + Err(EventError::MatchesNotComplete) => panic!("matches_not_complete"), + Err(EventError::TransferFailed) => panic!("transfer_failed"), + Err(_) => panic!("unexpected_error"), + } + } + + /// Return the stored prize-pool payout snapshot for a finalized event. + /// + /// Returns the `(address, amount)` vector recorded by `finalize_event`, or + /// an empty vector if the event has not been finalized (or does not exist). + pub fn get_event_payouts(env: Env, event_id: u64) -> Vec<(Address, i128)> { + finalize::get_event_payouts(&env, event_id) + } + /// Get platform-wide statistics. /// /// Returns aggregated statistics including total events, matches, diff --git a/contracts/creator-event-manager/src/storage_types.rs b/contracts/creator-event-manager/src/storage_types.rs index 55a12fee..edc97c9c 100644 --- a/contracts/creator-event-manager/src/storage_types.rs +++ b/contracts/creator-event-manager/src/storage_types.rs @@ -152,6 +152,10 @@ pub enum DataKey { /// Vec
of participants for an event (event_id) EventParticipants(u64), + /// Vec<(Address, i128)> snapshot of prize-pool payouts for a finalized + /// event (event_id). Written once by `finalize_event`. + EventPayouts(u64), + // ── Initialization sentinel ────────────────────────────────────────────── /// Set to `true` once `initialize` has been called; prevents re-init. Initialized, diff --git a/contracts/creator-event-manager/tests/finalize_tests.rs b/contracts/creator-event-manager/tests/finalize_tests.rs new file mode 100644 index 00000000..69471a80 --- /dev/null +++ b/contracts/creator-event-manager/tests/finalize_tests.rs @@ -0,0 +1,455 @@ +/// Tests for `finalize_event`: ranking, prize-pool splitting, and payout. +/// +/// Coverage: +/// - Top-N split paid to winners, verified against real token balances +/// - Rejected before end_time, with unresolved matches, or when called twice +/// - Fewer participants than reward ranks → unused percentage refunded +/// - Zero participants → full refund to creator +/// - Zero prize pool → no transfers, event still marked finalized +/// - Permissionless: a random caller can finalize +use creator_event_manager::storage; +use creator_event_manager::storage_types::MatchResult; +use creator_event_manager::CreatorEventManagerContractClient; +use soroban_sdk::testutils::Address as _; +use soroban_sdk::testutils::Ledger as _; +use soroban_sdk::token::{StellarAssetClient, TokenClient}; +use soroban_sdk::{Address, Env, String, Symbol, Vec}; + +const FEE: i128 = 1_000_000; +const PRIZE: i128 = 10_000_000; + +fn setup() -> ( + Env, + CreatorEventManagerContractClient<'static>, + Address, + Address, + Address, + Address, +) { + let env = Env::default(); + env.mock_all_auths(); + + let contract_id = env.register(creator_event_manager::CreatorEventManagerContract, ()); + let client = CreatorEventManagerContractClient::new(&env, &contract_id); + let client: CreatorEventManagerContractClient<'static> = + unsafe { core::mem::transmute(client) }; + + let admin = Address::generate(&env); + let ai_agent = Address::generate(&env); + let treasury = Address::generate(&env); + let token_admin = Address::generate(&env); + let xlm_token = env + .register_stellar_asset_contract_v2(token_admin) + .address(); + + client.initialize(&admin, &ai_agent, &treasury, &xlm_token, &FEE); + (env, client, contract_id, admin, ai_agent, xlm_token) +} + +fn fund(env: &Env, token: &Address, user: &Address, amount: i128) { + StellarAssetClient::new(env, token).mint(user, &amount); +} + +fn balance(env: &Env, token: &Address, who: &Address) -> i128 { + TokenClient::new(env, token).balance(who) +} + +fn title(env: &Env) -> String { + String::from_str(env, "Test Event") +} + +fn desc(env: &Env) -> String { + String::from_str(env, "Test Description") +} + +/// Create a funded event (prize_pool + reward_distribution) and `num_matches` +/// matches. The creator is funded with exactly `FEE + prize_pool`, so after +/// creation the creator's balance is 0 and the contract escrows the prize pool. +fn create_funded_event( + env: &Env, + contract_id: &Address, + client: &CreatorEventManagerContractClient<'static>, + creator: &Address, + xlm_token: &Address, + prize_pool: i128, + reward_distribution: Vec, + num_matches: u32, +) -> (u64, Symbol, Vec) { + fund(env, xlm_token, creator, FEE + prize_pool); + let start_time = env.ledger().timestamp() + 3600; + let end_time = env.ledger().timestamp() + 7200; + let (event_id, invite_code) = client.create_event( + creator, + &title(env), + &desc(env), + &100u32, + &start_time, + &end_time, + &prize_pool, + &reward_distribution, + ); + + let mut match_ids: Vec = Vec::new(env); + + env.as_contract(contract_id, || { + for i in 0..num_matches { + let match_id = storage::next_match_id(env); + let match_record = creator_event_manager::storage_types::Match::new( + match_id, + event_id, + String::from_str(env, &format!("Team A{}", i)), + String::from_str(env, &format!("Team B{}", i)), + env.ledger().timestamp() + 100 + (i as u64) * 60, + ); + storage::set_match(env, match_id, &match_record); + storage::add_event_match(env, event_id, match_id); + match_ids.push_back(match_id); + + let mut event = storage::get_event(env, event_id).expect("event exists"); + event.add_match(); + storage::set_event(env, event_id, &event); + } + }); + + (event_id, invite_code, match_ids) +} + +fn submit_result( + client: &CreatorEventManagerContractClient<'static>, + ai_agent: &Address, + match_id: u64, + result: MatchResult, +) { + let (home_score, away_score) = match result { + MatchResult::TeamA => (1u32, 0u32), + MatchResult::TeamB => (0u32, 1u32), + MatchResult::Draw => (1u32, 1u32), + }; + client.submit_match_result(ai_agent, &match_id, &home_score, &away_score); +} + +fn reward_dist(env: &Env, percents: &[u32]) -> Vec { + let mut v = Vec::new(env); + for p in percents { + v.push_back(*p); + } + v +} + +// --------------------------------------------------------------------------- +// Happy path: top-5 split +// --------------------------------------------------------------------------- + +#[test] +fn test_finalize_event_distributes_top5_split() { + let (env, client, contract_id, creator, ai_agent, xlm_token) = setup(); + + let dist = reward_dist(&env, &[40, 30, 20, 5, 5]); + let (event_id, invite_code, match_ids) = create_funded_event( + &env, + &contract_id, + &client, + &creator, + &xlm_token, + PRIZE, + dist, + 5, + ); + + // Contract escrows the full prize pool; creator spent everything. + assert_eq!(balance(&env, &xlm_token, &contract_id), PRIZE); + assert_eq!(balance(&env, &xlm_token, &creator), 0); + + // Five participants with strictly decreasing scores (distinct ranks). + // Actual result for every match is TeamA (1-0). An exact 1-0 prediction is + // worth 4 points; a 0-1 prediction is wrong (0 points). + let users = [ + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + Address::generate(&env), + ]; + // user i predicts correctly on the first (5 - i) matches. + for (i, user) in users.iter().enumerate() { + client.join_event(user, &invite_code); + let correct = 5 - i as u32; + for (m, match_id) in match_ids.iter().enumerate() { + if (m as u32) < correct { + client.submit_prediction(user, &match_id, &1u32, &0u32); // exact + } else { + client.submit_prediction(user, &match_id, &0u32, &1u32); // wrong + } + } + } + + // Advance past all match times and the event end_time, then resolve. + env.ledger().set_timestamp(env.ledger().timestamp() + 7300); + for match_id in match_ids.iter() { + submit_result(&client, &ai_agent, match_id, MatchResult::TeamA); + } + + // Permissionless finalize. + let caller = Address::generate(&env); + let payouts = client.finalize_event(&caller, &event_id); + + // Expected per-rank amounts. + let expected = [ + PRIZE * 40 / 100, + PRIZE * 30 / 100, + PRIZE * 20 / 100, + PRIZE * 5 / 100, + PRIZE * 5 / 100, + ]; + + assert_eq!(payouts.len(), 5); + for (i, user) in users.iter().enumerate() { + // Leaderboard order matches the user order (decreasing points). + let (addr, amount) = payouts.get(i as u32).unwrap(); + assert_eq!(addr, *user); + assert_eq!(amount, expected[i]); + assert_eq!(balance(&env, &xlm_token, user), expected[i]); + } + + // Full pool distributed: nothing left in the contract, nothing refunded. + assert_eq!(balance(&env, &xlm_token, &contract_id), 0); + assert_eq!(balance(&env, &xlm_token, &creator), 0); + + // Event marked finalized; snapshot retrievable. + assert!(client.get_event(&event_id).is_finalized); + let snapshot = client.get_event_payouts(&event_id); + assert_eq!(snapshot, payouts); +} + +// --------------------------------------------------------------------------- +// Rejections +// --------------------------------------------------------------------------- + +#[test] +#[should_panic(expected = "event_not_ended")] +fn test_finalize_event_before_end_time_rejected() { + let (env, client, contract_id, creator, _ai_agent, xlm_token) = setup(); + + let dist = reward_dist(&env, &[100]); + let (event_id, _invite, _matches) = create_funded_event( + &env, + &contract_id, + &client, + &creator, + &xlm_token, + PRIZE, + dist, + 1, + ); + + // Time is still well before end_time (7200). + let caller = Address::generate(&env); + client.finalize_event(&caller, &event_id); +} + +#[test] +#[should_panic(expected = "matches_not_complete")] +fn test_finalize_event_with_unresolved_match_rejected() { + let (env, client, contract_id, creator, ai_agent, xlm_token) = setup(); + + let dist = reward_dist(&env, &[100]); + let (event_id, invite_code, match_ids) = create_funded_event( + &env, + &contract_id, + &client, + &creator, + &xlm_token, + PRIZE, + dist, + 2, + ); + + let user = Address::generate(&env); + client.join_event(&user, &invite_code); + for match_id in match_ids.iter() { + client.submit_prediction(&user, &match_id, &1u32, &0u32); + } + + // Past end_time, but only resolve the first of two matches. + env.ledger().set_timestamp(env.ledger().timestamp() + 7300); + submit_result(&client, &ai_agent, match_ids.get(0).unwrap(), MatchResult::TeamA); + + let caller = Address::generate(&env); + client.finalize_event(&caller, &event_id); +} + +#[test] +#[should_panic(expected = "already_finalized")] +fn test_finalize_event_twice_rejected() { + let (env, client, contract_id, creator, ai_agent, xlm_token) = setup(); + + let dist = reward_dist(&env, &[100]); + let (event_id, invite_code, match_ids) = create_funded_event( + &env, + &contract_id, + &client, + &creator, + &xlm_token, + PRIZE, + dist, + 1, + ); + + let user = Address::generate(&env); + client.join_event(&user, &invite_code); + client.submit_prediction(&user, &match_ids.get(0).unwrap(), &1u32, &0u32); + + env.ledger().set_timestamp(env.ledger().timestamp() + 7300); + submit_result(&client, &ai_agent, match_ids.get(0).unwrap(), MatchResult::TeamA); + + let caller = Address::generate(&env); + client.finalize_event(&caller, &event_id); + // Second call must be rejected. + client.finalize_event(&caller, &event_id); +} + +// --------------------------------------------------------------------------- +// Refund scenarios +// --------------------------------------------------------------------------- + +#[test] +fn test_finalize_event_fewer_participants_than_ranks_refunds_creator() { + let (env, client, contract_id, creator, ai_agent, xlm_token) = setup(); + + // 5 reward ranks but only 2 participants. Ranks 3-5 (5 + 5 + ... ) are + // unallocated and refunded to the creator. + let dist = reward_dist(&env, &[40, 30, 20, 5, 5]); + let (event_id, invite_code, match_ids) = create_funded_event( + &env, + &contract_id, + &client, + &creator, + &xlm_token, + PRIZE, + dist, + 1, + ); + + let user1 = Address::generate(&env); + let user2 = Address::generate(&env); + // user1 exact (4 pts), user2 wrong (0 pts) → distinct ranks. + client.join_event(&user1, &invite_code); + client.submit_prediction(&user1, &match_ids.get(0).unwrap(), &1u32, &0u32); + client.join_event(&user2, &invite_code); + client.submit_prediction(&user2, &match_ids.get(0).unwrap(), &0u32, &1u32); + + env.ledger().set_timestamp(env.ledger().timestamp() + 7300); + submit_result(&client, &ai_agent, match_ids.get(0).unwrap(), MatchResult::TeamA); + + let caller = Address::generate(&env); + let payouts = client.finalize_event(&caller, &event_id); + + let rank1 = PRIZE * 40 / 100; + let rank2 = PRIZE * 30 / 100; + let refund = PRIZE - rank1 - rank2; // 30% (ranks 3-5) back to creator + + assert_eq!(payouts.len(), 2); + assert_eq!(balance(&env, &xlm_token, &user1), rank1); + assert_eq!(balance(&env, &xlm_token, &user2), rank2); + assert_eq!(balance(&env, &xlm_token, &creator), refund); + // Nothing stranded. + assert_eq!(balance(&env, &xlm_token, &contract_id), 0); +} + +#[test] +fn test_finalize_event_zero_participants_refunds_full_pool() { + let (env, client, contract_id, creator, _ai_agent, xlm_token) = setup(); + + let dist = reward_dist(&env, &[60, 40]); + let (event_id, _invite, _matches) = create_funded_event( + &env, + &contract_id, + &client, + &creator, + &xlm_token, + PRIZE, + dist, + 0, + ); + + // No participants, no matches. Past end_time. + env.ledger().set_timestamp(env.ledger().timestamp() + 7300); + + let caller = Address::generate(&env); + let payouts = client.finalize_event(&caller, &event_id); + + assert_eq!(payouts.len(), 0); + // Entire pool refunded to creator; contract empty. + assert_eq!(balance(&env, &xlm_token, &creator), PRIZE); + assert_eq!(balance(&env, &xlm_token, &contract_id), 0); + assert!(client.get_event(&event_id).is_finalized); +} + +#[test] +fn test_finalize_event_zero_prize_pool_noop() { + let (env, client, contract_id, creator, ai_agent, xlm_token) = setup(); + + // Fun event: zero prize pool, empty reward distribution. + let (event_id, invite_code, match_ids) = create_funded_event( + &env, + &contract_id, + &client, + &creator, + &xlm_token, + 0, + Vec::new(&env), + 1, + ); + + let user = Address::generate(&env); + client.join_event(&user, &invite_code); + client.submit_prediction(&user, &match_ids.get(0).unwrap(), &1u32, &0u32); + + env.ledger().set_timestamp(env.ledger().timestamp() + 7300); + submit_result(&client, &ai_agent, match_ids.get(0).unwrap(), MatchResult::TeamA); + + assert_eq!(balance(&env, &xlm_token, &contract_id), 0); + + let caller = Address::generate(&env); + let payouts = client.finalize_event(&caller, &event_id); + + // No reward ranks → no payouts, no transfers anywhere. + assert_eq!(payouts.len(), 0); + assert_eq!(balance(&env, &xlm_token, &contract_id), 0); + assert_eq!(balance(&env, &xlm_token, &creator), 0); + assert_eq!(balance(&env, &xlm_token, &user), 0); + // But the event is still marked finalized. + assert!(client.get_event(&event_id).is_finalized); +} + +#[test] +fn test_finalize_event_permissionless() { + let (env, client, contract_id, creator, ai_agent, xlm_token) = setup(); + + let dist = reward_dist(&env, &[100]); + let (event_id, invite_code, match_ids) = create_funded_event( + &env, + &contract_id, + &client, + &creator, + &xlm_token, + PRIZE, + dist, + 1, + ); + + let user = Address::generate(&env); + client.join_event(&user, &invite_code); + client.submit_prediction(&user, &match_ids.get(0).unwrap(), &1u32, &0u32); + + env.ledger().set_timestamp(env.ledger().timestamp() + 7300); + submit_result(&client, &ai_agent, match_ids.get(0).unwrap(), MatchResult::TeamA); + + // A random address — neither admin nor creator — can finalize. + let random_caller = Address::generate(&env); + let payouts = client.finalize_event(&random_caller, &event_id); + + assert_eq!(payouts.len(), 1); + assert_eq!(balance(&env, &xlm_token, &user), PRIZE); + assert_eq!(balance(&env, &xlm_token, &contract_id), 0); +}