diff --git a/contracts/predictify-hybrid/src/event_creation_tests.rs b/contracts/predictify-hybrid/src/event_creation_tests.rs new file mode 100644 index 00000000..e6a37376 --- /dev/null +++ b/contracts/predictify-hybrid/src/event_creation_tests.rs @@ -0,0 +1,187 @@ +#![cfg(test)] + +use crate::errors::Error; +use crate::types::{MarketState, OracleConfig, OracleProvider}; +use crate::{PredictifyHybrid, PredictifyHybridClient}; +use soroban_sdk::testutils::{Address as _, Ledger}; +use soroban_sdk::{vec, Address, Env, String, Symbol, Vec}; + +// Test helper structure +struct TestSetup { + env: Env, + contract_id: Address, + admin: Address, +} + +impl TestSetup { + fn new() -> Self { + let env = Env::default(); + env.mock_all_auths(); + + // Set a non-zero timestamp to avoid overflow in tests + env.ledger().with_mut(|li| { + li.timestamp = 10000; + }); + + let admin = Address::generate(&env); + let contract_id = env.register(PredictifyHybrid, ()); + + // Initialize the contract + let client = PredictifyHybridClient::new(&env, &contract_id); + client.initialize(&admin, &None); + + Self { + env, + contract_id, + admin, + } + } +} + +#[test] +fn test_create_event_success() { + let setup = TestSetup::new(); + let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); + + let description = String::from_str(&setup.env, "Will prediction markets be the future?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Yes"), + String::from_str(&setup.env, "No"), + ]; + let end_time = setup.env.ledger().timestamp() + 3600; // 1 hour from now + let oracle_config = OracleConfig { + provider: OracleProvider::Reflector, + feed_id: String::from_str(&setup.env, "BTC/USD"), + threshold: 50000, + comparison: String::from_str(&setup.env, "gt"), + }; + + let event_id = client.create_event( + &setup.admin, + &description, + &outcomes, + &end_time, + &oracle_config, + ); + + // Verify event details using the new get_event method + let event = client.get_event(&event_id).unwrap(); + assert_eq!(event.description, description); + assert_eq!(event.end_time, end_time); + assert_eq!(event.outcomes.len(), outcomes.len()); +} + +#[test] +fn test_create_market_success() { + let setup = TestSetup::new(); + let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); + + let description = String::from_str(&setup.env, "Will this market be created?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Yes"), + String::from_str(&setup.env, "No"), + ]; + let duration_days = 30; + let oracle_config = OracleConfig { + provider: OracleProvider::Reflector, + feed_id: String::from_str(&setup.env, "BTC/USD"), + threshold: 50000, + comparison: String::from_str(&setup.env, "gt"), + }; + + let market_id = client.create_market( + &setup.admin, + &description, + &outcomes, + &duration_days, + &oracle_config, + ); + + assert!(client.get_market(&market_id).is_some()); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #100)")] // Error::Unauthorized = 100 +fn test_create_event_unauthorized() { + let setup = TestSetup::new(); + let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); + + let non_admin = Address::generate(&setup.env); + let description = String::from_str(&setup.env, "Test event?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Yes"), + String::from_str(&setup.env, "No"), + ]; + let end_time = setup.env.ledger().timestamp() + 3600; + let oracle_config = OracleConfig { + provider: OracleProvider::Reflector, + feed_id: String::from_str(&setup.env, "BTC/USD"), + threshold: 50000, + comparison: String::from_str(&setup.env, "gt"), + }; + + client.create_event( + &non_admin, + &description, + &outcomes, + &end_time, + &oracle_config, + ); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #302)")] // Error::InvalidDuration = 302 +fn test_create_event_invalid_end_time() { + let setup = TestSetup::new(); + let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); + + let description = String::from_str(&setup.env, "Test event?"); + let outcomes = vec![ + &setup.env, + String::from_str(&setup.env, "Yes"), + String::from_str(&setup.env, "No"), + ]; + let end_time = setup.env.ledger().timestamp() - 3600; // Past time + let oracle_config = OracleConfig { + provider: OracleProvider::Reflector, + feed_id: String::from_str(&setup.env, "BTC/USD"), + threshold: 50000, + comparison: String::from_str(&setup.env, "gt"), + }; + + client.create_event( + &setup.admin, + &description, + &outcomes, + &end_time, + &oracle_config, + ); +} + +#[test] +#[should_panic(expected = "HostError: Error(Contract, #301)")] // Error::InvalidDuration = 302 +fn test_create_event_empty_outcomes() { + let setup = TestSetup::new(); + let client = PredictifyHybridClient::new(&setup.env, &setup.contract_id); + + let description = String::from_str(&setup.env, "Test event?"); + let outcomes = Vec::new(&setup.env); + let end_time = setup.env.ledger().timestamp() - 3600; // Past time + let oracle_config = OracleConfig { + provider: OracleProvider::Reflector, + feed_id: String::from_str(&setup.env, "BTC/USD"), + threshold: 50000, + comparison: String::from_str(&setup.env, "gt"), + }; + + client.create_event( + &setup.admin, + &description, + &outcomes, + &end_time, + &oracle_config, + ); +} diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index b98ee574..c2335626 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -102,6 +102,24 @@ pub struct MarketCreatedEvent { pub timestamp: u64, } +/// Event emitted when a new prediction event is successfully created. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EventCreatedEvent { + /// Unique event ID + pub event_id: Symbol, + /// Event description + pub description: String, + /// Event outcomes + pub outcomes: Vec, + /// Event end time + pub end_time: u64, + /// Event admin + pub admin: Address, + /// Creation timestamp + pub timestamp: u64, +} + /// Event emitted when a user successfully casts a vote on a prediction market. /// /// This event captures all details of voting activity, including voter identity, @@ -1372,6 +1390,27 @@ impl EventEmitter { Self::store_event(env, &symbol_short!("mkt_crt"), &event); } + /// Emit event created event + pub fn emit_event_created( + env: &Env, + event_id: &Symbol, + description: &String, + outcomes: &Vec, + admin: &Address, + end_time: u64, + ) { + let event = EventCreatedEvent { + event_id: event_id.clone(), + description: description.clone(), + outcomes: outcomes.clone(), + admin: admin.clone(), + end_time, + timestamp: env.ledger().timestamp(), + }; + + Self::store_event(env, &symbol_short!("evt_crt"), &event); + } + /// Emit vote cast event pub fn emit_vote_cast( env: &Env, diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index c2dfaf05..2a294cbb 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -38,6 +38,7 @@ mod rate_limiter; mod recovery; mod reentrancy_guard; mod resolution; +mod statistics; mod storage; mod types; mod upgrade_manager; @@ -46,7 +47,6 @@ mod validation; mod validation_tests; mod versioning; mod voting; -mod statistics; // THis is the band protocol wasm std_reference.wasm mod bandprotocol { soroban_sdk::contractimport!(file = "./std_reference.wasm"); @@ -82,6 +82,8 @@ mod event_management_tests; #[cfg(test)] mod statistics_tests; +#[cfg(test)] +mod event_creation_tests; // Re-export commonly used items use admin::{AdminAnalyticsResult, AdminInitializer, AdminManager, AdminPermission, AdminRole}; @@ -199,7 +201,12 @@ impl PredictifyHybrid { /// * `user` - The user depositing funds. /// * `asset` - The asset to deposit (e.g., XLM, BTC, ETH). /// * `amount` - The amount to deposit. - pub fn deposit(env: Env, user: Address, asset: ReflectorAsset, amount: i128) -> Result { + pub fn deposit( + env: Env, + user: Address, + asset: ReflectorAsset, + amount: i128, + ) -> Result { balances::BalanceManager::deposit(&env, user, asset, amount) } @@ -210,7 +217,12 @@ impl PredictifyHybrid { /// * `user` - The user withdrawing funds. /// * `asset` - The asset to withdraw. /// * `amount` - The amount to withdraw. - pub fn withdraw(env: Env, user: Address, asset: ReflectorAsset, amount: i128) -> Result { + pub fn withdraw( + env: Env, + user: Address, + asset: ReflectorAsset, + amount: i128, + ) -> Result { balances::BalanceManager::withdraw(&env, user, asset, amount) } @@ -359,6 +371,113 @@ impl PredictifyHybrid { market_id } + /// Creates a new prediction event with specified parameters. + /// + /// This function allows authorized admins to create prediction events + /// with specific descriptions, possible outcomes, and end times. Unlike `create_market`, + /// this function accepts an absolute Unix timestamp for the end time. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment + /// * `admin` - The administrator address (must be authorized) + /// * `description` - The event description or question + /// * `outcomes` - Vector of possible outcomes + /// * `end_time` - Absolute Unix timestamp for when the event ends + /// * `oracle_config` - Configuration for oracle integration + /// + /// # Returns + /// + /// Returns a unique `Symbol` serving as the event identifier. + /// + /// # Panics + /// + /// Panics if: + /// - Caller is not the contract admin + /// - validation fails (invalid description, outcomes, or end time) + pub fn create_event( + env: Env, + admin: Address, + description: String, + outcomes: Vec, + end_time: u64, + oracle_config: OracleConfig, + ) -> Symbol { + // Authenticate that the caller is the admin + admin.require_auth(); + + // Verify the caller is an admin + let stored_admin: Address = env + .storage() + .persistent() + .get(&Symbol::new(&env, "Admin")) + .unwrap_or_else(|| { + panic!("Admin not set"); + }); + + if admin != stored_admin { + panic_with_error!(env, Error::Unauthorized); + } + + // Validate inputs using EventValidator + if let Err(e) = crate::validation::EventValidator::validate_event_creation( + &env, + &admin, + &description, + &outcomes, + &end_time, + ) { + panic_with_error!(env, e.to_contract_error()); + } + + // Generate a unique collision-resistant event ID (reusing market ID generator) + let event_id = MarketIdGenerator::generate_market_id(&env, &admin); + + // Create a new event + let event = Event { + id: event_id.clone(), + description: description.clone(), + outcomes: outcomes.clone(), + end_time, + oracle_config, + admin: admin.clone(), + created_at: env.ledger().timestamp(), + status: MarketState::Active, + }; + + // Store the event + crate::storage::EventManager::store_event(&env, &event); + + // Emit event created event + EventEmitter::emit_event_created( + &env, + &event_id, + &description, + &outcomes, + &admin, + end_time, + ); + + // Record statistics (optional, can reuse market stats for now) + // statistics::StatisticsManager::record_market_created(&env); + + event_id + } + + /// Retrieves an event by its unique identifier. + /// + /// # Parameters + /// + /// * `env` - The Soroban environment + /// * `event_id` - Unique identifier of the event to retrieve + /// + /// # Returns + /// + /// Returns `Some(Event)` if found, or `None` otherwise. + pub fn get_event(env: Env, event_id: Symbol) -> Option { + crate::storage::EventManager::get_event(&env, &event_id).ok() + } + /// Allows users to vote on a market outcome by staking tokens. /// /// This function enables users to participate in prediction markets by voting @@ -548,7 +667,7 @@ impl PredictifyHybrid { // Record statistics statistics::StatisticsManager::record_bet_placed(&env, &user, amount); bet - }, + } Err(e) => panic_with_error!(env, e), } } @@ -910,7 +1029,7 @@ impl PredictifyHybrid { .checked_mul(total_pool) .unwrap_or_else(|| panic_with_error!(env, Error::InvalidInput)); let payout = product / winning_total; - + // Calculate fee amount for statistics // Payout is net of fee. Fee was deducted in user_share calculation. // Gross payout would be (user_stake * total_pool) / winning_total @@ -924,22 +1043,22 @@ impl PredictifyHybrid { // Simpler: Fee = (Payout * fee_percent) / (100 - fee_percent)? // Let's rely on explicit calculation if possible or approximation. // Actually, let's re-calculate gross to get fee. - // Gross = (user_stake * total_pool) / winning_total. + // Gross = (user_stake * total_pool) / winning_total. // Fee = Gross - Payout. - + let gross_share = (user_stake .checked_mul(PERCENTAGE_DENOMINATOR) .unwrap_or_else(|| panic_with_error!(env, Error::InvalidInput))) - / PERCENTAGE_DENOMINATOR; - // Wait, user_stake * 100 / 100 = user_stake. + / PERCENTAGE_DENOMINATOR; + // Wait, user_stake * 100 / 100 = user_stake. // The math above used PERCENTAGE_DENOMINATOR (100). - + let product_gross = user_stake .checked_mul(total_pool) .unwrap_or_else(|| panic_with_error!(env, Error::InvalidInput)); let gross_payout = product_gross / winning_total; let fee_amount = gross_payout - payout; - + statistics::StatisticsManager::record_winnings_claimed(&env, &user, payout); statistics::StatisticsManager::record_fees_collected(&env, fee_amount); @@ -1333,9 +1452,9 @@ impl PredictifyHybrid { pub fn resolve_market(env: Env, market_id: Symbol) -> Result<(), Error> { // Use the resolution module to resolve the market let _resolution = resolution::MarketResolutionManager::resolve_market(&env, &market_id)?; - + statistics::StatisticsManager::record_market_resolved(&env); - + Ok(()) } @@ -1675,10 +1794,10 @@ impl PredictifyHybrid { // Since place_bet now updates market.votes and market.stakes, // we can use the vote-based payout system for both bets and votes let mut total_distributed = 0; - + // Check if payouts have already been distributed let mut has_unclaimed_winners = false; - + // Check voters for (user, outcome) in market.votes.iter() { if &outcome == winning_outcome { @@ -1688,12 +1807,14 @@ impl PredictifyHybrid { } } } - + // Check bettors if !has_unclaimed_winners { for user in bettors.iter() { if let Some(bet) = bets::BetStorage::get_bet(&env, &market_id, &user) { - if bet.outcome == *winning_outcome && !market.claimed.get(user.clone()).unwrap_or(false) { + if bet.outcome == *winning_outcome + && !market.claimed.get(user.clone()).unwrap_or(false) + { has_unclaimed_winners = true; break; } @@ -1707,14 +1828,14 @@ impl PredictifyHybrid { // Calculate total winning stakes (voters + bettors) let mut winning_total = 0; - + // Sum voter stakes for (voter, outcome) in market.votes.iter() { if outcome == *winning_outcome { winning_total += market.stakes.get(voter.clone()).unwrap_or(0); } } - + // Sum bet amounts for user in bettors.iter() { if let Some(bet) = bets::BetStorage::get_bet(&env, &market_id, &user) { @@ -1753,7 +1874,6 @@ impl PredictifyHybrid { .ok_or(Error::InvalidInput)?) / winning_total; - if payout >= 0 { // Allow 0 payout but mark as claimed market.claimed.set(user.clone(), true); @@ -1780,13 +1900,14 @@ impl PredictifyHybrid { } if bet.amount > 0 { - let user_share = (bet.amount * (fee_denominator - fee_percent)) / fee_denominator; + let user_share = + (bet.amount * (fee_denominator - fee_percent)) / fee_denominator; let payout = (user_share * total_pool) / winning_total; if payout > 0 { market.claimed.set(user.clone(), true); total_distributed += payout; - + // Update bet status bet.status = BetStatus::Won; let _ = bets::BetStorage::store_bet(&env, &bet); @@ -1832,7 +1953,9 @@ impl PredictifyHybrid { cursor: u32, limit: u32, ) -> (Vec, u32) { - crate::event_archive::EventArchive::query_events_history(&env, from_ts, to_ts, cursor, limit) + crate::event_archive::EventArchive::query_events_history( + &env, from_ts, to_ts, cursor, limit, + ) } /// Query events by resolution status (e.g. Resolved, Cancelled). Paginated. @@ -2668,10 +2791,10 @@ impl PredictifyHybrid { return Err(Error::MarketClosed); } - let stored_admin: Option
= env.storage().persistent().get(&Symbol::new(&env, "Admin")); + let stored_admin: Option
= + env.storage().persistent().get(&Symbol::new(&env, "Admin")); let is_admin = stored_admin.as_ref().map_or(false, |a| a == &caller); - let timeout_passed = current_time - .saturating_sub(market.end_time) + let timeout_passed = current_time.saturating_sub(market.end_time) >= config::DEFAULT_RESOLUTION_TIMEOUT_SECONDS; if !is_admin && !timeout_passed { return Err(Error::Unauthorized); diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index 92fcb672..37a4673d 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -629,6 +629,40 @@ impl StorageOptimizer { } } +// ===== EVENT STORAGE ===== + +/// Manager for event storage operations +pub struct EventManager; + +impl EventManager { + /// Store a new event in persistent storage + pub fn store_event(env: &Env, event: &Event) { + env.storage().persistent().set(&event.id, event); + } + + /// Retrieve an event from persistent storage + pub fn get_event(env: &Env, event_id: &Symbol) -> Result { + env.storage() + .persistent() + .get(event_id) + .ok_or(Error::MarketNotFound) + } + + /// Check if an event exists + pub fn has_event(env: &Env, event_id: &Symbol) -> bool { + env.storage().persistent().has(event_id) + } + + /// Update an existing event + pub fn update_event(env: &Env, event: &Event) -> Result<(), Error> { + if !Self::has_event(env, &event.id) { + return Err(Error::MarketNotFound); + } + Self::store_event(env, event); + Ok(()) + } +} + // ===== STORAGE UTILITIES ===== /// Storage utility functions diff --git a/contracts/predictify-hybrid/src/types.rs b/contracts/predictify-hybrid/src/types.rs index 9e5700ea..f8d9cc3f 100644 --- a/contracts/predictify-hybrid/src/types.rs +++ b/contracts/predictify-hybrid/src/types.rs @@ -2593,6 +2593,33 @@ pub struct BetStats { pub outcome_totals: Map, } +// ===== EVENT TYPES ===== + +/// Represents a prediction market event with specified parameters. +/// +/// This structure stores all metadata and configuration for a prediction event, +/// including its description, possible outcomes, timing, and oracle integration. +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Event { + /// Unique identifier for the event + pub id: Symbol, + /// Event description or question + pub description: String, + /// Possible outcomes for the event (e.g., ["yes", "no"]) + pub outcomes: Vec, + /// When the event ends (Unix timestamp) + pub end_time: u64, + /// Oracle configuration for result verification + pub oracle_config: OracleConfig, + /// Administrative address that created/manages the event + pub admin: Address, + /// When the event was created (Unix timestamp) + pub created_at: u64, + /// Current status of the event + pub status: MarketState, +} + impl ReflectorAsset { pub fn is_xlm(&self) -> bool { matches!(self, ReflectorAsset::Stellar) diff --git a/contracts/predictify-hybrid/src/validation.rs b/contracts/predictify-hybrid/src/validation.rs index 9fe6477a..20e5bbba 100644 --- a/contracts/predictify-hybrid/src/validation.rs +++ b/contracts/predictify-hybrid/src/validation.rs @@ -1841,6 +1841,53 @@ impl InputValidator { /// - **Batch Processing**: Support multiple market validation /// - **Gas Efficient**: Minimize computational overhead /// - **Early Exit**: Stop on critical errors when appropriate +/// Event validation utilities +pub struct EventValidator; + +impl EventValidator { + /// Validate event creation parameters + pub fn validate_event_creation( + env: &Env, + admin: &Address, + description: &String, + outcomes: &Vec, + end_time: &u64, + ) -> Result<(), ValidationError> { + // Validate admin address + if let Err(e) = InputValidator::validate_address_format(admin) { + return Err(e); + } + + // Validate description format (reusing question format) + if let Err(e) = InputValidator::validate_question_format(description) { + return Err(e); + } + + // // Validate outcomes + if outcomes.len() < config::MIN_MARKET_OUTCOMES { + return Err(ValidationError::ArrayTooSmall); + } + + if outcomes.len() > config::MAX_MARKET_OUTCOMES { + return Err(ValidationError::ArrayTooLarge); + } + + for outcome in outcomes.iter() { + if let Err(e) = InputValidator::validate_outcome_format(&outcome) { + return Err(e); + } + } + + // Validate end time (must be in the future) + let current_time = env.ledger().timestamp(); + if *end_time <= current_time { + return Err(ValidationError::InvalidDuration); + } + + Ok(()) + } +} + pub struct MarketValidator; impl MarketValidator { @@ -2325,10 +2372,7 @@ impl VoteValidator { /// Validates bet amount against min/max limits. Used by place_bet. /// Returns InsufficientStake if below min, InvalidInput if above max. -pub fn validate_bet_amount_against_limits( - amount: i128, - limits: &BetLimits, -) -> Result<(), Error> { +pub fn validate_bet_amount_against_limits(amount: i128, limits: &BetLimits) -> Result<(), Error> { if amount < limits.min_bet { return Err(Error::InsufficientStake); }