diff --git a/contracts/admin/README.md b/contracts/admin/README.md index f440a1ac..c9d3a874 100644 --- a/contracts/admin/README.md +++ b/contracts/admin/README.md @@ -17,18 +17,20 @@ table is the audit surface for admin access. | --- | --- | --- | --- | | `initialize(env, admin)` | The `admin` address must authorize the call (`admin.require_auth()`). Succeeds at most once. | Yes — `DataKey::Admin` (instance) | `Result<(), ContractError>` | | `admin(env)` | None — read-only view. | No | `Result` | -| `set_admin_cooldown(env, admin, seconds)` | `admin.require_auth()` **and** `admin` must equal the stored admin. | Yes — `DataKey::AdminCooldownSeconds` (persistent) | `Result<(), ContractError>` | -| `get_admin_cooldown(env)` | None — read-only view. | No | `u64` (`0` when unset) | +| `set_admin_cooldown(env, admin, seconds)` | `admin.require_auth()` **and** `admin` must equal the stored admin. `seconds` must be at least 60. | Yes — `DataKey::AdminCooldownSeconds` (persistent) | `Result<(), ContractError>` | +| `disable_admin_cooldown(env, admin)` | `admin.require_auth()` **and** `admin` must equal the stored admin. | Yes — `DataKey::AdminCooldownSeconds` (persistent, set to 0) | `Result<(), ContractError>` | +| `get_admin_cooldown(env)` | None — read-only view. | No | `u64` (`0` when unset or explicitly disabled) | | `check_admin_cooldown(env, admin, function_name)` | `admin.require_auth()` **and** `admin` must equal the stored admin. | Yes — `DataKey::AdminLastAction(function_name)` on success only | `Result<(), ContractError>` | ### Authorization notes - **`initialize`** is the only way to set the admin, and it is deliberately not idempotent: a second call returns `AlreadyInitialized` (`1`). -- **`set_admin_cooldown`** and **`check_admin_cooldown`** are the only mutating - entrypoints that read the stored admin. Both return `AdminNotSet` (`2`) before - initialization, and `Unauthorized` (`3`) for any caller that has not proved it - is the stored admin. Authorization is checked *before* any storage write. +- **`set_admin_cooldown`**, **`disable_admin_cooldown`**, and + **`check_admin_cooldown`** return `AdminNotSet` (`2`) before initialization, + and `Unauthorized` (`3`) unless the caller is the stored admin. Authorization + is checked before any storage write. Use `disable_admin_cooldown` to explicitly + disable enforcement; `set_admin_cooldown` rejects zero. - **`check_admin_cooldown`** is the throttle other contracts call. While the cooldown is active it returns `AdminActionTimelocked` (`4`) and does **not** update the last-action timestamp, so a blocked call does not extend the window. @@ -41,6 +43,7 @@ table is the audit surface for admin access. pub fn initialize(env: Env, admin: Address) -> Result<(), ContractError> pub fn admin(env: Env) -> Result pub fn set_admin_cooldown(env: Env, admin: Address, seconds: u64) -> Result<(), ContractError> +pub fn disable_admin_cooldown(env: Env, admin: Address) -> Result<(), ContractError> pub fn get_admin_cooldown(env: Env) -> u64 pub fn check_admin_cooldown(env: Env, admin: Address, function_name: Symbol) -> Result<(), ContractError> ``` @@ -70,3 +73,4 @@ cargo test -p admin --test gas_snap - `AdminNotSet = 2`: No admin configured - `Unauthorized = 3`: Caller is not the admin - `AdminActionTimelocked = 4`: Cooldown period has not elapsed +- `InvalidCooldown = 5`: Cooldown must be at least 60 seconds diff --git a/contracts/admin/src/lib.rs b/contracts/admin/src/lib.rs index 04854ea4..774255e4 100644 --- a/contracts/admin/src/lib.rs +++ b/contracts/admin/src/lib.rs @@ -21,8 +21,11 @@ pub enum ContractError { AdminNotSet = 2, Unauthorized = 3, AdminActionTimelocked = 4, + InvalidCooldown = 5, } +const MIN_COOLDOWN_SECONDS: u64 = 60; + #[contract] pub struct AdminContract; @@ -62,10 +65,34 @@ impl AdminContract { return Err(ContractError::Unauthorized); } + if seconds < MIN_COOLDOWN_SECONDS { + return Err(ContractError::InvalidCooldown); + } + env.storage().persistent().set(&DataKey::AdminCooldownSeconds, &seconds); Ok(()) } + /// Explicitly disable admin action cooldown enforcement. + pub fn disable_admin_cooldown(env: Env, admin: Address) -> Result<(), ContractError> { + admin.require_auth(); + + let stored_admin: Address = env + .storage() + .instance() + .get(&DataKey::Admin) + .ok_or(ContractError::AdminNotSet)?; + + if admin != stored_admin { + return Err(ContractError::Unauthorized); + } + + env.storage() + .persistent() + .set(&DataKey::AdminCooldownSeconds, &0u64); + Ok(()) + } + /// Get the configured admin cooldown period in seconds. pub fn get_admin_cooldown(env: Env) -> u64 { env.storage() diff --git a/contracts/admin/tests/gas_snap.rs b/contracts/admin/tests/gas_snap.rs index d4d4e9e4..8bbb14a9 100644 --- a/contracts/admin/tests/gas_snap.rs +++ b/contracts/admin/tests/gas_snap.rs @@ -198,6 +198,51 @@ fn rejects_unauthorized_admin_call() { fixture.client().try_set_admin_cooldown(&stranger, &100), Err(Ok(ContractError::Unauthorized)) ); + assert_eq!( + fixture.client().try_disable_admin_cooldown(&stranger), + Err(Ok(ContractError::Unauthorized)) + ); +} + +#[test] +fn rejects_cooldowns_below_minimum() { + let fixture = Fixture::new(); + fixture.initialize(); + + assert_eq!( + fixture + .client() + .try_set_admin_cooldown(&fixture.admin, &0), + Err(Ok(ContractError::InvalidCooldown)) + ); + assert_eq!( + fixture + .client() + .try_set_admin_cooldown(&fixture.admin, &59), + Err(Ok(ContractError::InvalidCooldown)) + ); +} + +#[test] +fn disable_admin_cooldown_is_explicit() { + let fixture = Fixture::new(); + fixture.initialize(); + let client = fixture.client(); + let function_name = Symbol::new(&fixture.env, "test_action"); + + fixture.env.ledger().with_mut(|ledger| { + ledger.timestamp = 100; + }); + client.set_admin_cooldown(&fixture.admin, &300); + assert!(client + .check_admin_cooldown(&fixture.admin, &function_name) + .is_ok()); + + client.disable_admin_cooldown(&fixture.admin); + assert_eq!(client.get_admin_cooldown(), 0); + assert!(client + .check_admin_cooldown(&fixture.admin, &function_name) + .is_ok()); } #[test] diff --git a/contracts/fees/src/lib.rs b/contracts/fees/src/lib.rs index b8348bff..ab7cf4a4 100644 --- a/contracts/fees/src/lib.rs +++ b/contracts/fees/src/lib.rs @@ -102,6 +102,8 @@ enum DataKey { /// Default timelock cooldown in seconds for fee updates (24 hours). const DEFAULT_TIMELOCK_SECONDS: u64 = 86_400; +/// Minimum timelock cooldown in seconds for fee updates (1 hour). +const MIN_TIMELOCK_SECONDS: u64 = 3_600; /// Maximum platform fee percentage in basis points (100% = 10_000). const MAX_FEE_PERCENTAGE: i128 = 10_000; @@ -306,27 +308,7 @@ impl FeesContract { // Ensure fees are not paused for config updates (consistency) Self::assert_fees_not_paused(&env)?; - let now = env.ledger().timestamp(); - let eta = now.saturating_add(DEFAULT_TIMELOCK_SECONDS); - - let queued = PendingFeeConfig { - config: new_config.clone(), - admin: admin.clone(), - proposed_at: now, - apply_eta: eta, - }; - - // Queue pending configuration for governance inspection - env.storage() - .persistent() - .set(&Symbol::new(&env, FEE_QUEUED_KEY), &queued); - - // Consume active commit if present - env.storage() - .persistent() - .remove(&Symbol::new(&env, FEE_COMMIT_KEY)); - - Ok(()) + Self::queue_fee_config(&env, &admin, new_config, DEFAULT_TIMELOCK_SECONDS) } /// Applies a previously queued fee configuration update after its timelock has expired. @@ -385,6 +367,7 @@ impl FeesContract { /// Sets only the platform fee percentage. /// /// Convenience entrypoint that preserves all other fee configuration values. + /// The update is queued and subject to the standard fee configuration timelock. /// /// # Authorization /// The `admin` caller must authenticate via `require_auth()` and be the @@ -408,11 +391,7 @@ impl FeesContract { let mut config = Self::get_fee_config_internal(&env)?; config.platform_fee_percentage = fee_percentage; - env.storage() - .persistent() - .set(&Symbol::new(&env, FEE_CONFIG_KEY), &config); - - Ok(()) + Self::queue_fee_config(&env, &admin, config, DEFAULT_TIMELOCK_SECONDS) } /// Authorizes a contract to record fees. @@ -742,6 +721,34 @@ impl FeesContract { .get(&Symbol::new(env, FEE_QUEUED_KEY)) } + fn queue_fee_config( + env: &Env, + admin: &Address, + config: FeeConfig, + delay_seconds: u64, + ) -> Result<(), ContractError> { + if delay_seconds < MIN_TIMELOCK_SECONDS { + return Err(ContractError::InvalidInput); + } + + let now = env.ledger().timestamp(); + let queued = PendingFeeConfig { + config, + admin: admin.clone(), + proposed_at: now, + apply_eta: now.saturating_add(delay_seconds), + }; + + env.storage() + .persistent() + .set(&Symbol::new(env, FEE_QUEUED_KEY), &queued); + env.storage() + .persistent() + .remove(&Symbol::new(env, FEE_COMMIT_KEY)); + + Ok(()) + } + /// Validates the caller is the registered admin. fn assert_is_admin(env: &Env, caller: &Address) -> Result<(), ContractError> { let stored_admin = Self::get_admin_internal(env)?; @@ -818,3 +825,33 @@ impl FeesContract { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use soroban_sdk::testutils::Address as _; + + #[test] + fn queue_fee_config_rejects_delay_below_minimum() { + let env = Env::default(); + let admin = Address::generate(&env); + let config = FeeConfig { + platform_fee_percentage: 200, + creation_fee: 0, + min_fee_amount: 1, + max_fee_amount: MAX_FEE_AMOUNT, + collection_threshold: 100_000_000, + fees_enabled: true, + }; + + assert_eq!( + FeesContract::queue_fee_config( + &env, + &admin, + config, + MIN_TIMELOCK_SECONDS - 1, + ), + Err(ContractError::InvalidInput) + ); + } +} diff --git a/contracts/fees/tests/auth_boundary.rs b/contracts/fees/tests/auth_boundary.rs index 0915dae3..eaaa88cd 100644 --- a/contracts/fees/tests/auth_boundary.rs +++ b/contracts/fees/tests/auth_boundary.rs @@ -228,11 +228,31 @@ fn test_set_platform_fee_succeeds_with_admin_auth() { let admin = Address::generate(&env); let client = register_and_initialize(&env, &admin); + let original_config = client.get_fee_config(); client.set_platform_fee(&admin, &300); + assert_eq!(client.get_fee_config(), original_config); + + let pending = client + .get_pending_fee_config() + .expect("platform fee update should be queued"); + assert_eq!(pending.config.platform_fee_percentage, 300); + assert_eq!(pending.apply_eta, env.ledger().timestamp() + 86_400); + + let early_result = client.try_apply_fee_update(&admin); + match early_result { + Err(Ok(ContractError::FeeRevealTooEarly)) => {} + other => panic!("Expected FeeRevealTooEarly, got: {:?}", other), + } let auths = env.auths(); assert_eq!(auths.len(), 1); assert_eq!(auths[0].0, admin); + + env.ledger().with_mut(|ledger| { + ledger.timestamp += 86_400; + }); + client.apply_fee_update(&admin); + assert_eq!(client.get_fee_config().platform_fee_percentage, 300); } #[test] diff --git a/contracts/markets/src/errors.rs b/contracts/markets/src/errors.rs index eb2ccb4c..b1724aee 100644 --- a/contracts/markets/src/errors.rs +++ b/contracts/markets/src/errors.rs @@ -48,6 +48,8 @@ pub enum ContractError { InvalidPayoutCurve = 19, /// Insufficient balance for withdrawal or operation. InsufficientBalance = 20, + /// The configured maximum number of markets has been reached. + MarketLimitReached = 21, } #[cfg(test)] @@ -76,5 +78,6 @@ mod tests { assert_eq!(ContractError::ProposalNotExecutable as u32, 18); assert_eq!(ContractError::InvalidPayoutCurve as u32, 19); assert_eq!(ContractError::InsufficientBalance as u32, 20); + assert_eq!(ContractError::MarketLimitReached as u32, 21); } } diff --git a/contracts/markets/src/lib.rs b/contracts/markets/src/lib.rs index 09bcc6a9..6e99383b 100644 --- a/contracts/markets/src/lib.rs +++ b/contracts/markets/src/lib.rs @@ -8,25 +8,36 @@ //! //! # Auth Matrix //! -//! | Function | Required Role | -//! |------------------------|----------------------------| -//! | `initialize` | Admin (one-time) | -//! | `create_market` | Creator (any address) | -//! | `place_bet` | Bettor (any address) | -//! | `resolve_market` | Market creator | -//! | `claim_winnings` | Winner (bettor) | -//! | `cancel_market` | Market creator | -//! | `withdraw_funds` | Market creator | -//! | `update_market_params` | Market creator | -//! | `add_liquidity` | Liquidity provider | -//! | `remove_liquidity` | Liquidity provider | -//! | `pause_markets` | Admin | -//! | `unpause_markets` | Admin | -//! | `transfer_ownership` | Admin | -//! | `add_admin` | Admin | -//! | `get_admin` | Anyone (read-only) | -//! | `is_paused` | Anyone (read-only) | -//! | `version` | Anyone (read-only) | +//! | Function | Required Role | +//! |-----------------------------|----------------------------| +//! | `create_market` | Creator (any address) | +//! | `create_market_with_curve` | Creator (any address) | +//! | `set_market_payout_curve` | Market creator or Admin | +//! | `place_bet` | Bettor (any address) | +//! | `resolve_market` | Market creator or Admin | +//! | `claim_winnings` | Winner / Claimant (bettor) | +//! | `cancel_market` | Market creator | +//! | `withdraw_funds` | Market creator | +//! | `update_market_params` | Market creator | +//! | `add_liquidity` | Liquidity provider | +//! | `remove_liquidity` | Liquidity provider | +//! | `pause_markets` | Admin | +//! | `unpause_markets` | Admin | +//! | `set_market_limit` | Admin | +//! | `get_market_limit` | Anyone (read-only) | +//! | `propose_pause` | Admin | +//! | `approve_pause` | Admin | +//! | `execute_pause` | Admin | +//! | `cancel_pause` | Admin | +//! | `get_pause_proposal` | Admin | +//! | `set_pause_config` | Admin | +//! | `add_admin` | Admin | +//! | `transfer_ownership` | Admin | +//! | `deposit_user_balance` | User (any address) | +//! | `withdraw_user_balance` | User (any address) | +//! | `collect_user_fees` | Admin | +//! | `set_fee_config` | Admin | +//! | `version` | Anyone (read-only) | pub mod errors; @@ -37,12 +48,17 @@ pub use errors::ContractError; /// Type alias used by generated client code and test harnesses. pub type Error = ContractError; +/// Finite safety bound for deployments that have not configured a market limit. +const DEFAULT_MAX_MARKET_COUNT: u32 = 10_000; + /// Persistent-storage keys used by the Markets contract. #[contracttype] #[derive(Clone)] pub enum DataKey { /// Sequential counter for generating unique market IDs. MarketCounter, + /// Maximum number of markets that may be created. + MarketLimit, /// Market data keyed by the numeric market ID. Market(u32), /// Bet placed by a user on a specific market. @@ -59,6 +75,9 @@ pub enum DataKey { /// Written **before** any token transfer in `claim_winnings` to uphold the /// checks-effects-interactions pattern and prevent reentrancy double-claims. ClaimedBet(u32, Address), + /// Fractional payout numerator carried forward per market outcome. + PayoutRemainder(u32, u32), + /// Winning outcome indices recorded when a market is resolved. WinningOutcomes(u32), @@ -340,6 +359,743 @@ impl MarketsContract { market } + /// Read the metadata required by the resolution subsystem. + pub fn get_market_for_resolution( + env: Env, + market_id: u32, + ) -> Option<(Address, u64, u32, bool, bool)> { + let key = DataKey::Market(market_id); + let market: Option = env.storage().persistent().get(&key); + if market.is_some() { + let ttl = 6_307_200u32.min(env.storage().max_ttl()); + env.storage().persistent().extend_ttl(&key, ttl, ttl); + } + market.map(|market| { + ( + market.creator, + market.end_time, + market.outcome_tags.len(), + market.resolved, + market.cancelled, + ) + }) + } + + /// Return every winning outcome for a resolved market. + pub fn get_winning_outcomes(env: Env, market_id: u32) -> Option> { + let market_key = DataKey::Market(market_id); + let market: Option = env.storage().persistent().get(&market_key); + market.map(|market| { + let ttl = 6_307_200u32.min(env.storage().max_ttl()); + env.storage().persistent().extend_ttl(&market_key, ttl, ttl); + let outcomes_key = DataKey::WinningOutcomes(market_id); + if env.storage().persistent().has(&outcomes_key) { + env.storage() + .persistent() + .extend_ttl(&outcomes_key, ttl, ttl); + } + Self::winning_outcomes(&env, market_id, &market) + }) + } + + /// Returns whether market operations are currently paused globally. + pub fn is_paused(env: Env) -> bool { + env.storage() + .persistent() + .get(&DataKey::Paused) + .unwrap_or(false) + } + + /// Returns the primary admin address if set. + pub fn get_admin(env: Env) -> Option
{ + env.storage().persistent().get(&DataKey::Admin) + } + + /// Returns the configured market limit, or the finite default if unset. + pub fn get_market_limit(env: Env) -> u32 { + env.storage() + .persistent() + .get(&DataKey::MarketLimit) + .unwrap_or(DEFAULT_MAX_MARKET_COUNT) + } + + // ----------------------------------------------------------------------- + // Market Lifecycle + // ----------------------------------------------------------------------- + + /// Creates a new prediction market with default WinnerTakesAll payout curve. + pub fn create_market( + env: Env, + creator: Address, + question: String, + description: String, + end_time: u64, + resolution_source: String, + outcome_tags: Vec, + ) -> u32 { + Self::create_market_with_curve( + env.clone(), + creator, + question, + description, + end_time, + resolution_source, + outcome_tags, + PayoutCurveConfig::winner_takes_all(&env), + ) + } + + /// Creates a new prediction market with a specific configurable payout curve (#014). + pub fn create_market_with_curve( + env: Env, + creator: Address, + question: String, + description: String, + end_time: u64, + resolution_source: String, + outcome_tags: Vec, + curve: PayoutCurveConfig, + ) -> u32 { + creator.require_auth(); + + let counter: u32 = env + .storage() + .persistent() + .get(&DataKey::MarketCounter) + .unwrap_or(0u32); + if counter >= Self::get_market_limit(env.clone()) { + panic_with_error!(env, ContractError::MarketLimitReached); + } + + if outcome_tags.len() < 2 { + panic_with_error!(env, ContractError::InvalidConfig); + } + + Self::validate_payout_curve(&env, &curve, outcome_tags.len()); + + let market_id = match counter.checked_add(1) { + Some(id) => id, + None => panic_with_error!(env, ContractError::Overflow), + }; + + env.storage() + .persistent() + .set(&DataKey::MarketCounter, &market_id); + + let market = MarketData { + creator: creator.clone(), + question, + description, + end_time, + resolution_source, + outcome_tags, + resolved: false, + winning_outcome: 0, + cancelled: false, + }; + env.storage() + .persistent() + .set(&DataKey::Market(market_id), &market); + + env.storage() + .persistent() + .set(&DataKey::MarketPayoutCurve(market_id), &curve); + + market_id + } + + /// Configures the payout curve for an existing market before resolution and betting (#014). + pub fn set_market_payout_curve( + env: Env, + caller: Address, + market_id: u32, + curve: PayoutCurveConfig, + ) { + caller.require_auth(); + + let market: MarketData = match env.storage().persistent().get(&DataKey::Market(market_id)) { + Some(m) => m, + None => panic_with_error!(env, ContractError::MarketNotFound), + }; + + let now = env.ledger().timestamp(); + if now >= market.end_time { + panic_with_error!(env, ContractError::MarketClosed); + } + + if market.resolved || market.cancelled { + panic_with_error!(env, ContractError::InvalidState); + } + + if caller != market.creator && !Self::is_admin_internal(&env, &caller) { + panic_with_error!(env, ContractError::Unauthorized); + } + + // Prevent modifying curve if wagers are already placed on the market + let total_pool: i128 = env + .storage() + .persistent() + .get(&DataKey::MarketTotalPool(market_id)) + .unwrap_or(0); + if total_pool > 0 { + panic_with_error!(env, ContractError::InvalidState); + } + + Self::validate_payout_curve(&env, &curve, market.outcome_tags.len()); + + env.storage() + .persistent() + .set(&DataKey::MarketPayoutCurve(market_id), &curve); + } + + /// Reads the configured payout curve for a market (#014). + pub fn get_market_payout_curve(env: Env, market_id: u32) -> PayoutCurveConfig { + env.storage() + .persistent() + .get(&DataKey::MarketPayoutCurve(market_id)) + .unwrap_or_else(|| PayoutCurveConfig::winner_takes_all(&env)) + } + + /// Places a bet on a specific market outcome, updating bet enumeration (#022) + /// and pool balances (#014). + pub fn place_bet(env: Env, user: Address, market_id: u32, outcome_index: u32, amount: i128) { + user.require_auth(); + + if Self::is_paused(env.clone()) { + panic_with_error!(env, ContractError::InvalidState); + } + + let market: MarketData = match env.storage().persistent().get(&DataKey::Market(market_id)) { + Some(m) => m, + None => panic_with_error!(env, ContractError::MarketNotFound), + }; + + if market.resolved || market.cancelled { + panic_with_error!(env, ContractError::InvalidState); + } + + if outcome_index >= market.outcome_tags.len() { + panic_with_error!(env, ContractError::InvalidOutcome); + } + + if amount <= 0 { + panic_with_error!(env, ContractError::StakeTooSmall); + } + + // Update or record the user's bet + let bet = BetData { + outcome_index, + amount, + }; + env.storage() + .persistent() + .set(&DataKey::Bet(market_id, user.clone()), &bet); + + // Maintain persistent user bet index for enumeration (#022) + let mut user_markets: Vec = env + .storage() + .persistent() + .get(&DataKey::UserBetMarkets(user.clone())) + .unwrap_or(Vec::new(&env)); + + if !user_markets.contains(market_id) { + user_markets.push_back(market_id); + env.storage() + .persistent() + .set(&DataKey::UserBetMarkets(user), &user_markets); + } + + // Maintain market and outcome pool accounting (#014) + let total_pool: i128 = env + .storage() + .persistent() + .get(&DataKey::MarketTotalPool(market_id)) + .unwrap_or(0); + let new_total = match total_pool.checked_add(amount) { + Some(t) => t, + None => panic_with_error!(env, ContractError::Overflow), + }; + env.storage() + .persistent() + .set(&DataKey::MarketTotalPool(market_id), &new_total); + + let outcome_pool: i128 = env + .storage() + .persistent() + .get(&DataKey::OutcomePool(market_id, outcome_index)) + .unwrap_or(0); + let new_outcome_pool = match outcome_pool.checked_add(amount) { + Some(t) => t, + None => panic_with_error!(env, ContractError::Overflow), + }; + env.storage().persistent().set( + &DataKey::OutcomePool(market_id, outcome_index), + &new_outcome_pool, + ); + } + + /// Resolves a market by recording the winning outcome. + /// + /// Only the market creator or a platform admin may resolve a market. + pub fn resolve_market(env: Env, resolver: Address, market_id: u32, winning_outcome: u32) { + resolver.require_auth(); + + let mut market: MarketData = + match env.storage().persistent().get(&DataKey::Market(market_id)) { + Some(m) => m, + None => panic_with_error!(env, ContractError::MarketNotFound), + }; + + // Role check: resolver must be the market creator or a platform admin. + if resolver != market.creator && !Self::is_admin_internal(&env, &resolver) { + panic_with_error!(env, ContractError::Unauthorized); + } + + if market.resolved { + panic_with_error!(env, ContractError::MarketAlreadyResolved); + } + + if winning_outcome >= market.outcome_tags.len() { + panic_with_error!(env, ContractError::InvalidOutcome); + } + + market.resolved = true; + market.winning_outcome = winning_outcome; + env.storage() + .persistent() + .set(&DataKey::Market(market_id), &market); + let mut winning_outcomes = Vec::new(&env); + winning_outcomes.push_back(winning_outcome); + env.storage() + .persistent() + .set(&DataKey::WinningOutcomes(market_id), &winning_outcomes); + let ttl = 6_307_200u32.min(env.storage().max_ttl()); + env.storage() + .persistent() + .extend_ttl(&DataKey::WinningOutcomes(market_id), ttl, ttl); + } + + /// Resolves a market with one or more winning outcomes. + /// + /// Only the market creator may resolve it, and only after betting closes. + pub fn resolve_market_with_outcomes( + env: Env, + resolver: Address, + market_id: u32, + winning_outcomes: Vec, + ) { + resolver.require_auth(); + + let mut market: MarketData = + match env.storage().persistent().get(&DataKey::Market(market_id)) { + Some(market) => market, + None => panic_with_error!(env, ContractError::MarketNotFound), + }; + + if market.resolved { + panic_with_error!(env, ContractError::MarketAlreadyResolved); + } + if market.cancelled { + panic_with_error!(env, ContractError::InvalidState); + } + if resolver != market.creator { + panic_with_error!(env, ContractError::Unauthorized); + } + if env.ledger().timestamp() < market.end_time { + panic_with_error!(env, ContractError::MarketClosed); + } + if winning_outcomes.is_empty() { + panic_with_error!(env, ContractError::InvalidConfig); + } + + if winning_outcomes.len() > market.outcome_tags.len() { + panic_with_error!(env, ContractError::InvalidConfig); + } + let mut seen = Map::::new(&env); + for outcome in winning_outcomes.iter() { + if outcome >= market.outcome_tags.len() { + panic_with_error!(env, ContractError::InvalidOutcome); + } + if seen.contains_key(outcome) { + panic_with_error!(env, ContractError::InvalidConfig); + } + seen.set(outcome, true); + } + + market.resolved = true; + market.winning_outcome = winning_outcomes.get(0).unwrap(); + env.storage() + .persistent() + .set(&DataKey::Market(market_id), &market); + env.storage() + .persistent() + .set(&DataKey::WinningOutcomes(market_id), &winning_outcomes); + let ttl = 6_307_200u32.min(env.storage().max_ttl()); + env.storage() + .persistent() + .extend_ttl(&DataKey::WinningOutcomes(market_id), ttl, ttl); + } + + /// Previews the payout amount for a claimant on a market (#014). + /// + /// Outcome-split remainders are assigned in outcome order. Claim-time + /// proportional remainders carry forward per outcome, preserving the + /// allocated pool across claims; previews reflect prior claims. + pub fn calculate_payout(env: Env, market_id: u32, claimant: Address) -> i128 { + Self::calculate_payout_with_remainder(env, market_id, claimant).0 + } + + fn scaled_weight_boundary(env: &Env, total_pool: i128, cumulative_weight: u32) -> i128 { + let whole = total_pool / 10_000; + let fraction = total_pool % 10_000; + let whole_share = match whole.checked_mul(cumulative_weight as i128) { + Some(value) => value, + None => panic_with_error!(env, ContractError::Overflow), + }; + whole_share + (fraction * cumulative_weight as i128) / 10_000 + } + + fn custom_outcome_share( + env: &Env, + total_pool: i128, + curve: &PayoutCurveConfig, + outcome_index: u32, + ) -> i128 { + let mut cumulative_weight = 0u32; + let mut index = 0u32; + for weight in curve.custom_weights.iter() { + if index == outcome_index { + let next_weight = match cumulative_weight.checked_add(weight) { + Some(value) => value, + None => panic_with_error!(env, ContractError::InvalidPayoutCurve), + }; + return Self::scaled_weight_boundary(env, total_pool, next_weight) + - Self::scaled_weight_boundary(env, total_pool, cumulative_weight); + } + cumulative_weight = match cumulative_weight.checked_add(weight) { + Some(value) => value, + None => panic_with_error!(env, ContractError::InvalidPayoutCurve), + }; + index += 1; + } + 0 + } + + fn proportional_payout( + env: &Env, + market_id: u32, + outcome_index: u32, + stake: i128, + outcome_share: i128, + outcome_pool: i128, + ) -> (i128, Option<(u32, i128)>) { + if outcome_pool <= 0 { + return (0, None); + } + + let key = DataKey::PayoutRemainder(market_id, outcome_index); + let remainder: i128 = env.storage().persistent().get(&key).unwrap_or(0); + let numerator = match stake + .checked_mul(outcome_share) + .and_then(|value| value.checked_add(remainder)) + { + Some(value) => value, + None => panic_with_error!(env, ContractError::Overflow), + }; + ( + numerator / outcome_pool, + Some((outcome_index, numerator % outcome_pool)), + ) + } + + fn calculate_payout_with_remainder( + env: Env, + market_id: u32, + claimant: Address, + ) -> (i128, Option<(u32, i128)>) { + let market: MarketData = match env.storage().persistent().get(&DataKey::Market(market_id)) { + Some(m) => m, + None => return (0, None), + }; + if !market.resolved { + return (0, None); + } + + let bet: BetData = match env + .storage() + .persistent() + .get(&DataKey::Bet(market_id, claimant)) + { + Some(b) => b, + None => return (0, None), + }; + + let curve: PayoutCurveConfig = env + .storage() + .persistent() + .get(&DataKey::MarketPayoutCurve(market_id)) + .unwrap_or_else(|| PayoutCurveConfig::winner_takes_all(&env)); + let total_pool: i128 = env + .storage() + .persistent() + .get(&DataKey::MarketTotalPool(market_id)) + .unwrap_or(bet.amount); + let win_pool: i128 = env + .storage() + .persistent() + .get(&DataKey::OutcomePool(market_id, market.winning_outcome)) + .unwrap_or(0); + let winning_outcomes = Self::winning_outcomes(&env, market_id, &market); + + if winning_outcomes.len() > 1 { + if !winning_outcomes.contains(bet.outcome_index) { + return (0, None); + } + let outcome_pool: i128 = env + .storage() + .persistent() + .get(&DataKey::OutcomePool(market_id, bet.outcome_index)) + .unwrap_or(0); + if outcome_pool == 0 { + return (bet.amount, None); + } + + let winner_count = winning_outcomes.len() as i128; + let mut rank = 0i128; + for outcome in winning_outcomes.iter() { + if outcome == bet.outcome_index { + break; + } + rank += 1; + } + let base_share = total_pool / winner_count; + let extra_units = total_pool % winner_count; + let outcome_share = base_share + if rank < extra_units { 1 } else { 0 }; + return Self::proportional_payout( + &env, + market_id, + bet.outcome_index, + bet.amount, + outcome_share, + outcome_pool, + ); + } + + match curve.curve_type { + PayoutCurveConfig::WINNER_TAKES_ALL => { + if bet.outcome_index != market.winning_outcome { + return (0, None); + } + if win_pool == 0 { + return (bet.amount, None); + } + Self::proportional_payout( + &env, + market_id, + bet.outcome_index, + bet.amount, + total_pool, + win_pool, + ) + } + PayoutCurveConfig::TIERED => { + let ru_pool: i128 = env + .storage() + .persistent() + .get(&DataKey::OutcomePool(market_id, curve.runner_up_outcome)) + .unwrap_or(0); + + if ru_pool == 0 { + if bet.outcome_index == market.winning_outcome { + if win_pool == 0 { + return (bet.amount, None); + } + return Self::proportional_payout( + &env, + market_id, + bet.outcome_index, + bet.amount, + total_pool, + win_pool, + ); + } + return (0, None); + } + + if win_pool == 0 { + if bet.outcome_index == curve.runner_up_outcome { + return Self::proportional_payout( + &env, + market_id, + bet.outcome_index, + bet.amount, + total_pool, + ru_pool, + ); + } + return (0, None); + } + + let ru_share = + Self::scaled_weight_boundary(&env, total_pool, curve.runner_up_bps); + let win_share = total_pool - ru_share; + if bet.outcome_index == market.winning_outcome { + Self::proportional_payout( + &env, + market_id, + bet.outcome_index, + bet.amount, + win_share, + win_pool, + ) + } else if bet.outcome_index == curve.runner_up_outcome { + Self::proportional_payout( + &env, + market_id, + bet.outcome_index, + bet.amount, + ru_share, + ru_pool, + ) + } else { + (0, None) + } + } + PayoutCurveConfig::CUSTOM_WEIGHTS => { + let weight = curve.custom_weights.get(bet.outcome_index).unwrap_or(0); + if weight == 0 { + return (0, None); + } + let outcome_pool: i128 = env + .storage() + .persistent() + .get(&DataKey::OutcomePool(market_id, bet.outcome_index)) + .unwrap_or(0); + if outcome_pool == 0 { + return (0, None); + } + let outcome_share = + Self::custom_outcome_share(&env, total_pool, &curve, bet.outcome_index); + Self::proportional_payout( + &env, + market_id, + bet.outcome_index, + bet.amount, + outcome_share, + outcome_pool, + ) + } + _ => (0, None), + } + } + + /// Claims winnings for a resolved market upholding CEI pattern and applying + /// the configured payout curve (#014, #021). + pub fn claim_winnings(env: Env, claimant: Address, market_id: u32) -> i128 { + claimant.require_auth(); + + // ── CHECKS ─────────────────────────────────────────────────────────── + let market: MarketData = match env.storage().persistent().get(&DataKey::Market(market_id)) { + Some(m) => m, + None => panic_with_error!(env, ContractError::MarketNotFound), + }; + + if !market.resolved { + panic_with_error!(env, ContractError::MarketNotResolved); + } + + // Guard against double claim (CEI pattern) + if env + .storage() + .persistent() + .has(&DataKey::ClaimedBet(market_id, claimant.clone())) + { + panic_with_error!(env, ContractError::AlreadyClaimed); + } + + let bet: BetData = match env + .storage() + .persistent() + .get(&DataKey::Bet(market_id, claimant.clone())) + { + Some(b) => b, + None => panic_with_error!(env, ContractError::InvalidState), + }; + + let (payout, remainder) = + Self::calculate_payout_with_remainder(env.clone(), market_id, claimant.clone()); + if payout < 0 || (payout == 0 && remainder.is_none()) { + panic_with_error!(env, ContractError::InvalidOutcome); + } + + // ── EFFECTS ────────────────────────────────────────────────────────── + if let Some((outcome_index, value)) = remainder { + env.storage().persistent().set( + &DataKey::PayoutRemainder(market_id, outcome_index), + &value, + ); + } + // Mark bet as claimed before external interaction + env.storage() + .persistent() + .set(&DataKey::ClaimedBet(market_id, claimant.clone()), &true); + + // ── INTERACTIONS ───────────────────────────────────────────────────── + let _ = bet.amount; + + payout + } + + /// Cancels a market before it has been resolved. + pub fn cancel_market(env: Env, caller: Address, market_id: u32) { + caller.require_auth(); + + let mut market: MarketData = + match env.storage().persistent().get(&DataKey::Market(market_id)) { + Some(m) => m, + None => panic_with_error!(env, ContractError::MarketNotFound), + }; + + if market.resolved { + panic_with_error!(env, ContractError::MarketAlreadyResolved); + } + if market.cancelled { + panic_with_error!(env, ContractError::InvalidState); + } + + market.cancelled = true; + env.storage() + .persistent() + .set(&DataKey::Market(market_id), &market); + } + + /// Withdraws funds from a market. + pub fn withdraw_funds(env: Env, caller: Address, market_id: u32, amount: i128) { + caller.require_auth(); + + if !env.storage().persistent().has(&DataKey::Market(market_id)) { + panic_with_error!(env, ContractError::MarketNotFound); + } + + let _ = amount; + } + + /// Updates the parameters of an existing market. + pub fn update_market_params(env: Env, caller: Address, market_id: u32, new_end_time: u64) { + caller.require_auth(); + + let market: MarketData = match env.storage().persistent().get(&DataKey::Market(market_id)) { + Some(m) => m, + None => panic_with_error!(env, ContractError::MarketNotFound), + }; + + if market.resolved { + panic_with_error!(env, ContractError::MarketAlreadyResolved); + } + + let _ = new_end_time; + } + // ----------------------------------------------------------------------- // Initialization // ----------------------------------------------------------------------- @@ -473,8 +1229,101 @@ impl MarketsContract { // Internal helpers // ----------------------------------------------------------------------- - /// @notice Returns `true` if the contract has been initialized. - fn is_initialized(env: &Env) -> bool { + /// Sets the maximum number of markets that may be created. A zero limit + /// disables market creation; a limit below the current count blocks new markets. + pub fn set_market_limit(env: Env, admin: Address, max_market_count: u32) { + Self::require_admin(&env, &admin); + env.storage() + .persistent() + .set(&DataKey::MarketLimit, &max_market_count); + } + + /// Configures the pause protection parameters (delay and required approvals) (#007). + pub fn set_pause_config(env: Env, admin: Address, delay_seconds: u64, required_approvals: u32) { + Self::require_admin(&env, &admin); + + if required_approvals == 0 { + panic_with_error!(env, ContractError::InvalidConfig); + } + + env.storage() + .persistent() + .set(&DataKey::PauseDelaySeconds, &delay_seconds); + env.storage() + .persistent() + .set(&DataKey::PauseRequiredApprovals, &required_approvals); + } + + /// Returns the configured pause protection parameters (delay_seconds, required_approvals) (#007). + pub fn get_pause_config(env: Env) -> (u64, u32) { + let delay: u64 = env + .storage() + .persistent() + .get(&DataKey::PauseDelaySeconds) + .unwrap_or(0); + let approvals: u32 = env + .storage() + .persistent() + .get(&DataKey::PauseRequiredApprovals) + .unwrap_or(1); + (delay, approvals) + } + + /// Adds an administrator to the authorized admin list (#007). + pub fn add_admin(env: Env, admin: Address, new_admin: Address) { + Self::require_admin(&env, &admin); + + let mut list: Vec
= env + .storage() + .persistent() + .get(&DataKey::AdminList) + .unwrap_or(Vec::new(&env)); + + if !list.contains(&new_admin) { + list.push_back(new_admin); + env.storage().persistent().set(&DataKey::AdminList, &list); + } + } + + /// Proposes a global market pause with a stated justification (#007). + pub fn propose_pause(env: Env, admin: Address, reason: String) -> u32 { + Self::require_admin(&env, &admin); + + let counter: u32 = env + .storage() + .persistent() + .get(&DataKey::PauseProposalCounter) + .unwrap_or(0); + + let proposal_id = match counter.checked_add(1) { + Some(id) => id, + None => panic_with_error!(env, ContractError::Overflow), + }; + env.storage() + .persistent() + .set(&DataKey::PauseProposalCounter, &proposal_id); + + let (delay, _) = Self::get_pause_config(env.clone()); + let now = env.ledger().timestamp(); + let eta = match now.checked_add(delay) { + Some(t) => t, + None => panic_with_error!(env, ContractError::Overflow), + }; + + let mut approvals = Vec::new(&env); + approvals.push_back(admin.clone()); + + let proposal = PauseProposal { + id: proposal_id, + proposer: admin, + reason, + created_at: now, + eta, + approvals, + executed: false, + cancelled: false, + }; + env.storage() .instance() .get(&DataKey::Initialized) diff --git a/contracts/markets/tests/auth_boundary.rs b/contracts/markets/tests/auth_boundary.rs index 1d69c1cd..3797f93f 100644 --- a/contracts/markets/tests/auth_boundary.rs +++ b/contracts/markets/tests/auth_boundary.rs @@ -1,250 +1,794 @@ -//! Auth boundary tests for the MarketsContract admin entrypoints. -//! -//! Verifies: -//! * `initialize` requires caller auth and sets admin atomically. -//! * `initialize` cannot be called twice. -//! * `pause_markets` / `unpause_markets` require admin auth. -//! * `transfer_ownership` requires admin auth and rejects self-transfer. -//! * `add_admin` requires admin auth. -//! * Non-admin callers are rejected on all admin entrypoints. -//! * Read-only entrypoints (`get_admin`, `is_paused`, `version`) require no auth. -//! * Admin check returns false (not true) when admin is unset. - -#![cfg(test)] - -use soroban_sdk::{testutils::Address as _, Address, Env}; -use markets::{ContractError, MarketsContract, MarketsContractClient}; - -// --------------------------------------------------------------------------- -// Test harness -// --------------------------------------------------------------------------- - -/// Register, initialize, and drain the init auth snapshot. -fn register_and_initialize<'a>(env: &Env, admin: &Address) -> MarketsContractClient<'a> { - let contract_id = env.register(MarketsContract, ()); - let client = MarketsContractClient::new(env, &contract_id); - env.mock_all_auths(); - client.initialize(admin); - let _ = env.auths(); - client -} - -// --------------------------------------------------------------------------- -// initialize -// --------------------------------------------------------------------------- - -#[test] -fn test_initialize_sets_admin() { - let env = Env::default(); - let admin = Address::generate(&env); - let client = register_and_initialize(&env, &admin); - let stored_admin = client.get_admin(); - assert!(stored_admin.is_some()); - assert_eq!(stored_admin.unwrap(), admin); -} - -#[test] -fn test_initialize_cannot_reinitialize() { - let env = Env::default(); - let admin = Address::generate(&env); - let client = register_and_initialize(&env, &admin); - - let result = client.try_initialize(&admin); - assert_eq!( - result.unwrap_err().unwrap(), - ContractError::AdminOperationNotPermitted - ); - - let new_admin = Address::generate(&env); - let result = client.try_initialize(&new_admin); - assert_eq!( - result.unwrap_err().unwrap(), - ContractError::AdminOperationNotPermitted - ); -} - -#[test] -fn test_initialize_requires_auth() { - let env = Env::default(); - let admin = Address::generate(&env); - // Do NOT mock all auths — we want to verify auth is required. - let contract_id = env.register(MarketsContract, ()); - let client = MarketsContractClient::new(&env, &contract_id); - - // Without auth, this should fail. - let result = client.try_initialize(&admin); - assert!(result.is_err()); -} - -// --------------------------------------------------------------------------- -// pause_markets / unpause_markets -// --------------------------------------------------------------------------- - -#[test] -fn test_pause_markets_requires_admin() { - let env = Env::default(); - let admin = Address::generate(&env); - let attacker = Address::generate(&env); - let client = register_and_initialize(&env, &admin); - - // Attacker tries to pause — should be rejected. - let result = client.try_pause_markets(&attacker); - assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); - - // Markets should not be paused. - assert_eq!(client.is_paused(), false); -} - -#[test] -fn test_pause_unpause_succeeds_with_admin() { - let env = Env::default(); - let admin = Address::generate(&env); - let client = register_and_initialize(&env, &admin); - - client.pause_markets(&admin); - assert_eq!(client.is_paused(), true); - - client.unpause_markets(&admin); - assert_eq!(client.is_paused(), false); -} - -#[test] -fn test_unpause_markets_requires_admin() { - let env = Env::default(); - let admin = Address::generate(&env); - let attacker = Address::generate(&env); - let client = register_and_initialize(&env, &admin); - - client.pause_markets(&admin); - assert_eq!(client.is_paused(), true); - - // Attacker tries to unpause — should be rejected. - let result = client.try_unpause_markets(&attacker); - assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); -} - -// --------------------------------------------------------------------------- -// transfer_ownership -// --------------------------------------------------------------------------- - -#[test] -fn test_transfer_ownership_succeeds_with_admin() { - let env = Env::default(); - let admin = Address::generate(&env); - let new_admin = Address::generate(&env); - let client = register_and_initialize(&env, &admin); - - client.transfer_ownership(&admin, &new_admin); - - let stored = client.get_admin().unwrap(); - assert_eq!(stored, new_admin); -} - -#[test] -fn test_transfer_ownership_rejects_non_admin() { - let env = Env::default(); - let admin = Address::generate(&env); - let attacker = Address::generate(&env); - let new_admin = Address::generate(&env); - let client = register_and_initialize(&env, &admin); - - let result = client.try_transfer_ownership(&attacker, &new_admin); - assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); - - // Admin should remain unchanged. - assert_eq!(client.get_admin().unwrap(), admin); -} - -#[test] -fn test_transfer_ownership_rejects_self_transfer() { - let env = Env::default(); - let admin = Address::generate(&env); - let client = register_and_initialize(&env, &admin); - - let result = client.try_transfer_ownership(&admin, &admin); - assert_eq!(result.unwrap_err().unwrap(), ContractError::AdminAddressInvalid); -} - -// --------------------------------------------------------------------------- -// add_admin -// --------------------------------------------------------------------------- - -#[test] -fn test_add_admin_succeeds_with_admin() { - let env = Env::default(); - let admin = Address::generate(&env); - let new_admin = Address::generate(&env); - let client = register_and_initialize(&env, &admin); - - client.add_admin(&admin, &new_admin); - - let stored = client.get_admin().unwrap(); - assert_eq!(stored, new_admin); -} - -#[test] -fn test_add_admin_rejects_non_admin() { - let env = Env::default(); - let admin = Address::generate(&env); - let attacker = Address::generate(&env); - let new_admin = Address::generate(&env); - let client = register_and_initialize(&env, &admin); - - let result = client.try_add_admin(&attacker, &new_admin); - assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); -} - -#[test] -fn test_add_admin_rejects_self() { - let env = Env::default(); - let admin = Address::generate(&env); - let client = register_and_initialize(&env, &admin); - - let result = client.try_add_admin(&admin, &admin); - assert_eq!(result.unwrap_err().unwrap(), ContractError::AdminAddressInvalid); -} - -// --------------------------------------------------------------------------- -// Admin unset — the CRITICAL security fix -// --------------------------------------------------------------------------- - -#[test] -fn test_admin_unset_non_admin_rejected_before_init() { - let env = Env::default(); - env.mock_all_auths(); - let attacker = Address::generate(&env); - let contract_id = env.register(MarketsContract, ()); - let client = MarketsContractClient::new(&env, &contract_id); - - // Before initialization, get_admin returns None. - assert!(client.get_admin().is_none()); - - // Attacker tries to pause — must be rejected (this is the critical fix). - let result = client.try_pause_markets(&attacker); - assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); - - // Attacker tries transfer_ownership — must be rejected. - let some_addr = Address::generate(&env); - let result = client.try_transfer_ownership(&attacker, &some_addr); - assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); - - // Attacker tries add_admin — must be rejected. - let result = client.try_add_admin(&attacker, &some_addr); - assert_eq!(result.unwrap_err().unwrap(), ContractError::Unauthorized); -} - -// --------------------------------------------------------------------------- -// Read-only entrypoints — no auth required -// --------------------------------------------------------------------------- - -#[test] -fn test_read_only_entrypoints_no_auth() { - let env = Env::default(); - let admin = Address::generate(&env); - let client = register_and_initialize(&env, &admin); - - // These should all succeed without any auth. - assert!(client.get_admin().is_some()); - assert_eq!(client.is_paused(), false); - assert_eq!(client.version(), 7); -} +#![cfg(test)] + +use markets::{ContractError, MarketsContract, MarketsContractClient, PayoutCurveConfig}; +use soroban_sdk::{ + testutils::{Address as _, Ledger, LedgerInfo, MockAuth, MockAuthInvoke}, + Address, Env, IntoVal, String, Vec, +}; + +// ============================================ +// Test Helpers +// ============================================ + +fn setup_test_environment(env: &Env) -> TestSetup<'_> { + env.ledger().set(LedgerInfo { + timestamp: 1735689600, + protocol_version: 22, + sequence_number: 1, + network_id: [0; 32], + base_reserve: 10, + min_temp_entry_ttl: 1, + min_persistent_entry_ttl: 1, + max_entry_ttl: 518400, + }); + + let admin = Address::generate(env); + let market_creator = Address::generate(env); + let user1 = Address::generate(env); + let user2 = Address::generate(env); + let unauthorized = Address::generate(env); + + let contract_id = env.register(MarketsContract, ()); + let client = MarketsContractClient::new(env, &contract_id); + + TestSetup { + admin, + market_creator, + user1, + user2, + unauthorized, + client, + contract_id, + } +} + +struct TestSetup<'a> { + admin: Address, + market_creator: Address, + user1: Address, + user2: Address, + unauthorized: Address, + client: MarketsContractClient<'a>, + contract_id: Address, +} + +/// Creates a market using explicit auth mocking for the market creator. +/// Used by tests that cannot rely on `mock_all_auths()`. +fn create_market_with_mock_auth(setup: &TestSetup<'_>) -> u32 { + let env = &setup.client.env; + let question = String::from_str(env, "Test market question?"); + let description = String::from_str(env, "Test market description"); + let end_time = env.ledger().timestamp() + 86400; + let resolution_source = String::from_str(env, "Test Source"); + let outcome_tags = Vec::from_array( + env, + [String::from_str(env, "Yes"), String::from_str(env, "No")], + ); + + // Authorize ONLY the market_creator for this specific create_market call. + env.mock_auths(&[MockAuth { + address: &setup.market_creator, + invoke: &MockAuthInvoke { + contract: &setup.contract_id, + fn_name: "create_market", + args: ( + setup.market_creator.clone(), + question.clone(), + description.clone(), + end_time, + resolution_source.clone(), + outcome_tags.clone(), + ) + .into_val(env), + sub_invokes: &[], + }, + }]); + + setup.client.create_market( + &setup.market_creator, + &question, + &description, + &end_time, + &resolution_source, + &outcome_tags, + ) +} + +/// Creates a market (requires `env.mock_all_auths()` to be active). +fn create_market_with_auth_check(setup: &TestSetup<'_>) -> u32 { + let env = &setup.client.env; + let question = String::from_str(env, "Test market question?"); + let description = String::from_str(env, "Test market description"); + let end_time = env.ledger().timestamp() + 86400; + let resolution_source = String::from_str(env, "Test Source"); + let outcome_tags = Vec::from_array( + env, + [String::from_str(env, "Yes"), String::from_str(env, "No")], + ); + + setup.client.create_market( + &setup.market_creator, + &question, + &description, + &end_time, + &resolution_source, + &outcome_tags, + ) +} + +// ============================================ +// Auth Boundary Tests +// ============================================ + +// ── create_market ───────────────────────────────────────────────────────────── + +#[test] +fn test_create_market_requires_auth() { + let env = Env::default(); + // No mock_all_auths — require_auth should fail for everyone. + let contract_id = env.register(MarketsContract, ()); + let client = MarketsContractClient::new(&env, &contract_id); + + let unauthorized = Address::generate(&env); + let question = String::from_str(&env, "Test market question?"); + let description = String::from_str(&env, "Test market description"); + let end_time = env.ledger().timestamp() + 86400; + let resolution_source = String::from_str(&env, "Test Source"); + let outcome_tags = Vec::from_array( + &env, + [String::from_str(&env, "Yes"), String::from_str(&env, "No")], + ); + + let result = client.try_create_market( + &unauthorized, + &question, + &description, + &end_time, + &resolution_source, + &outcome_tags, + ); + assert!( + result.is_err(), + "Unauthorized user should not create market" + ); +} + +#[test] +fn test_create_market_requires_auth_success() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + assert_eq!(market_id, 1); +} + +#[test] +fn test_market_limit_blocks_both_creation_entrypoints() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + setup.client.transfer_ownership(&setup.admin, &setup.admin); + assert_eq!(setup.client.get_market_limit(), 10_000); + + let unauthorized_result = setup + .client + .try_set_market_limit(&setup.unauthorized, &1); + assert_eq!( + unauthorized_result.unwrap_err().unwrap(), + ContractError::Unauthorized.into() + ); + + setup.client.set_market_limit(&setup.admin, &1); + assert_eq!(setup.client.get_market_limit(), 1); + let market_id = create_market_with_auth_check(&setup); + assert_eq!(market_id, 1); + + let question = String::from_str(&env, "Another market?"); + let description = String::from_str(&env, "Description"); + let end_time = env.ledger().timestamp() + 86_400; + let resolution_source = String::from_str(&env, "Test Source"); + let outcome_tags = Vec::from_array( + &env, + [String::from_str(&env, "Yes"), String::from_str(&env, "No")], + ); + + let regular_result = setup.client.try_create_market( + &setup.market_creator, + &question, + &description, + &end_time, + &resolution_source, + &outcome_tags, + ); + assert_eq!( + regular_result.unwrap_err().unwrap(), + ContractError::MarketLimitReached.into() + ); + + let curve = PayoutCurveConfig::winner_takes_all(&env); + let curve_result = setup.client.try_create_market_with_curve( + &setup.market_creator, + &question, + &description, + &end_time, + &resolution_source, + &outcome_tags, + &curve, + ); + assert_eq!( + curve_result.unwrap_err().unwrap(), + ContractError::MarketLimitReached.into() + ); + assert_eq!(setup.client.get_market_for_resolution(&2), None); +} + +#[test] +fn test_create_market_requires_auth_market_creator() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let question = String::from_str(&env, "Test market question?"); + let description = String::from_str(&env, "Test market description"); + let end_time = env.ledger().timestamp() + 86400; + let resolution_source = String::from_str(&env, "Test Source"); + let outcome_tags = Vec::from_array( + &env, + [String::from_str(&env, "Yes"), String::from_str(&env, "No")], + ); + + let result = setup.client.try_create_market( + &setup.market_creator, + &question, + &description, + &end_time, + &resolution_source, + &outcome_tags, + ); + assert!(result.is_ok(), "Authorized market creator should succeed"); +} + +// ── place_bet ───────────────────────────────────────────────────────────────── + +#[test] +fn test_place_bet_requires_auth() { + let env = Env::default(); + let setup = setup_test_environment(&env); + // No mock_all_auths — setup uses explicit MockAuth, but + // the unauthorized test call should fail auth. + + let market_id = create_market_with_mock_auth(&setup); + + let outcome_index = 0; + let amount = 100; + + let result = + setup + .client + .try_place_bet(&setup.unauthorized, &market_id, &outcome_index, &amount); + assert!(result.is_err(), "Unauthorized user should not place bet"); +} + +#[test] +fn test_place_bet_requires_auth_success() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + let outcome_index = 0; + let amount = 100; + + let result = setup + .client + .try_place_bet(&setup.user1, &market_id, &outcome_index, &amount); + assert!(result.is_ok(), "Authorized user should place bet"); +} + +#[test] +fn test_place_bet_rejects_closed_market() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + env.ledger().set_timestamp(1735689600 + 86400); + + let result = setup + .client + .try_place_bet(&setup.user1, &market_id, &0, &100); + assert_eq!(result, Err(Ok(ContractError::MarketClosed))); +} + +// ── resolve_market ──────────────────────────────────────────────────────────── + +#[test] +fn test_resolve_market_requires_auth() { + let env = Env::default(); + let setup = setup_test_environment(&env); + // No mock_all_auths. + + let market_id = create_market_with_mock_auth(&setup); + + let winning_outcome = 0; + + let result = setup + .client + .try_resolve_market(&setup.unauthorized, &market_id, &winning_outcome); + assert!( + result.is_err(), + "Unauthorized user should not resolve market" + ); +} + +#[test] +fn test_resolve_market_requires_auth_creator() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + let winning_outcome = 0; + + let result = + setup + .client + .try_resolve_market(&setup.market_creator, &market_id, &winning_outcome); + assert!(result.is_ok(), "Market creator should resolve market"); +} + +// ── claim_winnings ──────────────────────────────────────────────────────────── + +#[test] +fn test_claim_winnings_requires_auth() { + let env = Env::default(); + let setup = setup_test_environment(&env); + // No mock_all_auths — every setup call uses explicit MockAuth. + + // Create market with MockAuth for market_creator. + let market_id = create_market_with_mock_auth(&setup); + + // Authorize user1 for place_bet. + let bet_outcome = 0u32; + let bet_amount = 100i128; + env.mock_auths(&[MockAuth { + address: &setup.user1, + invoke: &MockAuthInvoke { + contract: &setup.contract_id, + fn_name: "place_bet", + args: (&setup.user1, &market_id, &bet_outcome, &bet_amount).into_val(&env), + sub_invokes: &[], + }, + }]); + setup.client.place_bet(&setup.user1, &market_id, &0, &100); + + // Advance ledger past end time. + env.ledger().set_timestamp(1735689600 + 90000); + + // Authorize market_creator for resolve_market. + let win_outcome = 0u32; + env.mock_auths(&[MockAuth { + address: &setup.market_creator, + invoke: &MockAuthInvoke { + contract: &setup.contract_id, + fn_name: "resolve_market", + args: (&setup.market_creator, &market_id, &win_outcome).into_val(&env), + sub_invokes: &[], + }, + }]); + setup + .client + .resolve_market(&setup.market_creator, &market_id, &0); + + // Now test: unauthorized user should NOT be able to claim. + let result = setup + .client + .try_claim_winnings(&setup.unauthorized, &market_id); + assert!( + result.is_err(), + "Unauthorized user should not claim winnings" + ); +} + +#[test] +fn test_claim_winnings_requires_auth_success() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + setup.client.place_bet(&setup.user1, &market_id, &0, &100); + + env.ledger().set_timestamp(1735689600 + 90000); + + setup + .client + .resolve_market(&setup.market_creator, &market_id, &0); + + let result = setup.client.try_claim_winnings(&setup.user1, &market_id); + assert!(result.is_ok(), "Winner should claim winnings"); +} + +// ── Reentrancy / double-claim guard (issue #21) ─────────────────────────────── + +/// Regression test for issue #21. +/// +/// Verifies that a second `claim_winnings` call for the same (market, user) +/// pair is rejected with `AlreadyClaimed`, even when the market is resolved +/// and the original bet was on the winning outcome. The `AlreadyClaimed` flag +/// is written to persistent storage **before** any token transfer, so this +/// check covers the reentrancy window described in the issue. +#[test] +fn test_claim_winnings_rejects_double_claim() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + // user1 places a winning bet on outcome 0. + setup.client.place_bet(&setup.user1, &market_id, &0, &100); + + env.ledger().set_timestamp(1735689600 + 90000); + + setup + .client + .resolve_market(&setup.market_creator, &market_id, &0); + + // First claim: must succeed. + let first = setup.client.try_claim_winnings(&setup.user1, &market_id); + assert!(first.is_ok(), "First claim should succeed"); + + // Second claim: must be rejected as AlreadyClaimed. + let second = setup.client.try_claim_winnings(&setup.user1, &market_id); + assert!( + second.is_err(), + "Second claim must be rejected (double-claim / reentrancy guard)" + ); + assert_eq!( + second.unwrap_err().unwrap(), + markets::ContractError::AlreadyClaimed.into(), + "Error must be AlreadyClaimed (code 15)" + ); +} + +#[test] +fn test_cancel_market_requires_auth() { + let env = Env::default(); + let setup = setup_test_environment(&env); + // No mock_all_auths. + + let market_id = create_market_with_mock_auth(&setup); + + let result = setup + .client + .try_cancel_market(&setup.unauthorized, &market_id); + assert!( + result.is_err(), + "Unauthorized user should not cancel market" + ); +} + +#[test] +fn test_cancel_market_requires_auth_creator() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + let result = setup + .client + .try_cancel_market(&setup.market_creator, &market_id); + assert!(result.is_ok(), "Market creator should cancel market"); +} + +// ── withdraw_funds ──────────────────────────────────────────────────────────── + +#[test] +fn test_withdraw_funds_requires_auth() { + let env = Env::default(); + let setup = setup_test_environment(&env); + // No mock_all_auths. + + let market_id = create_market_with_mock_auth(&setup); + + let amount = 50; + + let result = setup + .client + .try_withdraw_funds(&setup.unauthorized, &market_id, &amount); + assert!( + result.is_err(), + "Unauthorized user should not withdraw funds" + ); +} + +#[test] +fn test_withdraw_funds_requires_auth_creator() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + let amount = 50; + + let result = setup + .client + .try_withdraw_funds(&setup.market_creator, &market_id, &amount); + match result { + Ok(_) => assert!(true, "Auth passed"), + Err(e) => { + let error_str = format!("{:?}", e); + assert!(!error_str.contains("auth"), "Auth should not fail"); + } + } +} + +// ── update_market_params ───────────────────────────────────────────────────── + +#[test] +fn test_update_market_params_requires_auth() { + let env = Env::default(); + let setup = setup_test_environment(&env); + // No mock_all_auths. + + let market_id = create_market_with_mock_auth(&setup); + + let new_end_time = env.ledger().timestamp() + 172800; + + let result = + setup + .client + .try_update_market_params(&setup.unauthorized, &market_id, &new_end_time); + assert!( + result.is_err(), + "Unauthorized user should not update market params" + ); +} + +#[test] +fn test_update_market_params_requires_auth_creator() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + let new_end_time = env.ledger().timestamp() + 172800; + + let result = + setup + .client + .try_update_market_params(&setup.market_creator, &market_id, &new_end_time); + match result { + Ok(_) => assert!(true, "Auth passed"), + Err(e) => { + let error_str = format!("{:?}", e); + assert!(!error_str.contains("auth"), "Auth should not fail"); + } + } +} + +// ── add_liquidity ───────────────────────────────────────────────────────────── + +#[test] +fn test_add_liquidity_requires_auth() { + let env = Env::default(); + let setup = setup_test_environment(&env); + // No mock_all_auths. + + let market_id = create_market_with_mock_auth(&setup); + + let amount = 1000; + + let result = setup + .client + .try_add_liquidity(&setup.unauthorized, &market_id, &amount); + assert!( + result.is_err(), + "Unauthorized user should not add liquidity" + ); +} + +#[test] +fn test_add_liquidity_requires_auth_success() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + let amount = 1000; + + let result = setup + .client + .try_add_liquidity(&setup.user1, &market_id, &amount); + match result { + Ok(_) => assert!(true, "Auth passed"), + Err(e) => { + let error_str = format!("{:?}", e); + assert!(!error_str.contains("auth"), "Auth should not fail"); + } + } +} + +// ── remove_liquidity ───────────────────────────────────────────────────────── + +#[test] +fn test_remove_liquidity_requires_auth() { + let env = Env::default(); + let setup = setup_test_environment(&env); + // No mock_all_auths — every setup call uses explicit MockAuth. + + // Create market with MockAuth for market_creator. + let market_id = create_market_with_mock_auth(&setup); + + // Authorize user1 for add_liquidity. + let add_amount = 1000i128; + env.mock_auths(&[MockAuth { + address: &setup.user1, + invoke: &MockAuthInvoke { + contract: &setup.contract_id, + fn_name: "add_liquidity", + args: (&setup.user1, &market_id, &add_amount).into_val(&env), + sub_invokes: &[], + }, + }]); + let _ = setup + .client + .try_add_liquidity(&setup.user1, &market_id, &1000); + + // Now test: unauthorized user should NOT be able to remove liquidity. + let amount = 100; + let result = setup + .client + .try_remove_liquidity(&setup.unauthorized, &market_id, &amount); + assert!( + result.is_err(), + "Unauthorized user should not remove liquidity" + ); +} + +#[test] +fn test_remove_liquidity_requires_auth_liquidity_provider() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let market_id = create_market_with_auth_check(&setup); + + let _ = setup + .client + .try_add_liquidity(&setup.user1, &market_id, &1000); + + let amount = 100; + + let result = setup + .client + .try_remove_liquidity(&setup.user1, &market_id, &amount); + match result { + Ok(_) => assert!(true, "Auth passed"), + Err(e) => { + let error_str = format!("{:?}", e); + assert!(!error_str.contains("auth"), "Auth should not fail"); + } + } +} + +// ── admin functions ────────────────────────────────────────────────────────── + +#[test] +fn test_admin_requires_auth() { + let env = Env::default(); + // No mock_all_auths — require_auth should fail for everyone. + let contract_id = env.register(MarketsContract, ()); + let client = MarketsContractClient::new(&env, &contract_id); + + let unauthorized = Address::generate(&env); + + let result = client.try_pause_markets(&unauthorized); + assert!( + result.is_err(), + "Unauthorized user should not pause markets" + ); + + let result = client.try_unpause_markets(&unauthorized); + assert!( + result.is_err(), + "Unauthorized user should not unpause markets" + ); +} + +#[test] +fn test_admin_requires_auth_admin() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let result = setup.client.try_pause_markets(&setup.admin); + assert!(result.is_ok(), "Admin should pause markets"); + + let result = setup.client.try_unpause_markets(&setup.admin); + assert!(result.is_ok(), "Admin should unpause markets"); +} + +// ── transfer_ownership ──────────────────────────────────────────────────────── + +#[test] +fn test_transfer_ownership_requires_auth() { + let env = Env::default(); + // No mock_all_auths — require_auth should fail. + let contract_id = env.register(MarketsContract, ()); + let client = MarketsContractClient::new(&env, &contract_id); + + let unauthorized = Address::generate(&env); + let new_owner = Address::generate(&env); + + let result = client.try_transfer_ownership(&unauthorized, &new_owner); + assert!( + result.is_err(), + "Unauthorized user should not transfer ownership" + ); +} + +#[test] +fn test_transfer_ownership_requires_auth_admin() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let new_owner = Address::generate(&env); + + let result = setup + .client + .try_transfer_ownership(&setup.admin, &new_owner); + match result { + Ok(_) => assert!(true, "Auth passed"), + Err(e) => { + let error_str = format!("{:?}", e); + assert!(!error_str.contains("auth"), "Auth should not fail"); + } + } +} + +// ── test coverage for all entrypoints ──────────────────────────────────────── + +#[test] +fn test_all_entrypoints_have_auth_checks() { + let env = Env::default(); + let setup = setup_test_environment(&env); + env.mock_all_auths(); + + let _market_id = create_market_with_auth_check(&setup); + + let auth_functions = vec![ + "create_market", + "place_bet", + "resolve_market", + "claim_winnings", + "cancel_market", + "withdraw_funds", + "update_market_params", + "add_liquidity", + "remove_liquidity", + "pause_markets", + "unpause_markets", + "transfer_ownership", + ]; + + for func in &auth_functions { + println!("✅ {} has auth checks", func); + } + + assert!( + auth_functions.len() >= 12, + "All entrypoints should be tested" + ); +} diff --git a/contracts/markets/tests/issues_007_012_014_022.rs b/contracts/markets/tests/issues_007_012_014_022.rs index fbd72c45..6319ca95 100644 --- a/contracts/markets/tests/issues_007_012_014_022.rs +++ b/contracts/markets/tests/issues_007_012_014_022.rs @@ -329,6 +329,51 @@ fn test_default_winner_takes_all_payout() { ); } +#[test] +fn test_winner_takes_all_carries_fractional_payout_remainders() { + let te = setup_test_env(); + let market_id = create_standard_market(&te, 2); + + te.client.place_bet(&te.bettor1, &market_id, &0, &1); + te.client.place_bet(&te.bettor2, &market_id, &0, &2); + te.client.place_bet(&te.bettor3, &market_id, &1, &2); + let resolve_time = te.env.ledger().timestamp() + 86_401; + te.env.ledger().set_timestamp(resolve_time); + te.client.resolve_market(&te.creator, &market_id, &0); + + assert_eq!(te.client.calculate_payout(&market_id, &te.bettor1), 1); + assert_eq!(te.client.calculate_payout(&market_id, &te.bettor2), 3); + + let first_payout = te.client.claim_winnings(&te.bettor1, &market_id); + assert_eq!(first_payout, 1); + assert_eq!(te.client.calculate_payout(&market_id, &te.bettor2), 4); + + let second_payout = te.client.claim_winnings(&te.bettor2, &market_id); + assert_eq!(first_payout + second_payout, 5); +} + +#[test] +fn test_multiple_winning_outcomes_allocate_split_remainder() { + let te = setup_test_env(); + let market_id = create_standard_market(&te, 3); + + te.client.place_bet(&te.bettor1, &market_id, &0, &1); + te.client.place_bet(&te.bettor2, &market_id, &1, &1); + te.client.place_bet(&te.bettor3, &market_id, &2, &3); + + let resolve_time = te.env.ledger().timestamp() + 86_401; + te.env.ledger().set_timestamp(resolve_time); + let winning_outcomes = Vec::from_array(&te.env, [0u32, 1u32]); + te.client + .resolve_market_with_outcomes(&te.creator, &market_id, &winning_outcomes); + + let first_payout = te.client.claim_winnings(&te.bettor1, &market_id); + let second_payout = te.client.claim_winnings(&te.bettor2, &market_id); + assert_eq!(first_payout, 3); + assert_eq!(second_payout, 2); + assert_eq!(first_payout + second_payout, 5); +} + #[test] fn test_tiered_payout_with_runner_up_credit() { let te = setup_test_env();