Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions contracts/open-market/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub const PERSISTENT_THRESHOLD: u32 = 501_120; // PERSISTENT_BUMP − 1 day
/// before it becomes executable. ~2 days at real time. Admin-configurable via
/// [`set_timelock_delay`].
pub const DEFAULT_TIMELOCK_DELAY: u64 = 172_800;
pub const DEFAULT_MAX_OUTCOMES: u32 = 10;

// ── Storage-specific TTL constants (merged from ttl.rs) ───────────────────────
// ~30 days at ~6s/ledger for frequently accessed market state.
Expand Down Expand Up @@ -244,7 +245,10 @@ pub struct Config {
/// via `ProposalType::UpdateQuorum` (timelocked governance path). Defaults
/// to `1000` (10%) at initialization.
pub governance_quorum_bps: u32,
/// Volume-based fee tier schedule. Governs the swap fee charged by every
/// Maximum number of mutually exclusive outcomes allowed per market.
/// Admin-configurable via [`set_max_outcomes`]. Defaults to 10 at initialization.
pub max_outcomes: u32,
/// Volume-based fee tier schedule. Governs the swap fee charged by every
/// market's AMM pool based on its cumulative trading volume.
/// Governance-configurable via `set_volume_fee_config` (admin, immediate).
/// Defaults to [`VolumeFeeConfig::default_config`] at initialization.
Expand Down Expand Up @@ -404,7 +408,8 @@ pub fn initialize(
arbiter_slash_bps: 1000, // 10% of stake slashed for a missed vote
arbiter_voting_period_seconds: 172_800, // ~2 days
governance_quorum_bps: 1000, // 10% of registered users must participate
volume_fee_config: VolumeFeeConfig::default_config(env),
max_outcomes: DEFAULT_MAX_OUTCOMES,
volume_fee_config: VolumeFeeConfig::default_config(env),
oracle_stake_amount: 100_000_000, // 10 XLM expressed in stroops
oracle_reward_bps: 500, // 5% of stake paid as a reward when resolution stands
vesting_tranche_count: 4,
Expand Down Expand Up @@ -1110,6 +1115,41 @@ fn emit_governance_quorum_updated(env: &Env, old_quorum_bps: u32, new_quorum_bps
);
}

/// Update the global maximum number of outcomes allowed per market.
pub fn set_max_outcomes(
env: &Env,
admin: Address,
new_max: u32,
) -> Result<(), InsightArenaError> {
ensure_not_paused(env)?;
let mut config = load_config(env)?;

admin.require_auth();
if admin != config.admin {
return Err(InsightArenaError::Unauthorized);
}

if new_max < 2 {
return Err(InsightArenaError::InvalidInput);
}

let old_max = config.max_outcomes;
config.max_outcomes = new_max;
env.storage().persistent().set(&DataKey::Config, &config);
bump_config(env);

emit_max_outcomes_updated(env, old_max, new_max);

Ok(())
}

fn emit_max_outcomes_updated(env: &Env, old_max: u32, new_max: u32) {
env.events().publish(
(symbol_short!("cfg"), symbol_short!("max_out")),
(old_max, new_max),
);
}

// ── Volume Fee Config ──────────────────────────────────────────────────────────

fn validate_volume_fee_config(config: &VolumeFeeConfig) -> Result<(), InsightArenaError> {
Expand Down
10 changes: 10 additions & 0 deletions contracts/open-market/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -786,6 +786,16 @@ impl InsightArenaContract {
config::set_max_liquidity_per_outcome(&env, admin, new_cap)
}

/// Update the global maximum number of outcomes allowed per market.
/// Caller must be the current admin.
pub fn set_max_outcomes(
env: Env,
admin: Address,
new_max: u32,
) -> Result<(), InsightArenaError> {
config::set_max_outcomes(&env, admin, new_max)
}

/// Set a per-market override for the maximum liquidity a single
/// outcome's AMM reserve may hold (`0` clears the override). Caller
/// must be the current admin.
Expand Down
28 changes: 20 additions & 8 deletions contracts/open-market/src/market.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use crate::storage_types::{
UserProfile,
};

pub const MAX_OUTCOMES: u32 = 10;

// ── Params struct ─────────────────────────────────────────────────────────────
// Soroban limits contract functions to 10 parameters. Bundling the market
// creation fields into a single `#[contracttype]` struct keeps the ABI legal
Expand Down Expand Up @@ -259,17 +261,22 @@ pub fn create_market(
return Err(InsightArenaError::InvalidTimeRange);
}

// ── Guard 5: at least two outcomes required ───────────────────────────────
if params.outcomes.len() < 2 {
// ── Load config for reputation, fee, stake floor, and outcome bounds ──────
let cfg = config::get_config(env)?;

// ── Guard 5: 2 to N outcomes (max bounded) required ────────────────────────
let max_outcomes = if cfg.max_outcomes > 0 {
cfg.max_outcomes
} else {
MAX_OUTCOMES
};
if params.outcomes.len() < 2 || params.outcomes.len() > max_outcomes {
return Err(InsightArenaError::InvalidInput);
}
if has_duplicate_outcomes(&params.outcomes) {
return Err(InsightArenaError::InvalidInput);
}

// ── Load config for reputation, fee, and stake floor checks ───────────────
let cfg = config::get_config(env)?;

// ── Guard 6: creator reputation must meet the governance threshold ────────
// Trusted-creator allowlist bypasses the score check entirely. The denial
// event fires before the error so indexers can see who was rejected and why.
Expand Down Expand Up @@ -1237,16 +1244,21 @@ pub fn add_volume(env: &Env, amount: i128) {
}

/// Accumulate per-outcome stake pools by iterating the predictor list.
///
/// Outcomes are discovered from the predictions themselves, so an outcome that
/// received no stake is absent from the result — an unstaked market yields an
/// empty distribution. This holds for any outcome count; N-way markets simply
/// surface however many of their options have been staked.
fn accumulate_outcome_pools(env: &Env, market_id: u64) -> (Vec<Symbol>, Vec<i128>) {
let mut outcome_symbols: Vec<Symbol> = Vec::new(env);
let mut outcome_pools: Vec<i128> = Vec::new(env);

let predictors: Vec<Address> = env
.storage()
.persistent()
.get(&DataKey::PredictorList(market_id))
.unwrap_or_else(|| Vec::new(env));

let mut outcome_symbols: Vec<Symbol> = Vec::new(env);
let mut outcome_pools: Vec<i128> = Vec::new(env);

for predictor in predictors.iter() {
if let Some(pred) = env
.storage()
Expand Down
126 changes: 126 additions & 0 deletions contracts/open-market/tests/market_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1892,3 +1892,129 @@ fn market_created_event_includes_metadata_hash() {
assert!(found, "market created event must include metadata_hash");
assert_eq!(client.get_metadata_hash(&id), metadata_hash);
}

#[test]
fn test_create_market_fails_exceeds_max_outcomes() {
let env = Env::default();
env.mock_all_auths();
let client = deploy(&env);
let creator = Address::generate(&env);

let mut params = default_params(&env);
params.outcomes = vec![
&env,
Symbol::new(&env, "opt1"),
Symbol::new(&env, "opt2"),
Symbol::new(&env, "opt3"),
Symbol::new(&env, "opt4"),
Symbol::new(&env, "opt5"),
Symbol::new(&env, "opt6"),
Symbol::new(&env, "opt7"),
Symbol::new(&env, "opt8"),
Symbol::new(&env, "opt9"),
Symbol::new(&env, "opt10"),
Symbol::new(&env, "opt11"),
];

let result = client.try_create_market(&creator, &params);
assert!(matches!(result, Err(Ok(InsightArenaError::InvalidInput))));
}

#[test]
fn test_3way_market_end_to_end() {
let env = Env::default();
env.mock_all_auths();
let (client, _admin, oracle, xlm_token) = deploy_with_token(&env);
let creator = Address::generate(&env);

let user_a = Address::generate(&env);
let user_b = Address::generate(&env);
let user_c = Address::generate(&env);
let user_d = Address::generate(&env);

fund(&env, &xlm_token, &user_a, 100_000_000);
fund(&env, &xlm_token, &user_b, 200_000_000);
fund(&env, &xlm_token, &user_c, 300_000_000);
fund(&env, &xlm_token, &user_d, 100_000_000);

let mut params = default_params(&env);
params.max_stake = 500_000_000;
let opt_a = Symbol::new(&env, "team_a");
let opt_b = Symbol::new(&env, "team_b");
let opt_draw = Symbol::new(&env, "draw");
params.outcomes = vec![&env, opt_a.clone(), opt_b.clone(), opt_draw.clone()];
params.creator_fee_bps = 0;

let market_id = client.create_market(&creator, &params);
assert_eq!(market_id, 1);

client.submit_prediction(&user_a, &market_id, &opt_a, &100_000_000);
client.submit_prediction(&user_b, &market_id, &opt_b, &200_000_000);
client.submit_prediction(&user_c, &market_id, &opt_draw, &300_000_000);
client.submit_prediction(&user_d, &market_id, &opt_a, &100_000_000);

let market = client.get_market(&market_id);
assert_eq!(market.total_pool, 700_000_000);
assert_eq!(market.participant_count, 4);

let dist = client.get_outcome_distribution(&market_id);
assert_eq!(dist.len(), 3);

env.ledger().set_timestamp(params.resolution_time + 1);

client.resolve_market(&oracle, &market_id, &opt_a);

let market_resolved = client.get_market(&market_id);
assert!(market_resolved.is_resolved);
assert_eq!(market_resolved.resolved_outcome, Some(opt_a.clone()));

let payout_a = client.claim_payout(&user_a, &market_id);
let payout_d = client.claim_payout(&user_d, &market_id);

assert_eq!(payout_a, 343_000_000);
assert_eq!(payout_d, 343_000_000);

let err_b = client.try_claim_payout(&user_b, &market_id);
assert!(matches!(err_b, Err(Ok(InsightArenaError::InvalidOutcome))));
}

#[test]
fn test_nway_market_5_outcomes_end_to_end() {
let env = Env::default();
env.mock_all_auths();
let (client, _admin, oracle, xlm_token) = deploy_with_token(&env);
let creator = Address::generate(&env);

let mut params = default_params(&env);
params.creator_fee_bps = 0;
params.outcomes = vec![
&env,
Symbol::new(&env, "opt1"),
Symbol::new(&env, "opt2"),
Symbol::new(&env, "opt3"),
Symbol::new(&env, "opt4"),
Symbol::new(&env, "opt5"),
];

let market_id = client.create_market(&creator, &params);

let u1 = Address::generate(&env);
let u2 = Address::generate(&env);
fund(&env, &xlm_token, &u1, 50_000_000);
fund(&env, &xlm_token, &u2, 50_000_000);

let winning_outcome = Symbol::new(&env, "opt3");
let losing_outcome = Symbol::new(&env, "opt5");

client.submit_prediction(&u1, &market_id, &winning_outcome, &50_000_000);
client.submit_prediction(&u2, &market_id, &losing_outcome, &50_000_000);

env.ledger().set_timestamp(params.resolution_time + 1);
client.resolve_market(&oracle, &market_id, &winning_outcome);

let payout1 = client.claim_payout(&u1, &market_id);
assert_eq!(payout1, 98_000_000);

let err2 = client.try_claim_payout(&u2, &market_id);
assert!(matches!(err2, Err(Ok(InsightArenaError::InvalidOutcome))));
}
Loading