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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions contracts/predictify-hybrid/docs/storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
1 change: 1 addition & 0 deletions contracts/predictify-hybrid/src/event_topic_catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub fn get_event_topic_catalog(env: &Env) -> Vec<EventTopicEntry> {
("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"),
Expand Down
22 changes: 22 additions & 0 deletions contracts/predictify-hybrid/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
) {
Expand Down
64 changes: 61 additions & 3 deletions contracts/predictify-hybrid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
128 changes: 122 additions & 6 deletions contracts/predictify-hybrid/src/markets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
///
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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, u32>(&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<String>) -> 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 {
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions contracts/predictify-hybrid/src/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions contracts/predictify-hybrid/src/storage_docs_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const DATA_KEY_VARIANTS: &[&str] = &[
"UserLastBetTime",
"CoolOffPeriod",
"PerMarketCoolOff",
"MaxOutcomesPerMarket",
];

#[test]
Expand Down
Loading
Loading