Skip to content
Open
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
120 changes: 91 additions & 29 deletions prediction_market/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,12 @@ const MIN_BET: i128 = 10_000_000; // minimum net stake: 1 XLM in stroops

const MAX_BETS_PER_USER: u32 = 20;
const MAX_MARKETS_PER_HOUR: u32 = 10;
// A page also reads the market and count entries, so leave room under
// Soroban's 100-entry footprint cap for those fixed reads.
const MIN_MARKET_DURATION_SECS: u64 = 60; // issue #10: no instantly-expired markets
const MAX_BETTORS_PER_PAGE: u32 = 100;
// A page also reads the market and count entries, so leave room under
// Soroban's 100-entry footprint cap for those fixed reads.
const MAX_BETTORS_PER_PAGE: u32 = 97;

// Fee constants — multiply before divide to avoid precision loss
const TOTAL_FEE_BPS: i128 = 200;
Expand Down Expand Up @@ -204,11 +208,15 @@ pub struct PendingConfigChange {
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BetEntry {
pub net: i128, // post-fee amount bet (used for payout)
pub gross: i128, // pre-fee amount sent (used for cancel_refund)
pub is_yes: bool,
pub claimed: bool,
pub count: u32, // how many times this user has bet on this market
pub net: i128, // post-fee amount bet on current side (used for payout)
pub gross: i128, // pre-fee amount sent on current side (used for cancel_refund)
pub is_yes: bool, // current side: true = YES, false = NO
pub claimed: bool, // claim status for current side
pub count: u32, // how many times this user has bet on this market
// ── Opposite-side position for capital-preserving side-switching ───────
pub opposite_net: i128, // net on opposite side (0 = no opposite position)
pub opposite_gross: i128, // gross on opposite side (0 = no opposite position)
pub opposite_claimed: bool, // claim status for opposite side
}

// ── WithdrawalRequest: capped, recipient-validated, timelocked (issue #12) ──
Expand Down Expand Up @@ -471,10 +479,7 @@ impl PredictionMarketContract {
(Symbol::new(&env, "cfg_act"), caller),
pending.cfg,
);
env.events().publish(
(Symbol::new(&env, "config_changed"), admin),
(token_contract, referral_contract, leaderboard_contract, xlm_sac),
);

Ok(())
}

Expand Down Expand Up @@ -595,6 +600,7 @@ impl PredictionMarketContract {
.instance()
.get(&DataKey::GovernorCount)
.unwrap_or(0)
}
/// The cross-contract ABI version this deployment implements (issue #84).
pub fn interface_version(_env: Env) -> u32 {
INTERFACE_VERSION
Expand Down Expand Up @@ -817,13 +823,12 @@ impl PredictionMarketContract {
env.storage().persistent().remove(&lock_key);
return Err(MarketError::TooManyBets);
}
if e.is_yes != is_yes {
env.storage().persistent().remove(&lock_key);
return Err(MarketError::OppositeSideBet);
}
// Opposite-side check removed - allow side switching
// But validate that switching is capital-preserving (handled in write logic)
}

let is_increase = existing.is_some();
let prior_current = existing.as_ref().map(|entry| (entry.is_yes, entry.net));

// ── Fee calculation — use precomputed multipliers ─────────────────
let total_fee = amount * TOTAL_FEE_BPS / BPS_DENOM;
Expand All @@ -840,20 +845,43 @@ impl PredictionMarketContract {
// as surplus (issue #78), so the market never holds it for withdrawal.
Self::credit_market_fees(&env, market_id, platform_fee);

// ── Write BetEntry (net + gross + count in one write) ─────────────
// `net`/`gross`/`claimed` always describe the side in `e.is_yes`.
// The opposite fields are retained for the other side (and for safe
// cancellation of entries written by earlier versions).
let new_entry = match existing {
Some(mut e) => {
e.net += net;
e.gross += amount;
e.count += 1;
e
if e.is_yes != is_yes {
// Move the complete old position into the new current-side
// bucket, then add this wager. Nothing remains on the side
// being left.
let moved_net = e.net;
let moved_gross = e.gross;
e.is_yes = is_yes;
e.net = moved_net + net;
e.gross = moved_gross + amount;
e.claimed = false;
e.opposite_net = 0;
e.opposite_gross = 0;
e.opposite_claimed = false;
e.count += 1;
e
} else {
// Same-side bet: increase existing position
e.net += net;
e.gross += amount;
e.count += 1;
e
}
}
None => BetEntry {
net,
gross: amount,
is_yes,
claimed: false,
count: 1,
opposite_net: 0,
opposite_gross: 0,
opposite_claimed: false,
},
};
env.storage().persistent().set(&bet_key, &new_entry);
Expand All @@ -879,7 +907,23 @@ impl PredictionMarketContract {
}

// ── Market totals ─────────────────────────────────────────────────
if is_yes {
if let Some((old_is_yes, old_net)) = prior_current {
if old_is_yes != is_yes {
// The existing current position changes pools along with the
// entry, then the new wager joins that destination pool.
if old_is_yes {
market.total_yes -= old_net;
market.total_no += old_net + net;
} else {
market.total_no -= old_net;
market.total_yes += old_net + net;
}
} else if is_yes {
market.total_yes += net;
} else {
market.total_no += net;
}
} else if is_yes {
market.total_yes += net;
} else {
market.total_no += net;
Expand Down Expand Up @@ -1231,13 +1275,21 @@ impl PredictionMarketContract {
.get(&bet_key)
.ok_or(MarketError::NoBetFound)?;

if entry.gross == 0 {
let refund = if !entry.claimed { entry.gross } else { 0 }
+ if !entry.opposite_claimed {
entry.opposite_gross
} else {
0
};
if refund == 0 {
return Err(MarketError::NoBetFound);
}

let gross = entry.gross;
entry.gross = 0;
entry.net = 0;
// Mark both buckets before the token transfer. This supports entries
// with an opposite balance while remaining idempotent for normal,
// single-current-side entries.
entry.claimed = true;
entry.opposite_claimed = true;
env.storage().persistent().set(&bet_key, &entry);
// Read-time TTL refresh (issue #9): a refund must not be able to observe
// an expired bet/market record — keep both alive so a user who returns
Expand All @@ -1253,14 +1305,15 @@ impl PredictionMarketContract {
token::Client::new(&env, &cfg.xlm_sac).transfer(
&env.current_contract_address(),
&user,
&gross,
&refund,
);

Ok(refund)
env.events().publish(
(Symbol::new(&env, "cancel_refund"), user, market_id),
gross,
refund,
);
Ok(gross)
Ok(refund)
}

// ── Claim ─────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -1343,16 +1396,25 @@ impl PredictionMarketContract {
Self::require_compatible_leaderboard(&env, &cfg.leaderboard)?;
let _: Val = env.invoke_contract(
&cfg.leaderboard,
&Symbol::new(&env, "reward"),
&Symbol::new(&env, "add_pts"),
vec![
&env,
this.clone().into_val(&env),
user.clone().into_val(&env),
points.into_val(&env),
tokens.into_val(&env),
real_win.into_val(&env),
],
);
let _: Val = env.invoke_contract(
&cfg.token,
&Symbol::new(&env, "mint"),
vec![
&env,
this.into_val(&env),
user.into_val(&env),
tokens.into_val(&env),
],
);

env.events().publish(
(Symbol::new(&env, "claim_processed"), user, market_id),
Expand Down
111 changes: 109 additions & 2 deletions prediction_market/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,14 +397,18 @@ fn test_increase_position_same_side() {
// ── 12. Reject opposite-side bet ─────────────────────────────────────────────

#[test]
#[should_panic(expected = "Error(Contract, #11)")]
fn test_reject_opposite_side_bet() {
fn test_switch_opposite_side_bet_moves_pool() {
let t = setup();
let id = create_test_market(&t);
let user = Address::generate(&t.env);
fund_user(&t, &user, 500_0000000);
t.client.place_bet(&user, &id, &true, &100_0000000_i128);
t.client.place_bet(&user, &id, &false, &50_0000000_i128);

let market = t.client.get_market(&id);
assert_eq!(market.total_yes, 0);
assert_eq!(market.total_no, 147_0000000);
assert_eq!(t.client.get_user_bet_count(&id, &user), 2);
}

// ── 13. Resolve market ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -1550,6 +1554,109 @@ fn test_cancel_refund_rebumps_ttl_entries() {
assert!(ttl(&market_key) > market_before);
}

#[test]
fn test_switch_yes_to_no_claims_no_position() {
let t = setup();
let id = create_test_market(&t);
let user = Address::generate(&t.env);
fund_user(&t, &user, 500_0000000);

t.client.place_bet(&user, &id, &true, &100_0000000_i128);
t.client.place_bet(&user, &id, &false, &100_0000000_i128);
let market = t.client.get_market(&id);
assert_eq!(market.total_yes, 0);
assert_eq!(market.total_no, 196_0000000);

advance_time(&t.env, 3601);
t.client.resolve_market(&t.admin, &id, &false);
let before_claim = t.xlm.balance(&user);
t.client.claim(&user, &id);
assert_eq!(t.xlm.balance(&user) - before_claim, 196_0000000);
}

#[test]
fn test_switch_back_to_yes_claims_moved_position() {
let t = setup();
let id = create_test_market(&t);
let user = Address::generate(&t.env);
fund_user(&t, &user, 500_0000000);

t.client.place_bet(&user, &id, &true, &100_0000000_i128);
t.client.place_bet(&user, &id, &false, &100_0000000_i128);
t.client.place_bet(&user, &id, &true, &100_0000000_i128);
let market = t.client.get_market(&id);
assert_eq!(market.total_yes, 294_0000000);
assert_eq!(market.total_no, 0);
assert_eq!(t.client.get_user_bet_count(&id, &user), 3);

advance_time(&t.env, 3601);
t.client.resolve_market(&t.admin, &id, &true);
let before_claim = t.xlm.balance(&user);
t.client.claim(&user, &id);
assert_eq!(t.xlm.balance(&user) - before_claim, 294_0000000);
}

#[test]
fn test_switch_yes_to_no_cancel_refunds_full_gross() {
let t = setup();
let id = create_test_market(&t);
let user = Address::generate(&t.env);
fund_user(&t, &user, 500_0000000);
let before = t.xlm.balance(&user);

t.client.place_bet(&user, &id, &true, &100_0000000_i128);
t.client.place_bet(&user, &id, &false, &100_0000000_i128);
t.client.cancel_market(&t.admin, &id);

assert_eq!(t.client.cancel_refund(&user, &id), 200_0000000);
assert_eq!(t.xlm.balance(&user), before);
}

#[test]
#[should_panic(expected = "Error(Contract, #17)")]
fn test_repeated_switches_respect_bet_cap() {
let t = setup();
let id = create_test_market(&t);
let user = Address::generate(&t.env);
fund_user(&t, &user, 3_000_0000000);

for i in 0..MAX_BETS_PER_USER {
t.client
.place_bet(&user, &id, &(i % 2 == 0), &100_0000000_i128);
}
assert_eq!(t.client.get_user_bet_count(&id, &user), MAX_BETS_PER_USER);
t.client.place_bet(&user, &id, &true, &100_0000000_i128);
}

#[test]
fn test_no_switch_payout_and_refund_math_is_unchanged() {
let t = setup();
let winner = Address::generate(&t.env);
let loser = Address::generate(&t.env);
fund_user(&t, &winner, 500_0000000);
fund_user(&t, &loser, 200_0000000);

let resolved_id = create_test_market(&t);
t.client
.place_bet(&winner, &resolved_id, &true, &100_0000000_i128);
t.client
.place_bet(&loser, &resolved_id, &false, &100_0000000_i128);
advance_time(&t.env, 3601);
t.client.resolve_market(&t.admin, &resolved_id, &true);
let before_claim = t.xlm.balance(&winner);
t.client.claim(&winner, &resolved_id);
assert_eq!(t.xlm.balance(&winner) - before_claim, 196_0000000);

let cancelled_id = create_test_market(&t);
let before_refund = t.xlm.balance(&winner);
t.client
.place_bet(&winner, &cancelled_id, &true, &100_0000000_i128);
t.client.cancel_market(&t.admin, &cancelled_id);
assert_eq!(
t.client.cancel_refund(&winner, &cancelled_id),
100_0000000
);
assert_eq!(t.xlm.balance(&winner), before_refund);
// ── #54: permissionless refresh + per-market expiry tracking + migration ─────

#[test]
Expand Down
Loading