Skip to content
Merged
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
264 changes: 143 additions & 121 deletions contracts/market/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,29 +1,11 @@
#![no_std]
use shared::types::{Bet, BetSide, Fighter, Market, MarketStatus, Outcome, ProtocolConfig};
use soroban_sdk::{
contract, contractimpl, contracttype, symbol_short, Address, Bytes, Env, String, Symbol, Vec,
};
use crate::types::{Bet, BetSide, ClaimReceipt, Fighter, Market, MarketResolved, MarketStatus, Outcome, ProtocolConfig, WinningsClaimed};
use soroban_sdk::{contract, contractimpl, contracttype, symbol_short, token, Address, Bytes, Env, String, Symbol, Vec};

const MARKET_INFO_KEY: &str = "market_info";
const NEXT_BET_ID_KEY: &str = "next_bet_id";

// ─── STORAGE KEYS ─────────────────────────────────────────────────────────────
// MarketInfo -> Market
// Factory -> Address
// Bet(bet_id) -> Bet
// BetsByAddr(addr) -> Vec<Bytes>
// Claimed(bet_id) -> bool
// DisputeRaised -> bool
// DisputeReason -> Bytes

pub mod types;

use soroban_sdk::{
contract, contractimpl, contracttype, Address, Bytes, Env, Symbol, Vec,
contract, contractimpl, contracttype, symbol_short, Address, Bytes, Env, String, Symbol, Vec,
};
use types::{Bet, BetSide, Fighter, Market, MarketStatus, Outcome, ProtocolConfig};
use types::{Bet, BetSide, ClaimReceipt, Fighter, Market, MarketResolved, MarketStatus, Outcome, ProtocolConfig, WinningsClaimed};

// ─── STORAGE KEYS ─────────────────────────────────────────────────────────────
// DataKey::MarketInfo -> Market
Expand Down Expand Up @@ -126,28 +108,15 @@ impl MarketContract {
total_pool: 0,
protocol_fee_bp,
oracle_address: oracle,
outcome: SettledOutcome::Pending,
fee_collector_address: fee_collector,
outcome: None,
fee_collector_address: fee_collector,
resolved_at: 0,
dispute_window_sec,
};
env.storage().persistent().set(&DataKey::MarketInfo, &market);
env.storage().persistent().set(&DataKey::Factory, &factory);
}

/// Accepts a bet from bettor and records it in escrow.
/// Transfers XLM from bettor to this contract.
pub fn place_bet(env: Env, bettor: Address, side: BetSide, amount: i128) -> Bytes {
bettor.require_auth();

let mut market: Market = env.storage().persistent()
.get(&DataKey::MarketInfo)
.expect("market not initialized");
/// Accepts a bet from bettor and records it.
/// Panics if market is not Open, or if current time > betting_ends_at.
/// A bet placed exactly at betting_ends_at is still valid.
pub fn place_bet(env: Env, bettor: Address, side: BetSide, amount: i128) -> Bytes {
/// Places a bet on a fighter in this market.
///
/// Transfers XLM from `bettor` to this contract (escrow), records the bet,
Expand Down Expand Up @@ -204,11 +173,6 @@ impl MarketContract {
panic!("above maximum bet");
}

// Reject bets after betting_ends_at; bets at exactly betting_ends_at are valid.
if env.ledger().timestamp() > market.betting_ends_at {
panic!("betting period has ended");
}

if amount <= 0 {
panic!("amount must be positive");
}
Expand All @@ -220,16 +184,6 @@ impl MarketContract {
market.total_pool = market.total_pool.checked_add(amount).expect("total_pool overflow");

let bet_count: u64 = env.storage().persistent()
.get(&Symbol::new(&env, "BET_CNT"))
.unwrap_or(0u64);
let new_count = bet_count + 1;
env.storage().persistent().set(&Symbol::new(&env, "BET_CNT"), &new_count);
let mut id_bytes = [0u8; 32];
id_bytes[..8].copy_from_slice(&new_count.to_be_bytes());
let bet_id = Bytes::from_array(&env, &id_bytes);
let bet_count: u64 = env
.storage()
.persistent()
.get(&Symbol::new(&env, "BET_COUNT"))
.unwrap_or(0u64);
let new_count = bet_count + 1;
Expand All @@ -250,19 +204,6 @@ impl MarketContract {
claimed: false,
};
env.storage().persistent().set(&DataKey::Bet(bet_id.clone()), &bet);
env.storage().persistent().set(&DataKey::MarketInfo, &market);

let mut addr_bets: Vec<Bytes> = env.storage().persistent()
.get(&DataKey::BetsByAddr(bettor.clone()))
.unwrap_or(Vec::new(&env));
addr_bets.push_back(bet_id.clone());
env.storage().persistent().set(&DataKey::BetsByAddr(bettor.clone()), &addr_bets);

env.events().publish(
(symbol_short!("bet_placed"),),
env.storage()
.persistent()
.set(&DataKey::Bet(bet_id.clone()), &bet);

let mut bets: Vec<Bytes> = env
.storage()
Expand Down Expand Up @@ -563,11 +504,11 @@ impl MarketContract {
}


/// Raises a dispute against the market resolution.
/// Dispute resolution - allows bettors to challenge submitted market resolutions.
///
/// Transitions status to `Disputed`, freezing all claim processing until an admin
/// settles the dispute. Must be called within `dispute_window_sec` of `resolved_at`.
/// Only one active dispute is allowed per market. Emits a `DisputeRaised` event.
/// Only one active dispute is allowed per market. Emits a `resolution_disputed` event.
///
/// # Arguments
///
Expand All @@ -584,7 +525,7 @@ impl MarketContract {
/// - The dispute window has elapsed since resolution.
/// - A dispute is already active on this market.
/// - The market status is not `Resolved`.
pub fn raise_dispute(env: Env, bettor: Address, reason: Bytes) {
pub fn dispute_resolution(env: Env, bettor: Address, reason: Bytes) {
bettor.require_auth();

let mut market = Self::read_market(&env);
Expand Down Expand Up @@ -625,7 +566,7 @@ impl MarketContract {
env.storage().persistent().set(&DataKey::DisputeReason, &reason);

env.events().publish(
(Symbol::new(&env, "DisputeRaised"),),
(Symbol::new(&env, "resolution_disputed"),),
(market.market_id.clone(), bettor.clone(), reason),
);
}
Expand All @@ -648,8 +589,101 @@ impl MarketContract {
/// - `admin` has not authorized the call or is not the configured admin.
/// - The market status is not `Disputed`.
pub fn resolve_dispute(env: Env, admin: Address, override_outcome: Outcome) {
let _ = (env, admin, override_outcome);
todo!("implement: require_auth(admin), validate status==Disputed, update outcome, set status=Resolved")
admin.require_auth();

let factory: Address = env
.storage()
.persistent()
.get(&DataKey::Factory)
.expect("factory not set");
let config: ProtocolConfig = env.invoke_contract(
&factory,
&Symbol::new(&env, "get_config"),
soroban_sdk::vec![&env],
);
if config.admin != admin {
panic!("not factory admin");
}

let mut market = Self::read_market(&env);
if market.status != MarketStatus::Disputed {
panic!("market not in disputed state");
}

market.outcome = Some(override_outcome.clone());
market.status = MarketStatus::Resolved;
Self::write_market(&env, &market);

env.events().publish(
(Symbol::new(&env, "DisputeResolved"),),
(market.market_id.clone(), override_outcome),
);
}

/// Finalizes the market resolution after dispute window expires or admin override.
///
/// Supports two scenarios:
/// 1. Permissionless finalization when market is Resolved and dispute window has elapsed
/// 2. Admin-controlled finalization when market is Disputed (admin-only)
///
/// After finalization, the `claim_winnings` function becomes available.
/// Emits a `ResolutionFinalized` event.
///
/// # Arguments
///
/// * `env` - The Soroban execution environment.
/// * `admin` - (Optional) Address of the protocol admin. Required only when market is Disputed.
/// If market is Resolved and window has elapsed, any caller can finalize.
///
/// # Panics
///
/// Panics if:
/// - Market status is Resolved but dispute window has not elapsed yet.
/// - Market status is Disputed but caller is not the admin.
/// - Market is in any other status (Open, Locked, Cancelled).
pub fn finalize_resolution(env: Env, admin: Option<Address>) {
let mut market = Self::read_market(&env);

match market.status {
MarketStatus::Resolved => {
let current_time = env.ledger().timestamp();
let dispute_deadline = market.resolved_at + market.dispute_window_sec;
if current_time <= dispute_deadline {
panic!("dispute window still open");
}
market.status = MarketStatus::Resolved;
Self::write_market(&env, &market);
}
MarketStatus::Disputed => {
if let Some(admin_addr) = admin {
admin_addr.require_auth();
let factory: Address = env
.storage()
.persistent()
.get(&DataKey::Factory)
.expect("factory not set");
let config: ProtocolConfig = env.invoke_contract(
&factory,
&Symbol::new(&env, "get_config"),
soroban_sdk::vec![&env],
);
if config.admin != admin_addr {
panic!("not factory admin");
}
} else {
panic!("admin required for disputed market");
}

market.status = MarketStatus::Resolved;
Self::write_market(&env, &market);
}
_ => panic!("market cannot be finalized in current state"),
}

env.events().publish(
(Symbol::new(&env, "ResolutionFinalized"),),
(market.market_id.clone(), env.ledger().timestamp()),
);
}

/// Returns the full [`Market`] struct for this contract.
Expand All @@ -668,10 +702,7 @@ impl MarketContract {
///
/// Panics if the market has not been initialized.
pub fn get_market_info(env: Env) -> Market {
env.storage().persistent()
.get(&DataKey::MarketInfo)
env.storage().persistent().get(&DataKey::MarketInfo)
.expect("market not initialized")
Self::read_market(&env)
}

/// Returns the [`Bet`] identified by `bet_id`.
Expand All @@ -691,43 +722,6 @@ impl MarketContract {
///
/// Panics if `bet_id` does not correspond to any recorded bet.
pub fn get_bet(env: Env, bet_id: Bytes) -> Bet {
env.storage().persistent()
todo!("implement: require_auth(bettor), verify bettor has a bet, check within window, check no existing dispute, set status=Disputed, store reason")
}

/// Admin-only. Settles a disputed market with a final override outcome.
/// require_auth() is the first call.
pub fn resolve_dispute(env: Env, admin: Address, override_outcome: Outcome) {
admin.require_auth();

let mut market = Self::read_market(&env);
if market.status != MarketStatus::Disputed {
panic!("market not in disputed state");
}

market.outcome = match override_outcome {
Outcome::FighterA => SettledOutcome::FighterA,
Outcome::FighterB => SettledOutcome::FighterB,
Outcome::Draw => SettledOutcome::Draw,
Outcome::NoContest => SettledOutcome::NoContest,
};
market.status = MarketStatus::Resolved;
Self::write_market(&env, &market);

env.events().publish(
(Symbol::new(&env, "DisputeResolved"),),
market.market_id.clone(),
);
}

pub fn get_market_info(env: Env) -> Market {
Self::read_market(&env)
}

pub fn get_bet(env: Env, bet_id: Bytes) -> Bet {
env.storage()
.persistent()
.get(&DataKey::Bet(bet_id))
env.storage().persistent().get(&DataKey::Bet(bet_id))
.expect("bet not found")
}
Expand Down Expand Up @@ -756,16 +750,6 @@ impl MarketContract {
}
}
bets
let _ = (env, bettor);
todo!("implement: read BetsByAddr for bet_ids, map to Bet structs, return vec")
let mut bets: Vec<Bet> = Vec::new(&env);
for bet_id in bet_ids.iter() {
let bet: Bet = env.storage().persistent()
.get(&DataKey::Bet(bet_id))
.expect("bet not found for bet_id in index");
bets.push_back(bet);
}
bets
}

/// Estimates the payout for a bet based on current pool sizes.
Expand All @@ -786,8 +770,46 @@ impl MarketContract {
///
/// Panics if `bet_id` does not correspond to any recorded bet.
pub fn calculate_payout(env: Env, bet_id: Bytes) -> i128 {
let _ = (env, bet_id);
todo!("implement: read bet + market pools, apply payout formula, return estimate")
let bet: Bet = env.storage().persistent()
.get(&DataKey::Bet(bet_id))
.expect("bet not found");

let market: Market = env.storage().persistent()
.get(&DataKey::MarketInfo)
.expect("market not initialized");

let outcome = match market.outcome.clone() {
Some(o) => o,
None => return 0,
};

let is_winner = match (&bet.side, &outcome) {
(BetSide::FighterA, Outcome::FighterA) => true,
(BetSide::FighterB, Outcome::FighterB) => true,
_ => false,
};

if !is_winner {
return 0;
}

let winning_pool = match outcome {
Outcome::FighterA => market.pool_a,
Outcome::FighterB => market.pool_b,
_ => market.pool_a.checked_add(market.pool_b).expect("pool sum overflow"),
};

if winning_pool == 0 {
return 0;
}

let fee_amount = shared::types::calculate_fee(market.total_pool, market.protocol_fee_bp);
let net_pool = market.total_pool.checked_sub(fee_amount).expect("net pool underflow");
bet.amount
.checked_mul(net_pool)
.expect("payout overflow")
.checked_div(winning_pool)
.expect("payout div zero")
}

/// Returns current pool sizes and implied odds for both fighters.
Expand Down
Loading