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();