diff --git a/CHANGELOG.md b/CHANGELOG.md index bc71de1..94646a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/contracts/predictify-hybrid/src/lib.rs b/contracts/predictify-hybrid/src/lib.rs index dbcc22a..082bb99 100644 --- a/contracts/predictify-hybrid/src/lib.rs +++ b/contracts/predictify-hybrid/src/lib.rs @@ -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 diff --git a/contracts/predictify-hybrid/src/markets.rs b/contracts/predictify-hybrid/src/markets.rs index 09cf10d..bf8849a 100644 --- a/contracts/predictify-hybrid/src/markets.rs +++ b/contracts/predictify-hybrid/src/markets.rs @@ -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: @@ -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 diff --git a/contracts/predictify-hybrid/src/tests/mod.rs b/contracts/predictify-hybrid/src/tests/mod.rs index 765e2f6..932cf93 100644 --- a/contracts/predictify-hybrid/src/tests/mod.rs +++ b/contracts/predictify-hybrid/src/tests/mod.rs @@ -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; \ No newline at end of file +/// Creator-gated question edits before any stake is committed (issue #004). +pub mod update_market_question_tests; diff --git a/contracts/predictify-hybrid/src/tests/update_market_question_tests.rs b/contracts/predictify-hybrid/src/tests/update_market_question_tests.rs new file mode 100644 index 0000000..b54380e --- /dev/null +++ b/contracts/predictify-hybrid/src/tests/update_market_question_tests.rs @@ -0,0 +1,320 @@ +//! Tests for the creator-gated `update_market_question` entrypoint (issue #004). +//! +//! A market creator may correct the question text only while the market is +//! still `Active`, before `end_time`, and while `total_staked == 0`. Those +//! conditions keep the edit harmless: nobody can have committed funds against +//! text that later changes. +//! +//! ## Coverage matrix +//! +//! | Scenario | Expected result | +//! |---|---| +//! | Creator edits before any stake | `Ok(())`, question + commitment updated | +//! | Edit after a stake exists | [`Error::InvalidState`] | +//! | Edit after a vote exists | [`Error::InvalidState`] | +//! | Edit by a non-creator | [`Error::Unauthorized`] | +//! | Edit after `end_time` | [`Error::MarketClosed`] | +//! | Edit on a non-active market | [`Error::InvalidState`] | +//! | Empty question | [`Error::InvalidQuestion`] | +//! | Question longer than [`MAX_QUESTION_LENGTH`] | [`Error::QuestionTooLong`] | +//! | Question exactly at the limit | `Ok(())` | +//! +//! ## Running +//! +//! ```bash +//! cargo test -p predictify-hybrid -- update_market_question_tests +//! ``` + +#![cfg(test)] + +extern crate std; + +use soroban_sdk::{ + testutils::{Address as _, Ledger}, + Address, Env, String, Symbol, Vec, +}; + +use crate::err::Error; +use crate::markets::{MarketStateManager, MAX_QUESTION_LENGTH}; +use crate::types::{Market, MarketState, OracleConfig, OracleProvider}; +use crate::{PredictifyHybrid, PredictifyHybridClient}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const DAY_SECS: u64 = 86_400; + +/// Returns a client bound to a registered contract instance. +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), + } +} + +/// 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"), + } +} + +/// Initializes the contract and creates a 30-day market owned by `admin`. +fn setup_market(env: &Env, admin: &Address) -> (Address, Symbol, Vec) { + env.mock_all_auths(); + env.ledger().set_timestamp(1_735_689_600); + + let contract_id = env.register(PredictifyHybrid, ()); + client(env, &contract_id).initialize(admin, &Some(200i128), &None); + + let outcomes = Vec::from_array( + env, + [String::from_str(env, "yes"), String::from_str(env, "no")], + ); + + let market_id = client(env, &contract_id).create_market( + admin, + &String::from_str(env, "Will BTC close above $100k?"), + &outcomes, + &30u32, + &oracle_config(env), + &None, + &86_400u64, + &None, + &None, + &None, + &None, + &None, + ); + + (contract_id, market_id, outcomes) +} + +/// Loads the stored market record. +fn load_market(env: &Env, contract_id: &Address, market_id: &Symbol) -> Market { + env.as_contract(contract_id, || { + MarketStateManager::get_market(env, market_id).expect("market must exist") + }) +} + +/// Rewrites the stored market record (used to simulate states the contract +/// cannot yet reach through public entrypoints). +fn store_market(env: &Env, contract_id: &Address, market_id: &Symbol, market: &Market) { + env.as_contract(contract_id, || { + MarketStateManager::update_market(env, market_id, market); + }); +} + +/// Builds a question string of `len` ASCII characters. +fn question_of_len(env: &Env, len: u32) -> String { + let base = "x".repeat(len as usize); + String::from_str(env, base.as_str()) +} + +// --------------------------------------------------------------------------- +// Happy path +// --------------------------------------------------------------------------- + +#[test] +fn test_creator_updates_question_before_any_stake() { + let env = Env::default(); + let admin = Address::generate(&env); + let (contract_id, market_id, outcomes) = setup_market(&env, &admin); + let updated = String::from_str(&env, "Will BTC close above $120k?"); + + let before = load_market(&env, &contract_id, &market_id); + + client(&env, &contract_id).update_market_question(&admin, &market_id, &updated); + + let after = load_market(&env, &contract_id, &market_id); + assert_eq!(after.question, updated); + assert_eq!(after.admin, before.admin); + assert_eq!(after.outcomes, outcomes); + assert_eq!(after.total_staked, 0); + assert_ne!( + after.metadata_commitment, before.metadata_commitment, + "metadata commitment must be refreshed with the new question" + ); + assert_eq!( + after.metadata_commitment, + Market::compute_metadata_commitment(&env, &updated, &outcomes, &after.oracle_config), + "stored commitment must match the recomputed canonical commitment" + ); +} + +#[test] +fn test_update_accepts_question_at_max_length() { + let env = Env::default(); + let admin = Address::generate(&env); + let (contract_id, market_id, _) = setup_market(&env, &admin); + let updated = question_of_len(&env, MAX_QUESTION_LENGTH); + + client(&env, &contract_id).update_market_question(&admin, &market_id, &updated); + + assert_eq!(load_market(&env, &contract_id, &market_id).question, updated); +} + +// --------------------------------------------------------------------------- +// Stake / vote gates +// --------------------------------------------------------------------------- + +#[test] +fn test_update_rejected_after_stake() { + let env = Env::default(); + let admin = Address::generate(&env); + let (contract_id, market_id, _) = setup_market(&env, &admin); + + let mut market = load_market(&env, &contract_id, &market_id); + market.total_staked = 1; + store_market(&env, &contract_id, &market_id, &market); + + let result = client(&env, &contract_id).try_update_market_question( + &admin, + &market_id, + &String::from_str(&env, "Edited after a stake"), + ); + + assert_contract_error(result, Error::InvalidState); +} + +#[test] +fn test_update_rejected_after_vote_exists() { + let env = Env::default(); + let admin = Address::generate(&env); + let (contract_id, market_id, _) = setup_market(&env, &admin); + + // Defensive gate: a vote entry (even with inconsistent total bookkeeping) + // must also freeze the question text. + let mut market = load_market(&env, &contract_id, &market_id); + market.votes.set(admin.clone(), String::from_str(&env, "yes")); + store_market(&env, &contract_id, &market_id, &market); + + let result = client(&env, &contract_id).try_update_market_question( + &admin, + &market_id, + &String::from_str(&env, "Edited after a vote"), + ); + + assert_contract_error(result, Error::InvalidState); +} + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +#[test] +fn test_update_rejected_for_non_creator() { + let env = Env::default(); + let admin = Address::generate(&env); + let (contract_id, market_id, _) = setup_market(&env, &admin); + let stranger = Address::generate(&env); + + let result = client(&env, &contract_id).try_update_market_question( + &stranger, + &market_id, + &String::from_str(&env, "Not my market"), + ); + + assert_contract_error(result, Error::Unauthorized); +} + +// --------------------------------------------------------------------------- +// Time / state gates +// --------------------------------------------------------------------------- + +#[test] +fn test_update_rejected_after_end_time() { + let env = Env::default(); + let admin = Address::generate(&env); + let (contract_id, market_id, _) = setup_market(&env, &admin); + + env.ledger().with_mut(|ledger| { + ledger.timestamp += 31 * DAY_SECS; + }); + + let result = client(&env, &contract_id).try_update_market_question( + &admin, + &market_id, + &String::from_str(&env, "Too late"), + ); + + assert_contract_error(result, Error::MarketClosed); +} + +#[test] +fn test_update_rejected_when_market_not_active() { + let env = Env::default(); + let admin = Address::generate(&env); + let (contract_id, market_id, _) = setup_market(&env, &admin); + + let mut market = load_market(&env, &contract_id, &market_id); + market.state = MarketState::Resolved; + store_market(&env, &contract_id, &market_id, &market); + + let result = client(&env, &contract_id).try_update_market_question( + &admin, + &market_id, + &String::from_str(&env, "Resolved market"), + ); + + assert_contract_error(result, Error::InvalidState); +} + +// --------------------------------------------------------------------------- +// Question validation +// --------------------------------------------------------------------------- + +#[test] +fn test_update_rejected_for_empty_question() { + let env = Env::default(); + let admin = Address::generate(&env); + let (contract_id, market_id, _) = setup_market(&env, &admin); + + let result = client(&env, &contract_id).try_update_market_question( + &admin, + &market_id, + &String::from_str(&env, ""), + ); + + assert_contract_error(result, Error::InvalidQuestion); +} + +#[test] +fn test_update_rejected_for_overlong_question() { + let env = Env::default(); + let admin = Address::generate(&env); + let (contract_id, market_id, _) = setup_market(&env, &admin); + + let result = client(&env, &contract_id).try_update_market_question( + &admin, + &market_id, + &question_of_len(&env, MAX_QUESTION_LENGTH + 1), + ); + + assert_contract_error(result, Error::QuestionTooLong); + assert_eq!( + load_market(&env, &contract_id, &market_id).question, + String::from_str(&env, "Will BTC close above $100k?"), + "a rejected update must not modify the stored question" + ); +}