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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +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)
- **predictify-hybrid**: `update_market_question` entrypoint lets a market creator fix a typo in the question before any stake is committed. Updates are rejected once `total_staked != 0`, after `end_time`, outside the `Active` state, or for callers other than the market creator; the stored metadata commitment is refreshed so cached metadata fails verification (closes #4)

### Changed

Expand Down
58 changes: 58 additions & 0 deletions contracts/predictify-hybrid/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3769,6 +3769,64 @@ impl PredictifyHybrid {
Ok(())
}

/// Updates a market's question text before any stake is committed.
///
/// Market creators can correct a typo in the published question while the
/// market is still `Active`, before its `end_time`, and while
/// `total_staked == 0`. Once the first stake (vote or bet) is recorded the
/// question is frozen: nobody can have acted on text that later changes,
/// and the resolution criteria stay unambiguous.
///
/// # Parameters
///
/// * `env` - The Soroban environment for blockchain operations
/// * `creator` - The market creator (`market.admin`); must authorize the call
/// * `market_id` - Unique identifier of the market to update
/// * `new_question` - The replacement question text (1-500 bytes)
///
/// # Errors
///
/// - `Error::MarketNotFound` — no market exists for `market_id`
/// - `Error::Unauthorized` — `creator` is not the market creator
/// - `Error::InvalidQuestion` — the new question is empty
/// - `Error::QuestionTooLong` — the new question exceeds 500 bytes
/// - `Error::InvalidState` — market is not `Active`, or a stake/vote exists
/// - `Error::MarketClosed` — the market's `end_time` has passed
///
/// # Effects
///
/// The stored `metadata_commitment` is recomputed so clients holding the
/// previous commitment detect the change through `verify_market_metadata`.
///
/// # Example
///
/// ```rust
/// # use soroban_sdk::{Env, Address, Symbol, String};
/// # use predictify_hybrid::PredictifyHybrid;
/// # let env = Env::default();
/// # let creator = Address::generate(&env);
/// # let market_id = Symbol::new(&env, "market_1");
/// match PredictifyHybrid::update_market_question(
/// env.clone(),
/// creator,
/// market_id,
/// String::from_str(&env, "Will Bitcoin close above $120,000 on 2026-12-31?"),
/// ) {
/// Ok(()) => println!("question updated"),
/// Err(e) => println!("update rejected: {:?}", e),
/// }
/// ```
pub fn update_market_question(
env: Env,
creator: Address,
market_id: Symbol,
new_question: String,
) -> Result<(), Error> {
creator.require_auth();
markets::MarketStateManager::update_question(&env, &market_id, &creator, new_question)
}


/// Updates the description/question of a market (admin only, before betting starts).
///
/// This function allows contract administrators to update the market question
Expand Down
84 changes: 84 additions & 0 deletions contracts/predictify-hybrid/src/markets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ use crate::storage::{
use crate::types::*;
// Oracle imports removed - not currently used

/// Maximum length, in bytes, of a market question.
///
/// Mirrors the documented contract API (1-500 characters) and keeps the stored
/// question bounded, so metadata commitments remain cheap to compute and verify.
pub const MAX_QUESTION_LENGTH: u32 = 500;

/// Market management system for Predictify Hybrid contract
///
/// This module provides a comprehensive market management system with:
Expand Down Expand Up @@ -1197,6 +1203,84 @@ impl MarketStateManager {
Ok(())
}

/// Updates a market's question text before any stake has been committed.
///
/// Market creators regularly publish a market with a typo. Because the
/// question is part of the market's public metadata commitment it must be
/// frozen as soon as real value is at risk: allowing edits after the first
/// stake would let a creator move the goalposts after observing bets.
///
/// # Rules
///
/// - `creator` must be the market creator (`market.admin`).
/// - The market must still be `Active` and its `end_time` must not have passed.
/// - `total_staked` must be exactly zero, and neither `votes` nor `stakes`
/// may contain entries.
/// - The new question must be non-empty and at most [`MAX_QUESTION_LENGTH`]
/// bytes long.
///
/// # Effects
///
/// - Rewrites `question` and refreshes `metadata_commitment`, so clients
/// holding the previous commitment fail `verify_market_metadata` and
/// re-read the market.
/// - Emits [`crate::events::MarketDescriptionUpdatedEvent`] carrying the old
/// and new question text (the question is the market's description).
///
/// # Errors
///
/// - [`Error::MarketNotFound`] — no market exists for `market_id`
/// - [`Error::Unauthorized`] — `creator` is not the market creator
/// - [`Error::InvalidQuestion`] — the new question is empty
/// - [`Error::QuestionTooLong`] — the new question exceeds [`MAX_QUESTION_LENGTH`]
/// - [`Error::InvalidState`] — market is not `Active`, or a stake/vote exists
/// - [`Error::MarketClosed`] — `end_time` has already passed
pub fn update_question(
env: &Env,
market_id: &Symbol,
creator: &Address,
new_question: String,
) -> Result<(), Error> {
if new_question.is_empty() {
return Err(Error::InvalidQuestion);
}
if new_question.len() > MAX_QUESTION_LENGTH {
return Err(Error::QuestionTooLong);
}

let mut market = Self::get_market(env, market_id)?;

if market.admin != *creator {
return Err(Error::Unauthorized);
}
if market.state != MarketState::Active {
return Err(Error::InvalidState);
}
if env.ledger().timestamp() >= market.end_time {
return Err(Error::MarketClosed);
}
// Stake gate: once anyone has committed funds the question is frozen,
// so no participant can have acted on text that later changes.
if market.total_staked != 0 || !market.votes.is_empty() || !market.stakes.is_empty() {
return Err(Error::InvalidState);
}

let old_question = market.question.clone();
market.question = new_question.clone();
market.refresh_metadata_commitment(env);
Self::update_market(env, market_id, &market);

EventEmitter::emit_market_description_updated(
env,
market_id,
&old_question,
&new_question,
creator,
);

Ok(())
}

/// Removes a market from persistent storage after proper closure.
///
/// This function safely removes a market from storage, ensuring it's
Expand Down
4 changes: 2 additions & 2 deletions contracts/predictify-hybrid/src/tests/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,5 @@ pub mod disputes_gas_snap;
/// (code, description, string code).
pub mod market_closed_tests;

/// Runtime-configurable cap on outcomes per market (issue #009).
pub mod max_outcomes_cap_tests;
/// Creator-gated question edits before any stake is committed (issue #004).
pub mod update_market_question_tests;
Loading
Loading