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
106 changes: 97 additions & 9 deletions contracts/creator-event-manager/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ use crate::admin;
use crate::invite::{self, InviteError};
use crate::storage::{self, TTL_LEDGERS};
use crate::storage_types::{
DataKey, Event, MAX_DESCRIPTION_LEN, MAX_EVENT_DURATION_SECONDS, MAX_TITLE_LEN,
DataKey, Event, MAX_DESCRIPTION_LEN, MAX_EVENT_DURATION_SECONDS, MAX_REWARD_RANKS, MAX_TITLE_LEN,
REWARD_PERCENT_TOTAL,
};

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -38,6 +39,12 @@ pub enum EventError {
EventStartInPast = 11,
/// (end_time - start_time) exceeds MAX_EVENT_DURATION_SECONDS
EventDurationTooLong = 12,
/// prize_pool < 0
InvalidPrizePool = 13,
/// reward_distribution is malformed (see `validate_reward_distribution`).
InvalidRewardDistribution = 14,
/// Creator's XLM balance is below the requested prize_pool.
InsufficientPrizePoolFunds = 15,
}

impl From<InviteError> for EventError {
Expand All @@ -48,6 +55,53 @@ impl From<InviteError> for EventError {
}
}

// ---------------------------------------------------------------------------
// Prize pool validation
// ---------------------------------------------------------------------------

/// Validate a prize pool and its reward distribution.
///
/// Rules:
/// * `prize_pool` must be `>= 0` ([`EventError::InvalidPrizePool`]).
/// * When `prize_pool > 0`:
/// * `reward_distribution` must be non-empty,
/// * have at most [`MAX_REWARD_RANKS`] entries,
/// * every entry must be in `1..=REWARD_PERCENT_TOTAL`,
/// * and the entries must sum to exactly [`REWARD_PERCENT_TOTAL`].
/// * When `prize_pool == 0` (a "fun event"), `reward_distribution` must be empty.
fn validate_prize_pool(prize_pool: i128, reward_distribution: &Vec<u32>) -> Result<(), EventError> {
if prize_pool < 0 {
return Err(EventError::InvalidPrizePool);
}

if prize_pool == 0 {
// Fun event: no payouts, so no distribution may be specified.
if !reward_distribution.is_empty() {
return Err(EventError::InvalidRewardDistribution);
}
return Ok(());
}

// prize_pool > 0 from here on.
if reward_distribution.is_empty() || reward_distribution.len() > MAX_REWARD_RANKS {
return Err(EventError::InvalidRewardDistribution);
}

let mut sum: u32 = 0;
for percent in reward_distribution.iter() {
if percent == 0 || percent > REWARD_PERCENT_TOTAL {
return Err(EventError::InvalidRewardDistribution);
}
sum += percent;
}

if sum != REWARD_PERCENT_TOTAL {
return Err(EventError::InvalidRewardDistribution);
}

Ok(())
}

// ---------------------------------------------------------------------------
// create_event (#794)
// ---------------------------------------------------------------------------
Expand All @@ -61,14 +115,18 @@ impl From<InviteError> for EventError {
/// 4. Validate `max_participants > 0`.
/// 5. Validate time range: `start_time < end_time`, `start_time >= current_time`,
/// and duration `<= MAX_EVENT_DURATION_SECONDS`.
/// 6. Check creator has sufficient XLM balance for the creation fee.
/// 7. Transfer the fee from creator to treasury.
/// 8. Assign a new `event_id` via the global counter.
/// 9. Generate a unique 8-character invite code.
/// 10. Persist the `Event`, empty participant list, empty match list, and the
/// 6. Validate the prize pool and reward distribution.
/// 7. Check creator has sufficient XLM balance for the creation fee.
/// 8. Transfer the fee from creator to treasury.
/// 9. If `prize_pool > 0`, escrow the prize pool from creator into the contract
/// address (a separate transfer from the creation fee → treasury transfer).
/// 10. Assign a new `event_id` via the global counter.
/// 11. Generate a unique 8-character invite code.
/// 12. Persist the `Event`, empty participant list, empty match list, and the
/// invite-code → event_id reverse index.
/// 11. Emit an `EventCreated` event.
/// 12. Return `(event_id, invite_code)`.
/// 13. Emit an `EventCreated` event, plus a `prize_pool_funded` event when the
/// event is funded.
/// 14. Return `(event_id, invite_code)`.
pub fn create_event(
env: &Env,
creator: Address,
Expand All @@ -77,6 +135,8 @@ pub fn create_event(
max_participants: u32,
start_time: u64,
end_time: u64,
prize_pool: i128,
reward_distribution: Vec<u32>,
) -> Result<(u64, Symbol), EventError> {
creator.require_auth();

Expand Down Expand Up @@ -114,6 +174,9 @@ pub fn create_event(
return Err(EventError::EventDurationTooLong);
}

// Validate the prize pool and its reward distribution.
validate_prize_pool(prize_pool, &reward_distribution)?;

let fee = admin::get_creation_fee(env).unwrap_or_else(|| panic!("not_initialized"));
let treasury = admin::get_treasury(env).unwrap_or_else(|| panic!("not_initialized"));
let xlm_token = admin::get_xlm_token(env).unwrap_or_else(|| panic!("not_initialized"));
Expand All @@ -124,9 +187,21 @@ pub fn create_event(
return Err(EventError::InsufficientFee);
}

// Transfer creation fee from creator to treasury.
// The creator must be able to cover the prize pool on top of the creation
// fee. Check this before either transfer so we never move only the fee.
if prize_pool > 0 && token_client.balance(&creator) < fee + prize_pool {
return Err(EventError::InsufficientPrizePoolFunds);
}

// Transfer creation fee from creator to treasury (platform anti-spam fee).
token_client.transfer(&creator, &treasury, &fee);

// Escrow the prize pool from creator into the contract address. This is a
// distinct transfer from the creation-fee → treasury transfer above.
if prize_pool > 0 {
token_client.transfer(&creator, &env.current_contract_address(), &prize_pool);
}

let event_id = storage::next_event_id(env);
let invite_code = invite::generate_invite_code(env).map_err(EventError::from)?;

Expand All @@ -141,6 +216,8 @@ pub fn create_event(
end_time,
invite_code.clone(),
max_participants,
prize_pool,
reward_distribution.clone(),
);

storage::set_event(env, event_id, &event);
Expand Down Expand Up @@ -174,6 +251,17 @@ pub fn create_event(
(event_id, creator, invite_code.clone()),
);

// Announce the escrowed prize pool so off-chain indexers can track funding.
if prize_pool > 0 {
env.events().publish(
(
Symbol::new(env, "event"),
Symbol::new(env, "prize_pool_funded"),
),
(event_id, prize_pool, reward_distribution),
);
}

Ok((event_id, invite_code))
}

Expand Down
41 changes: 41 additions & 0 deletions contracts/creator-event-manager/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,12 @@ impl CreatorEventManagerContract {
/// * `"event_duration_too_long"` — duration exceeds MAX_EVENT_DURATION_SECONDS.
/// * `"insufficient_fee"` — creator's XLM balance is below the creation fee.
/// * `"code_generation_failed"` — could not generate a unique invite code.
/// * `"invalid_prize_pool"` — `prize_pool` is negative.
/// * `"invalid_reward_distribution"` — distribution is malformed (empty when
/// funded, too many ranks, a zero/over-100 entry, a non-100 sum, or a
/// non-empty distribution on an unfunded event).
/// * `"insufficient_prize_pool_funds"` — creator cannot cover
/// `creation_fee + prize_pool`.
pub fn create_event(
env: Env,
creator: Address,
Expand All @@ -265,6 +271,8 @@ impl CreatorEventManagerContract {
max_participants: u32,
start_time: u64,
end_time: u64,
prize_pool: i128,
reward_distribution: Vec<u32>,
) -> (u64, Symbol) {
match event::create_event(
&env,
Expand All @@ -274,6 +282,8 @@ impl CreatorEventManagerContract {
max_participants,
start_time,
end_time,
prize_pool,
reward_distribution,
) {
Ok(result) => result,
Err(EventError::Paused) => panic!("contract_paused"),
Expand All @@ -286,6 +296,9 @@ impl CreatorEventManagerContract {
Err(EventError::InsufficientFee) => panic!("insufficient_fee"),
Err(EventError::TransferFailed) => panic!("transfer_failed"),
Err(EventError::CodeGenerationFailed) => panic!("code_generation_failed"),
Err(EventError::InvalidPrizePool) => panic!("invalid_prize_pool"),
Err(EventError::InvalidRewardDistribution) => panic!("invalid_reward_distribution"),
Err(EventError::InsufficientPrizePoolFunds) => panic!("insufficient_prize_pool_funds"),
Err(_) => panic!("unexpected_error"),
}
}
Expand Down Expand Up @@ -318,6 +331,34 @@ impl CreatorEventManagerContract {
}
}

/// Return the escrowed prize pool (in stroops) for an event.
///
/// Returns `0` for a "fun event" with no payouts.
///
/// # Panics
/// * `"event_not_found"` — no event exists with the given ID.
pub fn get_event_prize_pool(env: Env, event_id: u64) -> i128 {
match views::get_event_prize_pool(&env, event_id) {
Ok(prize_pool) => prize_pool,
Err(EventError::EventNotFound) => panic!("event_not_found"),
Err(_) => panic!("unexpected_error"),
}
}

/// Return the reward distribution percentages for an event.
///
/// The vector is empty for a "fun event" with no payouts.
///
/// # Panics
/// * `"event_not_found"` — no event exists with the given ID.
pub fn get_event_reward_distribution(env: Env, event_id: u64) -> Vec<u32> {
match views::get_event_reward_distribution(&env, event_id) {
Ok(distribution) => distribution,
Err(EventError::EventNotFound) => panic!("event_not_found"),
Err(_) => panic!("unexpected_error"),
}
}

/// Return all participant addresses for an event.
///
/// Reads the `EventParticipants(event_id)` storage index after validating
Expand Down
25 changes: 24 additions & 1 deletion contracts/creator-event-manager/src/storage_types.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
use soroban_sdk::{contracttype, Address, String, Symbol};
use soroban_sdk::{contracttype, Address, String, Symbol, Vec};

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

/// Maximum number of leaderboard ranks that can receive a prize pool payout.
pub const MAX_REWARD_RANKS: u32 = 5;
/// The reward distribution percentages must sum to exactly this value.
pub const REWARD_PERCENT_TOTAL: u32 = 100;

/// Maximum length for event title (characters)
pub const MAX_TITLE_LEN: u32 = 200;
/// Maximum length for event description (characters)
Expand Down Expand Up @@ -224,6 +229,19 @@ pub struct Event {

/// Number of matches that belong to this event
pub match_count: u32,

/// XLM prize pool (in stroops) escrowed in the contract for winners.
/// `0` means this is a "fun event" with no payouts.
pub prize_pool: i128,

/// Percentage of the prize pool awarded to each leaderboard rank, in
/// 1-based rank order (index 0 → rank 1). Each entry is 1–100 and the
/// entries sum to `REWARD_PERCENT_TOTAL` when `prize_pool > 0`; the vector
/// is empty when `prize_pool == 0`.
pub reward_distribution: Vec<u32>,

/// Whether the prize pool has been distributed / the event closed out.
pub is_finalized: bool,
}

impl Event {
Expand All @@ -240,6 +258,8 @@ impl Event {
end_time: u64,
invite_code: Symbol,
max_participants: u32,
prize_pool: i128,
reward_distribution: Vec<u32>,
) -> Self {
Self {
event_id,
Expand All @@ -256,6 +276,9 @@ impl Event {
max_participants,
participant_count: 0,
match_count: 0,
prize_pool,
reward_distribution,
is_finalized: false,
}
}

Expand Down
18 changes: 18 additions & 0 deletions contracts/creator-event-manager/src/views.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@ pub fn get_event_participants(env: &Env, event_id: u64) -> Result<Vec<Address>,
Ok(storage::get_event_participants(env, event_id))
}

/// Return the escrowed prize pool (in stroops) for an existing event.
///
/// Validates that `event_id` exists, then returns the stored `prize_pool`.
/// A "fun event" (no payouts) returns `0`.
pub fn get_event_prize_pool(env: &Env, event_id: u64) -> Result<i128, EventError> {
let event = event::get_event(env, event_id)?;
Ok(event.prize_pool)
}

/// Return the reward distribution percentages for an existing event.
///
/// Validates that `event_id` exists, then returns the stored
/// `reward_distribution`. The vector is empty for a "fun event".
pub fn get_event_reward_distribution(env: &Env, event_id: u64) -> Result<Vec<u32>, EventError> {
let event = event::get_event(env, event_id)?;
Ok(event.reward_distribution)
}

/// Build aggregate statistics for an existing event.
///
/// The function first retrieves the event to validate that `event_id` exists,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use creator_event_manager::storage_types::{
Event, Match, MatchResult, Prediction, MAX_TEAM_NAME_LEN, OUTCOME_DRAW, OUTCOME_TEAM_A,
OUTCOME_TEAM_B,
};
use soroban_sdk::{testutils::Address as _, Address, Env, String, Symbol};
use soroban_sdk::{testutils::Address as _, Address, Env, String, Symbol, Vec};

// =============================================================================
// Helpers
Expand All @@ -24,6 +24,8 @@ fn make_event(env: &Env, event_id: u64) -> Event {
1_640_995_200u64 + 86400, // end_time (24 hours later)
Symbol::new(env, "ABCD1234"),
100u32,
0i128,
Vec::new(env),
)
}

Expand Down Expand Up @@ -131,6 +133,8 @@ fn test_event_add_participant_rejects_when_full() {
2000u64, // end_time
Symbol::new(&env, "LIMIT1"),
1u32,
0i128,
Vec::new(&env),
);
assert!(event.add_participant().is_ok());
assert_eq!(
Expand Down
Loading
Loading