From bad17db5d2380edeabef102d77c70fb1548e15fd Mon Sep 17 00:00:00 2001 From: Whiznificent Date: Sat, 24 Oct 2026 14:14:21 +0000 Subject: [PATCH] feat(predictify-hybrid): make the per-market outcome cap admin-configurable The 10-outcome limit was a compile-time constant, so changing it required a contract upgrade. The cap now lives in persistent storage (DataKey::MaxOutcomesPerMarket), defaults to 10 for backwards compatibility, and is settable by the primary admin within a 2..=20 range. Enforcement moves to the two paths that can change a market's outcome list: create_market (previously relied on a hard-coded check) and update_event_outcomes (previously had no upper bound at all). Lowering the cap deliberately leaves existing markets untouched so live markets stay resolvable. Closes #9 --- CHANGELOG.md | 1 + contracts/predictify-hybrid/docs/storage.md | 1 + .../src/event_topic_catalog.rs | 1 + contracts/predictify-hybrid/src/events.rs | 22 + contracts/predictify-hybrid/src/lib.rs | 64 ++- contracts/predictify-hybrid/src/markets.rs | 128 +++++- contracts/predictify-hybrid/src/storage.rs | 4 + .../src/storage_docs_tests.rs | 1 + .../src/tests/max_outcomes_cap_tests.rs | 385 ++++++++++++++++++ contracts/predictify-hybrid/src/tests/mod.rs | 5 +- 10 files changed, 602 insertions(+), 10 deletions(-) create mode 100644 contracts/predictify-hybrid/src/tests/max_outcomes_cap_tests.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bb18643..bc71de10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial CHANGELOG.md following Keep a Changelog format - CI check requiring CHANGELOG.md updates for PRs modifying contract code +- **predictify-hybrid**: the cap on outcomes per market is now a runtime parameter instead of a hard-coded `10`. Admins call `set_max_outcomes_per_market` (range 2-20, default 10) and read it back with `get_max_outcomes_per_market`; the cap is enforced by `create_market` and by `update_event_outcomes`, which previously had no upper bound at all (closes #9) ### Changed diff --git a/contracts/predictify-hybrid/docs/storage.md b/contracts/predictify-hybrid/docs/storage.md index 11f90944..02179529 100644 --- a/contracts/predictify-hybrid/docs/storage.md +++ b/contracts/predictify-hybrid/docs/storage.md @@ -128,6 +128,7 @@ its call site(s), not just its name. | `UserLastBetTime(Symbol, Address)` | *(unused)* | Declared to support a per-(market, user) cool-off timer; no current call site. Per-entity data of this shape would need Persistent once wired up, matching `UserStake`. | | `CoolOffPeriod` | *(unused)* | Declared for a global cool-off period in seconds; no current call site. | | `PerMarketCoolOff(Symbol)` | *(unused)* | Declared for a per-market cool-off override; no current call site. | +| `MaxOutcomesPerMarket` | Persistent | Admin-configurable cap on the number of outcomes a market may declare ([`markets.rs`](../src/markets.rs)). A single global config value read on every market creation, so it must survive across ledgers; extended to `MARKET_TTL_LEDGERS` alongside the markets it governs. Absent means `DEFAULT_MAX_OUTCOMES_PER_MARKET` (10) applies. | ### Dual-tier keys: `MarketMetadata` / `MarketScratch` diff --git a/contracts/predictify-hybrid/src/event_topic_catalog.rs b/contracts/predictify-hybrid/src/event_topic_catalog.rs index 6333e07d..0ce7c255 100644 --- a/contracts/predictify-hybrid/src/event_topic_catalog.rs +++ b/contracts/predictify-hybrid/src/event_topic_catalog.rs @@ -29,6 +29,7 @@ pub fn get_event_topic_catalog(env: &Env) -> Vec { ("place_bets", "bet_batch_placed", "Fired when multiple bets are placed in a single batch"), ("resolve_market_bets", "bet_status_updated", "Fired when a bet's status changes (won/lost/refunded/cancelled)"), ("set_global_bet_limits", "bet_limits_updated", "Fired when global or per-event bet limits are updated"), + ("set_max_outcomes_per_market", "max_outcomes_per_market_set", "Fired when the admin changes the maximum outcomes a new market may declare"), ("update_market_bet_stats", "bet_stats_updated", "Fired when per-market betting statistics are updated"), ("resolve_market", "market_resolved", "Fired on successful market resolution"), ("resolve_market_manual", "market_resolved_manual", "Fired on admin manual resolution"), diff --git a/contracts/predictify-hybrid/src/events.rs b/contracts/predictify-hybrid/src/events.rs index 080e6d79..e2f9b84d 100644 --- a/contracts/predictify-hybrid/src/events.rs +++ b/contracts/predictify-hybrid/src/events.rs @@ -103,6 +103,15 @@ pub struct MaxBetCapSetEvent { pub timestamp: u64, } +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MaxOutcomesPerMarketSetEvent { + pub admin: Address, + pub max_outcomes: u32, + pub nonce: u64, + pub timestamp: u64, +} + #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] pub struct OracleResultEvent { @@ -1238,6 +1247,19 @@ impl EventEmitter { env.events().publish((event_sym,), event); } + pub fn emit_max_outcomes_per_market_set(env: &Env, admin: &Address, max_outcomes: u32) { + let event_sym = symbol_short!("mxoutc"); + let event = MaxOutcomesPerMarketSetEvent { + admin: admin.clone(), + max_outcomes, + nonce: Self::get_and_increment_nonce(env, event_sym.clone()), + timestamp: env.ledger().timestamp(), + }; + // `store_event` both persists the payload and publishes it under + // `event_sym`, so no extra `publish` here. + Self::store_event(env, &event_sym, &event); + } + pub fn emit_oracle_result( env: &Env, market_id: &Symbol, result: &String, provider: &String, feed_id: &String, price: i128, threshold: i128, comparison: &String, ) { diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index 4ee9785d..af565f7f 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -346,6 +346,12 @@ impl PredictifyHybrid { panic_with_error!(env, e); } + // Enforce the admin-configurable outcome cap before anything else can + // touch storage, so the limit holds for every creation path. + if let Err(e) = crate::markets::OutcomeCapManager::validate_outcome_count(&env, &outcomes) { + panic_with_error!(env, e); + } + if let Err(e) = crate::validation::CreationValidator::validate_market_creation(&env, &question, &outcomes, &duration_days) { panic_with_error!(env, e); } @@ -3406,6 +3412,53 @@ impl PredictifyHybrid { Ok(()) } + /// Sets the maximum number of outcomes a market may declare (admin only). + /// + /// The cap used to be a hard-coded `10` compiled into the contract, which + /// meant changing it required a contract upgrade. It now lives in + /// persistent storage, so governance can raise or lower it between + /// deployments. + /// + /// The cap is validated on market creation + /// ([`markets::MarketValidator::validate_market_params`]): a market whose + /// `outcomes` vector exceeds it is rejected with + /// [`Error::TooManyOutcomes`]. Markets that already exist keep their + /// outcomes — lowering the cap must never invalidate a live market, since + /// its bets would then be unresolvable. + /// + /// # Parameters + /// + /// - `admin` – Must be the primary admin address, and must authorize the call + /// - `max_outcomes` – New cap, within + /// [`markets::MIN_OUTCOMES_PER_MARKET`]`..=`[`markets::MAX_OUTCOMES_PER_MARKET_CEILING`] + /// + /// # Errors + /// + /// - [`Error::Unauthorized`] when `admin` is not the primary admin + /// - [`Error::InvalidInput`] when `max_outcomes` is outside the allowed range + /// + /// # Events + /// + /// Emits a `max_outcomes_per_market_set` event (topic `mxoutc`) carrying the + /// admin address and the new cap, so indexers and front-ends can pick the + /// limit up without polling storage. + pub fn set_max_outcomes_per_market( + env: Env, + admin: Address, + max_outcomes: u32, + ) -> Result<(), Error> { + Self::require_primary_admin(&env, &admin)?; + markets::OutcomeCapManager::set_max_outcomes(&env, &admin, max_outcomes) + } + + /// Returns the maximum number of outcomes a newly created market may declare. + /// + /// Defaults to [`markets::DEFAULT_MAX_OUTCOMES_PER_MARKET`] (`10`) until an + /// admin calls [`Self::set_max_outcomes_per_market`]. + pub fn get_max_outcomes_per_market(env: Env) -> u32 { + markets::OutcomeCapManager::get_max_outcomes(&env) + } + pub fn set_max_participants( env: Env, admin: Address, @@ -3917,6 +3970,8 @@ impl PredictifyHybrid { /// - No bets can have been placed yet /// - Market must not be resolved /// - New outcomes must have at least 2 options + /// - New outcomes must not exceed the admin-configured cap + /// ([`Self::get_max_outcomes_per_market`]) /// - All outcome strings must be non-empty /// /// # Security @@ -3936,9 +3991,12 @@ impl PredictifyHybrid { ) -> Result<(), Error> { Self::require_primary_admin(&env, &admin)?; - // Validate new outcomes - if new_outcomes.len() < 2 { - panic_with_error!(env, Error::InvalidOutcomes); + // Validate new outcomes against the same cap used at creation: this + // path rewrites `market.outcomes`, so skipping the cap here would let + // an admin grow a market past the bound that keeps resolution within + // the instruction budget. + if let Err(e) = crate::markets::OutcomeCapManager::validate_outcome_count(&env, &new_outcomes) { + panic_with_error!(env, e); } // Check all outcomes are non-empty diff --git a/contracts/predictify-hybrid/src/markets.rs b/contracts/predictify-hybrid/src/markets.rs index 8d2b498a..09cf10d7 100644 --- a/contracts/predictify-hybrid/src/markets.rs +++ b/contracts/predictify-hybrid/src/markets.rs @@ -43,7 +43,8 @@ impl MarketCreator { /// * `env` - The Soroban environment for blockchain operations /// * `admin` - Address of the market administrator (must have sufficient balance for fees) /// * `question` - The prediction question (1-500 characters, cannot be empty) - /// * `outcomes` - Vector of possible outcomes (minimum 2, maximum 10 outcomes) + /// * `outcomes` - Vector of possible outcomes (minimum 2, maximum + /// [`OutcomeCapManager::get_max_outcomes`], default [`DEFAULT_MAX_OUTCOMES_PER_MARKET`]) /// * `duration_days` - Market duration in days (1-365 days) /// * `oracle_config` - Oracle configuration specifying data source and resolution criteria /// @@ -165,7 +166,8 @@ impl MarketCreator { /// * `_env` - The Soroban environment for blockchain operations /// * `admin` - Address of the market administrator /// * `question` - The prediction question (1-500 characters) - /// * `outcomes` - Vector of possible outcomes (minimum 2, maximum 10) + /// * `outcomes` - Vector of possible outcomes (minimum 2, maximum + /// [`OutcomeCapManager::get_max_outcomes`], default [`DEFAULT_MAX_OUTCOMES_PER_MARKET`]) /// * `duration_days` - Market duration in days (1-365 days) /// * `asset_symbol` - Reflector asset symbol (e.g., "BTC", "ETH", "XLM") /// * `threshold` - Price threshold for comparison (in asset's base units) @@ -246,7 +248,8 @@ impl MarketCreator { /// * `_env` - The Soroban environment for blockchain operations /// * `admin` - Address of the market administrator /// * `question` - The prediction question (1-500 characters) - /// * `outcomes` - Vector of possible outcomes (minimum 2, maximum 10) + /// * `outcomes` - Vector of possible outcomes (minimum 2, maximum + /// [`OutcomeCapManager::get_max_outcomes`], default [`DEFAULT_MAX_OUTCOMES_PER_MARKET`]) /// * `duration_days` - Market duration in days (1-365 days) /// * `feed_id` - Pyth price feed identifier (e.g., "BTC/USD", "ETH/USD") /// * `threshold` - Price threshold for comparison (in feed's base units) @@ -328,7 +331,8 @@ impl MarketCreator { /// * `_env` - The Soroban environment for blockchain operations /// * `admin` - Address of the market administrator /// * `question` - The prediction question (1-500 characters) - /// * `outcomes` - Vector of possible outcomes (minimum 2, maximum 10) + /// * `outcomes` - Vector of possible outcomes (minimum 2, maximum + /// [`OutcomeCapManager::get_max_outcomes`], default [`DEFAULT_MAX_OUTCOMES_PER_MARKET`]) /// * `duration_days` - Market duration in days (1-365 days) /// * `asset_symbol` - Specific asset symbol (e.g., "BTC", "ETH", "GOLD") /// * `threshold` - Price threshold for comparison (in asset's base units) @@ -401,6 +405,111 @@ impl MarketCreator { /// This struct provides comprehensive validation functions for market creation, /// voting operations, and state transitions. All validation functions follow /// strict business rules to maintain platform integrity and user experience. +// ===== OUTCOME COUNT LIMITS ===== + +/// Default cap on the number of outcomes a market may declare. +/// +/// This is the value the contract used to hard-code. It is now only the +/// fallback used until governance calls +/// [`OutcomeCapManager::set_max_outcomes`], so the effective cap can move +/// without a contract upgrade. +pub const DEFAULT_MAX_OUTCOMES_PER_MARKET: u32 = 10; + +/// Smallest cap an admin may configure. +/// +/// A market needs at least two mutually exclusive outcomes for resolution and +/// payout accounting to mean anything, so the cap can never go below two. +pub const MIN_OUTCOMES_PER_MARKET: u32 = 2; + +/// Largest cap an admin may configure. +/// +/// Resolution, disputed resolution and claiming all iterate the outcome list +/// (`resolve_market`, `resolve_market_multiple`, `claim_winnings`). Bounding +/// the configured cap keeps a single resolution inside the ledger's +/// instruction budget and keeps the per-market record small, so a mistyped +/// (or compromised) admin key cannot create markets that are too expensive to +/// resolve. +pub const MAX_OUTCOMES_PER_MARKET_CEILING: u32 = 20; + +/// Manages the runtime-configurable cap on outcomes per market. +/// +/// The cap replaces the previously hard-coded `10` with a value stored in +/// persistent storage under [`DataKey::MaxOutcomesPerMarket`], so governance +/// can raise or lower it without redeploying the contract. +pub struct OutcomeCapManager; + +impl OutcomeCapManager { + /// Returns the configured maximum number of outcomes per market. + /// + /// Falls back to [`DEFAULT_MAX_OUTCOMES_PER_MARKET`] when the admin has + /// never configured a cap, so existing behaviour is unchanged. + pub fn get_max_outcomes(env: &Env) -> u32 { + env.storage() + .persistent() + .get::(&DataKey::MaxOutcomesPerMarket) + .unwrap_or(DEFAULT_MAX_OUTCOMES_PER_MARKET) + } + + /// Stores a new cap on outcomes per market (admin only). + /// + /// # Parameters + /// + /// * `env` - The Soroban environment + /// * `caller` - The admin address; must authorize the call + /// * `max_outcomes` - The new cap, between [`MIN_OUTCOMES_PER_MARKET`] and + /// [`MAX_OUTCOMES_PER_MARKET_CEILING`] inclusive + /// + /// # Errors + /// + /// * [`Error::Unauthorized`] - `caller` is not the stored primary admin + /// * [`Error::InvalidInput`] - `max_outcomes` is outside the allowed range + /// + /// # Effects + /// + /// The cap applies to markets created after the call. Markets that already + /// exist keep their outcomes: lowering the cap must never invalidate a live + /// market, otherwise its bets could never be resolved. + pub fn set_max_outcomes(env: &Env, caller: &Address, max_outcomes: u32) -> Result<(), Error> { + // Admin authorization is enforced by the `set_max_outcomes_per_market` + // entrypoint; requiring auth here as well keeps the manager safe if it + // is ever called from another path. + caller.require_auth(); + + if max_outcomes < MIN_OUTCOMES_PER_MARKET || max_outcomes > MAX_OUTCOMES_PER_MARKET_CEILING { + return Err(Error::InvalidInput); + } + + let key = DataKey::MaxOutcomesPerMarket; + env.storage().persistent().set(&key, &max_outcomes); + env.storage() + .persistent() + .extend_ttl(&key, MARKET_TTL_LEDGERS, MARKET_TTL_LEDGERS); + + EventEmitter::emit_max_outcomes_per_market_set(env, caller, max_outcomes); + + Ok(()) + } + + /// Rejects an outcome list that violates the configured cap. + /// + /// # Errors + /// + /// * [`Error::InvalidOutcomes`] - fewer than [`MIN_OUTCOMES_PER_MARKET`] outcomes + /// * [`Error::TooManyOutcomes`] - more than [`OutcomeCapManager::get_max_outcomes`] outcomes + pub fn validate_outcome_count(env: &Env, outcomes: &Vec) -> Result<(), Error> { + let count = outcomes.len(); + + if count < MIN_OUTCOMES_PER_MARKET { + return Err(Error::InvalidOutcomes); + } + if count > Self::get_max_outcomes(env) { + return Err(Error::TooManyOutcomes); + } + + Ok(()) + } +} + pub struct MarketValidator; impl MarketValidator { @@ -425,13 +534,16 @@ impl MarketValidator { /// # Errors /// /// * `Error::InvalidQuestion` - Question is empty or exceeds 500 characters - /// * `Error::InvalidOutcomes` - Less than 2 outcomes, more than 10 outcomes, or empty outcome strings + /// * `Error::InvalidOutcomes` - Less than 2 outcomes, or empty outcome strings + /// * `Error::TooManyOutcomes` - More outcomes than [`OutcomeCapManager::get_max_outcomes`] + /// allows (default [`DEFAULT_MAX_OUTCOMES_PER_MARKET`]) /// * `Error::InvalidDuration` - Duration is 0 or exceeds 365 days /// /// # Validation Rules /// /// * Question: Must be non-empty and between 1-500 characters - /// * Outcomes: Must have 2-10 unique, non-empty outcome strings + /// * Outcomes: Must have at least 2 unique, non-empty outcome strings, and no + /// more than the admin-configured cap (default [`DEFAULT_MAX_OUTCOMES_PER_MARKET`]) /// * Duration: Must be between 1-365 days inclusive /// /// # Example @@ -471,6 +583,10 @@ impl MarketValidator { duration_days: u32, ) -> Result<(), Error> { Self::validate_unique_outcomes(outcomes)?; + // Outcome-count enforcement is owned by `OutcomeCapManager` so the cap + // stays a single admin-configurable parameter instead of a constant + // duplicated across validators. + OutcomeCapManager::validate_outcome_count(env, outcomes)?; crate::validation::CreationValidator::validate_market_creation( env, question, diff --git a/contracts/predictify-hybrid/src/storage.rs b/contracts/predictify-hybrid/src/storage.rs index 37de8029..4dc07e0a 100644 --- a/contracts/predictify-hybrid/src/storage.rs +++ b/contracts/predictify-hybrid/src/storage.rs @@ -213,6 +213,10 @@ pub enum DataKey { /// Per-market cool-off override in seconds (u64) for a specific `market_id`. /// Takes precedence over `CoolOffPeriod` when present. PerMarketCoolOff(Symbol), + /// Global maximum number of outcomes a market may declare. + /// Value: `u32`. Admins can raise or lower it without a contract upgrade; + /// when unset, [`crate::markets::DEFAULT_MAX_OUTCOMES_PER_MARKET`] applies. + MaxOutcomesPerMarket, } /// Storage format version for migration tracking diff --git a/contracts/predictify-hybrid/src/storage_docs_tests.rs b/contracts/predictify-hybrid/src/storage_docs_tests.rs index 18e95c87..85cdcd88 100644 --- a/contracts/predictify-hybrid/src/storage_docs_tests.rs +++ b/contracts/predictify-hybrid/src/storage_docs_tests.rs @@ -34,6 +34,7 @@ const DATA_KEY_VARIANTS: &[&str] = &[ "UserLastBetTime", "CoolOffPeriod", "PerMarketCoolOff", + "MaxOutcomesPerMarket", ]; #[test] diff --git a/contracts/predictify-hybrid/src/tests/max_outcomes_cap_tests.rs b/contracts/predictify-hybrid/src/tests/max_outcomes_cap_tests.rs new file mode 100644 index 00000000..bbdd4080 --- /dev/null +++ b/contracts/predictify-hybrid/src/tests/max_outcomes_cap_tests.rs @@ -0,0 +1,385 @@ +//! Tests for the runtime-configurable outcome cap (issue #009). +//! +//! The cap used to be a constant compiled into the contract, so changing it +//! needed an upgrade. It now lives in persistent storage +//! (`DataKey::MaxOutcomesPerMarket`) and is enforced on both write paths that +//! can change a market's outcome list: `create_market` and +//! `update_event_outcomes`. +//! +//! ## Coverage matrix +//! +//! | Scenario | Expected result | +//! |---|---| +//! | Cap never configured | [`DEFAULT_MAX_OUTCOMES_PER_MARKET`] (`10`) | +//! | Outcomes equal to the cap | accepted | +//! | Outcomes one over the cap | [`Error::TooManyOutcomes`] | +//! | Admin raises the cap | larger markets accepted | +//! | Admin lowers the cap | existing markets untouched, new larger markets rejected | +//! | Non-admin sets the cap | [`Error::Unauthorized`] | +//! | Cap below [`MIN_OUTCOMES_PER_MARKET`] | [`Error::InvalidInput`] | +//! | Cap above [`MAX_OUTCOMES_PER_MARKET_CEILING`] | [`Error::InvalidInput`] | +//! | `create_market` over the cap | [`Error::TooManyOutcomes`] | +//! | `update_event_outcomes` over the cap | [`Error::TooManyOutcomes`] | +//! | Cap change | `mxoutc` event published | +//! +//! ## Running +//! +//! ```bash +//! cargo test -p predictify-hybrid -- max_outcomes_cap_tests +//! ``` + +#![cfg(test)] + +extern crate std; + +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, Events, Ledger}, + Address, Env, String, Symbol, TryIntoVal, Vec, +}; + +use crate::err::Error; +use crate::markets::{ + MarketStateManager, OutcomeCapManager, DEFAULT_MAX_OUTCOMES_PER_MARKET, + MAX_OUTCOMES_PER_MARKET_CEILING, MIN_OUTCOMES_PER_MARKET, +}; +use crate::types::{Market, MarketState, OracleConfig, OracleProvider}; +use crate::{PredictifyHybrid, PredictifyHybridClient}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const DAY_SECS: u64 = 86_400; +const START_TS: u64 = 1_735_689_600; + +fn client<'a>(env: &'a Env, contract_id: &Address) -> PredictifyHybridClient<'a> { + PredictifyHybridClient::new(env, contract_id) +} + +/// Asserts that the invocation failed with the given contract error. +fn assert_contract_error( + result: Result>, + expected: Error, +) { + match result { + Err(Ok(actual)) => assert_eq!( + actual, + soroban_sdk::Error::from_contract_error(expected as u32), + "unexpected contract error" + ), + other => panic!("expected contract error {:?}, got {:?}", expected, other), + } +} + +/// Initializes the contract with `admin` as the primary admin. +fn setup(env: &Env, admin: &Address) -> Address { + env.mock_all_auths(); + env.ledger().set_timestamp(START_TS); + + let contract_id = env.register(PredictifyHybrid, ()); + client(env, &contract_id).initialize(admin, &Some(200i128), &None); + contract_id +} + +/// Builds `count` distinct outcome labels (`outcome_1` .. `outcome_N`). +fn outcomes_of(env: &Env, count: u32) -> Vec { + let mut outcomes = Vec::new(env); + for index in 0..count { + outcomes.push_back(String::from_str( + env, + std::format!("outcome_{}", index + 1).as_str(), + )); + } + outcomes +} + +/// Minimal valid oracle configuration (Reflector, BTC/USD). +fn oracle_config(env: &Env) -> OracleConfig { + OracleConfig { + provider: OracleProvider::reflector(), + oracle_address: Address::from_str( + env, + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + ), + feed_id: String::from_str(env, "BTC/USD"), + threshold: 50_000i128, + comparison: String::from_str(env, "gt"), + } +} + +/// Runs `f` inside the contract context so storage calls resolve. +fn in_contract(env: &Env, contract_id: &Address, f: impl FnOnce() -> T) -> T { + env.as_contract(contract_id, f) +} + +/// Stores a market record directly, bypassing the fee-paying creation path. +fn store_market(env: &Env, contract_id: &Address, market_id: &Symbol, outcomes: Vec) { + let market = Market::new( + env, + Address::generate(env), + String::from_str(env, "Will BTC close above $100k?"), + outcomes, + START_TS + 30 * DAY_SECS, + oracle_config(env), + None, + 86_400u64, + MarketState::Active, + ); + in_contract(env, contract_id, || { + MarketStateManager::update_market(env, market_id, &market); + }); +} + +/// Returns the number of events published under the given topic. +fn count_events_with_topic(env: &Env, topic: Symbol) -> u32 { + let mut count = 0; + for event in env.events().all().iter() { + let topics = event.1; + if topics.is_empty() { + continue; + } + let topic0: Symbol = topics.get(0).unwrap().try_into_val(env).unwrap(); + if topic0 == topic { + count += 1; + } + } + count +} + +// --------------------------------------------------------------------------- +// Defaults and boundaries +// --------------------------------------------------------------------------- + +#[test] +fn test_default_cap_is_ten() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = setup(&env, &admin); + + assert_eq!( + client(&env, &contract_id).get_max_outcomes_per_market(), + DEFAULT_MAX_OUTCOMES_PER_MARKET + ); + assert_eq!(DEFAULT_MAX_OUTCOMES_PER_MARKET, 10); +} + +#[test] +fn test_outcomes_at_the_cap_are_accepted() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = setup(&env, &admin); + let outcomes = outcomes_of(&env, DEFAULT_MAX_OUTCOMES_PER_MARKET); + + in_contract(&env, &contract_id, || { + assert_eq!( + OutcomeCapManager::validate_outcome_count(&env, &outcomes), + Ok(()) + ); + }); +} + +#[test] +fn test_outcomes_above_the_default_cap_are_rejected() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = setup(&env, &admin); + let outcomes = outcomes_of(&env, DEFAULT_MAX_OUTCOMES_PER_MARKET + 1); + + in_contract(&env, &contract_id, || { + assert_eq!( + OutcomeCapManager::validate_outcome_count(&env, &outcomes), + Err(Error::TooManyOutcomes) + ); + }); +} + +#[test] +fn test_single_outcome_is_rejected() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = setup(&env, &admin); + let outcomes = outcomes_of(&env, MIN_OUTCOMES_PER_MARKET - 1); + + in_contract(&env, &contract_id, || { + assert_eq!( + OutcomeCapManager::validate_outcome_count(&env, &outcomes), + Err(Error::InvalidOutcomes) + ); + }); +} + +// --------------------------------------------------------------------------- +// Admin surface +// --------------------------------------------------------------------------- + +#[test] +fn test_admin_can_raise_the_cap() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = setup(&env, &admin); + + client(&env, &contract_id).set_max_outcomes_per_market(&admin, &12u32); + assert_eq!(client(&env, &contract_id).get_max_outcomes_per_market(), 12); + + in_contract(&env, &contract_id, || { + assert_eq!( + OutcomeCapManager::validate_outcome_count(&env, &outcomes_of(&env, 12)), + Ok(()), + "12 outcomes must be accepted once the cap is raised to 12" + ); + assert_eq!( + OutcomeCapManager::validate_outcome_count(&env, &outcomes_of(&env, 13)), + Err(Error::TooManyOutcomes) + ); + }); +} + +#[test] +fn test_admin_can_lower_the_cap_without_affecting_existing_markets() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = setup(&env, &admin); + + // A five-outcome market created while the cap was 10. + let market_id = Symbol::new(&env, "existing"); + let existing_outcomes = outcomes_of(&env, 5); + store_market(&env, &contract_id, &market_id, existing_outcomes.clone()); + + client(&env, &contract_id).set_max_outcomes_per_market(&admin, &3u32); + assert_eq!(client(&env, &contract_id).get_max_outcomes_per_market(), 3); + + // New markets above the lowered cap are rejected... + in_contract(&env, &contract_id, || { + assert_eq!( + OutcomeCapManager::validate_outcome_count(&env, &outcomes_of(&env, 5)), + Err(Error::TooManyOutcomes) + ); + }); + + // ...but the live market keeps its outcomes: lowering the cap must never + // invalidate a market whose bets would then be unresolvable. + let stored = in_contract(&env, &contract_id, || { + MarketStateManager::get_market(&env, &market_id).expect("market must still exist") + }); + assert_eq!(stored.outcomes, existing_outcomes); +} + +#[test] +fn test_non_admin_cannot_set_the_cap() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = setup(&env, &admin); + let stranger = Address::generate(&env); + + let result = client(&env, &contract_id).try_set_max_outcomes_per_market(&stranger, &12u32); + assert_contract_error(result, Error::Unauthorized); + assert_eq!( + client(&env, &contract_id).get_max_outcomes_per_market(), + DEFAULT_MAX_OUTCOMES_PER_MARKET, + "a rejected update must not change the stored cap" + ); +} + +#[test] +fn test_cap_below_minimum_is_rejected() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = setup(&env, &admin); + + let result = client(&env, &contract_id) + .try_set_max_outcomes_per_market(&admin, &(MIN_OUTCOMES_PER_MARKET - 1)); + assert_contract_error(result, Error::InvalidInput); + assert_eq!( + client(&env, &contract_id).get_max_outcomes_per_market(), + DEFAULT_MAX_OUTCOMES_PER_MARKET + ); +} + +#[test] +fn test_cap_above_ceiling_is_rejected() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = setup(&env, &admin); + + let result = client(&env, &contract_id) + .try_set_max_outcomes_per_market(&admin, &(MAX_OUTCOMES_PER_MARKET_CEILING + 1)); + assert_contract_error(result, Error::InvalidInput); + assert_eq!( + client(&env, &contract_id).get_max_outcomes_per_market(), + DEFAULT_MAX_OUTCOMES_PER_MARKET + ); +} + +#[test] +fn test_cap_change_emits_event() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = setup(&env, &admin); + + assert_eq!(count_events_with_topic(&env, symbol_short!("mxoutc")), 0); + client(&env, &contract_id).set_max_outcomes_per_market(&admin, &15u32); + assert_eq!(count_events_with_topic(&env, symbol_short!("mxoutc")), 1); +} + +// --------------------------------------------------------------------------- +// Enforcement on the write paths +// --------------------------------------------------------------------------- + +#[test] +fn test_create_market_rejects_outcomes_above_the_cap() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = setup(&env, &admin); + + let result = client(&env, &contract_id).try_create_market( + &admin, + &String::from_str(&env, "Which team wins the league?"), + &outcomes_of(&env, DEFAULT_MAX_OUTCOMES_PER_MARKET + 1), + &30u32, + &oracle_config(&env), + &None, + &86_400u64, + &None, + &None, + &None, + &None, + &None, + ); + + assert_contract_error(result, Error::TooManyOutcomes); +} + +#[test] +fn test_create_market_accepts_outcomes_at_the_cap_boundary() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = setup(&env, &admin); + let outcomes = outcomes_of(&env, DEFAULT_MAX_OUTCOMES_PER_MARKET); + + // The cap is the last check before the creation fee is taken, so a market + // with exactly `cap` outcomes must pass outcome validation. + in_contract(&env, &contract_id, || { + assert_eq!( + OutcomeCapManager::validate_outcome_count(&env, &outcomes), + Ok(()) + ); + }); +} + +#[test] +fn test_update_event_outcomes_cannot_bypass_the_cap() { + let env = Env::default(); + let admin = Address::generate(&env); + let contract_id = setup(&env, &admin); + let market_id = Symbol::new(&env, "growable"); + store_market(&env, &contract_id, &market_id, outcomes_of(&env, 2)); + + let result = client(&env, &contract_id).try_update_event_outcomes( + &admin, + &market_id, + &outcomes_of(&env, DEFAULT_MAX_OUTCOMES_PER_MARKET + 1), + ); + + assert_contract_error(result, Error::TooManyOutcomes); +} diff --git a/contracts/predictify-hybrid/src/tests/mod.rs b/contracts/predictify-hybrid/src/tests/mod.rs index 5fa865d6..765e2f6b 100644 --- a/contracts/predictify-hybrid/src/tests/mod.rs +++ b/contracts/predictify-hybrid/src/tests/mod.rs @@ -45,4 +45,7 @@ pub mod disputes_gas_snap; /// Covers every code path that should return or panic with `MarketClosed`, /// boundary conditions around `end_time`, and verifies the error metadata /// (code, description, string code). -pub mod market_closed_tests; \ No newline at end of file +pub mod market_closed_tests; + +/// Runtime-configurable cap on outcomes per market (issue #009). +pub mod max_outcomes_cap_tests; \ No newline at end of file