diff --git a/CHANGELOG.md b/CHANGELOG.md index 561e96d4..fdd5296e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to this repository are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2026-09-22] - Betting market events have a draft state and a close time + +The betting market locked its outcome list implicitly, by refusing +`add_outcome` once `total_pool` was nonzero. Anyone could bet one minor unit on +a half-built market to freeze it with a single outcome, and the admin's +`add_outcome` and the first bet could land in either order in the same slot. +Nothing stopped bets between the real-world result and settlement, either: +`place_bet` accepted stakes until `settle_event` ran. + +- Events start as `Draft`. `add_outcome` works only on a draft (else the new + `EventNotDraft`, which replaces `BettingAlreadyStarted`), and a new admin + handler, `open_betting`, moves a draft with at least two outcomes (else + `NotEnoughOutcomes`) to `Open`. `place_bet` on a draft fails with + `EventNotOpen`, so the outcome list is final before any money can arrive. +- `initialize_event` takes a `betting_closes_at` timestamp, which must be in the + future (else `CloseTimeInPast`) and is stored on the event. `place_bet` + requires `now < betting_closes_at` (else `BettingClosed`) and `settle_event` + requires `now >= betting_closes_at` (else `BettingStillOpen`). +- `cancel_event` accepts a draft as well as an open event. +- Changed in the Anchor v2 and Quasar ports, with new tests for each rule, and + two new Kani proofs: the betting and settlement windows partition time, and a + lifecycle model in which the outcome list never changes once money is in the + pool. The Anchor v1 port is a frozen snapshot and does not change. + ## [2026-09-22] - The first-deposit minimum is counted in every share divisor The token swap and perpetual futures examples both withhold a minimum from the diff --git a/finance/betting-market/README.md b/finance/betting-market/README.md index 41ed37e4..0a9cec0b 100644 --- a/finance/betting-market/README.md +++ b/finance/betting-market/README.md @@ -1,6 +1,6 @@ # Betting Market -A parimutuel (pooled) betting market. An admin opens an event and its possible outcomes; bettors stake a token on the outcome they expect to win. All stakes share one pool, and when the admin settles the event, losing stakes (minus a protocol fee) are split among winners in proportion to their stake. +A parimutuel (pooled) betting market. An admin creates an event, adds its possible outcomes, and opens it to bets; bettors stake a token on the outcome they expect to win until betting closes. All stakes share one pool, and when the admin settles the event, losing stakes (minus a protocol fee) are split among winners in proportion to their stake. [⚓ Anchor](./anchor) diff --git a/finance/betting-market/anchor/CHANGELOG.md b/finance/betting-market/anchor/CHANGELOG.md index 1f23dc2f..c489ddd4 100644 --- a/finance/betting-market/anchor/CHANGELOG.md +++ b/finance/betting-market/anchor/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [2026-09-22] + +### Changed + +- Events start as `Draft` and move to `Open` through a new admin handler, + `open_betting`, which needs at least two outcomes (`NotEnoughOutcomes`). + `add_outcome` works only on a draft (`EventNotDraft`, replacing + `BettingAlreadyStarted`), and `place_bet` on a draft fails with + `EventNotOpen`, so the outcome list is fixed before any bet can land. +- `initialize_event` takes `betting_closes_at` (after `event_id`, before the + description), which must be in the future (`CloseTimeInPast`). `place_bet` + requires `now < betting_closes_at` (`BettingClosed`) and `settle_event` + requires `now >= betting_closes_at` (`BettingStillOpen`). +- `cancel_event` accepts a draft as well as an open event. + ## 2026-09-08 Renamed `Config.fee_bps` to `Config.default_fee_bps` (and the matching diff --git a/finance/betting-market/anchor/README.md b/finance/betting-market/anchor/README.md index 72682fec..1a994704 100644 --- a/finance/betting-market/anchor/README.md +++ b/finance/betting-market/anchor/README.md @@ -6,8 +6,9 @@ > no prebuilt binary for this pre-release). The Anchor v1 version of this example is in > [`../anchor-v1`](../anchor-v1/). -A parimutuel (pooled) betting market on Solana. An admin opens an **event**, adds the possible -**outcomes**, and bettors stake a token on the outcome they think will win. Every stake across +A parimutuel (pooled) betting market on Solana. An admin creates an **event**, adds the possible +**outcomes**, and opens it to bets; bettors then stake a token on the outcome they think will win, +until the event's betting close time. Every stake across every outcome goes into one pool. When the admin settles the event to the winning outcome, the losing stakes - minus a protocol fee - are split among the winners in proportion to their stake. @@ -21,20 +22,22 @@ them in one place no single bettor controls, and paying winners by a fixed, publ still requires trusting the admin, who chooses the winning outcome, as described below. The pool is a token account owned by the event's PDA, so payouts are signed by the program with the event's seeds - there is no admin key that can move bettors' stakes out of the pool. The admin's only -powers are creating events/outcomes and choosing the winning outcome (or cancelling). +powers are creating events/outcomes, opening them to bets, and choosing the winning outcome (or +cancelling). ## Major Concepts ### Accounts - **Config** (`seeds = [b"config"]`) - one per deployment. Holds the `admin` (the only key that can - create events/outcomes, settle, and cancel), the `token_mint` every market accepts, the - `fee_recipient`, and the `default_fee_bps` each new event copies at creation. + create events/outcomes, open betting, settle, and cancel), the `token_mint` every market accepts, + the `fee_recipient`, and the `default_fee_bps` each new event copies at creation. - **Event** (`seeds = [b"event", event_id]`) - one betting market. Tracks `total_pool`, `status` - (`Open` / `Settled` / `Cancelled`), and - once settled - the `winning_outcome_index`, - `winning_pool`, and `distributable_losing_pool` that the payout formula reads. The event's - `fee_bps` is copied from the config's `default_fee_bps` at creation and is what settlement - charges, so later Config changes can't alter a market bettors have already joined. + (`Draft` / `Open` / `Settled` / `Cancelled`), `betting_closes_at`, and - once settled - the + `winning_outcome_index`, `winning_pool`, and `distributable_losing_pool` that the payout formula + reads. The event's `fee_bps` is copied from the config's `default_fee_bps` at creation and is what + settlement charges. It and `betting_closes_at` are fixed at creation, so later Config changes + can't alter a market bettors have already joined. - **Outcome** (`seeds = [b"outcome", event, index]`) - one possible result. Its `total_amount` is the outcome's share of the pool and the denominator for pro-rata payouts when it wins. - **Bet** (`seeds = [b"bet", outcome, bettor]`) - a bettor's total stake on one outcome. Re-betting @@ -82,22 +85,44 @@ division floors each share, leaving at most a few minor units of dust in the vau - `initialize_config` - anyone (the signer becomes admin). One-time setup: sets admin, stake token, default fee, fee recipient. -- `initialize_event` - admin. Opens a market and creates its vault. -- `add_outcome` - admin. Adds a possible result. Only before any bet is placed. -- `place_bet` - bettor. Stakes tokens on one outcome; updates the pools and adds the Bet to the - user's index (rejected with `TooManyBets` if all `MAX_BETS_PER_USER` slots hold open positions). -- `settle_event` - admin. Resolves to a winning outcome, takes the fee, records the payout figures. +- `initialize_event` - admin. Creates a market as a `Draft`, fixes its `betting_closes_at` (which + must be in the future), and creates its vault. +- `add_outcome` - admin. Adds a possible result. Only while the event is a `Draft`. +- `open_betting` - admin. Moves a `Draft` with at least two outcomes to `Open`, which fixes the + outcome list. +- `place_bet` - bettor. Stakes tokens on one outcome of an `Open` event, before + `betting_closes_at`; updates the pools and adds the Bet to the user's index (rejected with + `TooManyBets` if all `MAX_BETS_PER_USER` slots hold open positions). +- `settle_event` - admin. Once `betting_closes_at` has passed, resolves to a winning outcome, takes + the fee, records the payout figures. - `claim_winnings` - winning bettor. Withdraws stake plus pro-rata share of the losing pool, then closes the Bet account and removes it from the user's index. - `close_losing_bet` - losing bettor. After settlement, closes a worthless Bet to reclaim its rent and free the slot in the user's index. -- `cancel_event` - admin. Voids an unresolved market. +- `cancel_event` - admin. Voids a draft or unresolved market. - `claim_refund` - bettor. After a cancellation, reclaims the exact stake; the Bet account closes and leaves the user's index. -`add_outcome` is locked once betting starts, so the field of choices can't change under existing -bettors. `settle_event` rejects a winning outcome with no bets - use `cancel_event` to unwind an -event that can't be resolved fairly. +### Lifecycle + +``` +Draft --open_betting--> Open --settle_event (at or after betting_closes_at)--> Settled + | | + +------cancel_event----+----------------------------------------------> Cancelled +``` + +The outcome list is fixed when the admin opens betting, before any money can arrive: a bet on a +`Draft` is rejected with `EventNotOpen`, and `add_outcome` on anything but a `Draft` with +`EventNotDraft`. So no bettor can freeze a half-built market with an early bet, and no outcome can +be added under someone who has already staked. + +Time splits the `Open` state in two. Bets land only while `now < betting_closes_at` (else +`BettingClosed`), and `settle_event` only once `now >= betting_closes_at` (else +`BettingStillOpen`). The two windows never overlap, so nobody can stake after the result could be +known, and the admin cannot end a market before the window bettors were promised. + +`settle_event` rejects a winning outcome with no bets - use `cancel_event` to unwind an event that +can't be resolved fairly. ## Setup @@ -114,7 +139,8 @@ anchor build Tests are Rust integration tests running against [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm) with [solana-kite](https://crates.io/crates/solana-kite) helpers. They cover the full lifecycle (bet → settle → claim with exact payout and fee assertions), admin authorization, the -bet-after-settle and double-claim guards, settling an outcome with no bets, the cancel/refund +bet-after-settle and double-claim guards, the outcome list locking when betting opens, the +two-outcome minimum, both edges of the betting close time, settling an outcome with no bets, the cancel/refund path, the `close_losing_bet` guards, and the User index: claims, refunds, and losing-bet closes remove the Bet's entry, and a wallet whose index is full can bet again after closing a position. @@ -128,7 +154,7 @@ anchor test ### How does a prediction market work on Solana? -This example uses the parimutuel (pooled) model: an admin opens an event with `initialize_event` and `add_outcome`, and bettors stake tokens on an outcome with `place_bet`. Every stake goes into one pool; after `settle_event` names the winning outcome, winners call `claim_winnings` to split the losing stakes, minus a protocol fee, in proportion to their own stake. +This example uses the parimutuel (pooled) model: an admin sets up an event with `initialize_event` and `add_outcome` and opens it with `open_betting`, and bettors stake tokens on an outcome with `place_bet` until betting closes. Every stake goes into one pool; after `settle_event` names the winning outcome, winners call `claim_winnings` to split the losing stakes, minus a protocol fee, in proportion to their own stake. ### How are the odds set? diff --git a/finance/betting-market/anchor/programs/betting-market/Cargo.toml b/finance/betting-market/anchor/programs/betting-market/Cargo.toml index 69060e10..9a3e13d1 100644 --- a/finance/betting-market/anchor/programs/betting-market/Cargo.toml +++ b/finance/betting-market/anchor/programs/betting-market/Cargo.toml @@ -58,6 +58,9 @@ spl-token = { version = "9.0.0", features = ["no-entrypoint"] } spl-associated-token-account = { version = "8.0.0", features = ["no-entrypoint"] } solana-kite = "0.4.0" borsh = "1.6.1" +# LiteSVM's `get_sysvar` / `set_sysvar` want the host-side Clock type; the +# tests move `unix_timestamp` across the betting close time. +solana-clock = "3.0.1" [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/finance/betting-market/anchor/programs/betting-market/src/error.rs b/finance/betting-market/anchor/programs/betting-market/src/error.rs index ecf7c14d..a7d7c343 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/error.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/error.rs @@ -28,10 +28,18 @@ pub enum BettingError { BetNotInUserIndex, #[msg("Arithmetic overflow")] MathOverflow, - #[msg("Outcomes can only be added before any bets are placed")] - BettingAlreadyStarted, + #[msg("Outcomes can only be added, and betting opened, while the event is a draft")] + EventNotDraft, #[msg("The event description is too long")] DescriptionTooLong, #[msg("The outcome label is too long")] LabelTooLong, + #[msg("An event needs at least two outcomes before betting opens")] + NotEnoughOutcomes, + #[msg("The betting close time must be in the future")] + CloseTimeInPast, + #[msg("Betting on this event has closed")] + BettingClosed, + #[msg("Betting on this event is still open, so it cannot be settled yet")] + BettingStillOpen, } diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/add_outcome.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/add_outcome.rs index aa6e4b65..6d8eb000 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/add_outcome.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/add_outcome.rs @@ -39,15 +39,11 @@ pub fn handle_add_outcome( label: String, ) -> Result<()> { require!(label.len() <= MAX_LABEL_LEN, BettingError::LabelTooLong); + // Outcomes can only be added to a draft. Once `open_betting` runs, the + // field of choices is final before the first bet can land. require!( - context.accounts.event.status == EventStatus::Open, - BettingError::EventNotOpen - ); - // Lock the outcome set once betting starts so the field of choices can't - // change out from under existing bettors. - require!( - context.accounts.event.total_pool == 0, - BettingError::BettingAlreadyStarted + context.accounts.event.status == EventStatus::Draft, + BettingError::EventNotDraft ); let index = context.accounts.event.outcome_count; diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/cancel_event.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/cancel_event.rs index fbd31f33..df59b381 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/cancel_event.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/cancel_event.rs @@ -24,8 +24,13 @@ pub struct CancelEventAccountConstraints { } pub fn handle_cancel_event(context: &mut Context) -> Result<()> { + // A draft or open event can be cancelled; a settled or cancelled one has + // already taken its exit. require!( - context.accounts.event.status == EventStatus::Open, + matches!( + context.accounts.event.status, + EventStatus::Draft | EventStatus::Open + ), BettingError::EventNotOpen ); context.accounts.event.status = EventStatus::Cancelled; diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/initialize_event.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/initialize_event.rs index c5c852e6..d251cc89 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/initialize_event.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/initialize_event.rs @@ -55,19 +55,25 @@ pub struct InitializeEventAccountConstraints { pub fn handle_initialize_event( context: &mut Context, event_id: u64, + betting_closes_at: i64, description: String, ) -> Result<()> { require!( description.len() <= MAX_DESCRIPTION_LEN, BettingError::DescriptionTooLong ); + // A close time already passed would make a market no one can bet on. + let now = Clock::get()?.unix_timestamp; + require!(betting_closes_at > now, BettingError::CloseTimeInPast); *context.accounts.event = Event { event_id, description, outcome_count: 0, total_pool: 0, - status: EventStatus::Open, + // Starts as a draft: outcomes are added before anyone can bet. + status: EventStatus::Draft, + betting_closes_at, fee_bps: context.accounts.config.default_fee_bps, winning_outcome_index: 0, winning_pool: 0, diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/mod.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/mod.rs index 9056cbad..b86c5895 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/mod.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/mod.rs @@ -5,6 +5,7 @@ pub mod claim_winnings; pub mod close_losing_bet; pub mod initialize_config; pub mod initialize_event; +pub mod open_betting; pub mod place_bet; pub mod settle_event; pub mod shared; @@ -16,6 +17,7 @@ pub use claim_winnings::*; pub use close_losing_bet::*; pub use initialize_config::*; pub use initialize_event::*; +pub use open_betting::*; pub use place_bet::*; pub use settle_event::*; pub use shared::*; diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/open_betting.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/open_betting.rs new file mode 100644 index 00000000..6dc156b3 --- /dev/null +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/open_betting.rs @@ -0,0 +1,44 @@ +use anchor_lang::prelude::*; + +use crate::state::{betting_is_open, Event}; + +use crate::{error::BettingError, Config, EventStatus}; + +// Move a draft event to Open. From here the outcome list is final and bettors +// can stake, so the question they bet on is fixed before any money arrives. +#[derive(Accounts)] +pub struct OpenBettingAccountConstraints { + #[account(address = config.admin @ BettingError::Unauthorized)] + pub admin: Signer, + + #[account(seeds = [b"config"], + bump = config.bump)] + pub config: BorshAccount, + + #[account( + mut, + seeds = [b"event", event.event_id.to_le_bytes()], + bump = event.bump, + )] + pub event: BorshAccount, +} + +pub fn handle_open_betting(context: &mut Context) -> Result<()> { + require!( + context.accounts.event.status == EventStatus::Draft, + BettingError::EventNotDraft + ); + // A market with one outcome has no losing side to pay the winners from. + require!( + context.accounts.event.outcome_count >= 2, + BettingError::NotEnoughOutcomes + ); + // Opening after the close time would open a market no one can bet on. + let now = Clock::get()?.unix_timestamp; + require!( + betting_is_open(now, context.accounts.event.betting_closes_at), + BettingError::BettingClosed + ); + context.accounts.event.status = EventStatus::Open; + Ok(()) +} diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/place_bet.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/place_bet.rs index 2af5be0c..2e53ae11 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/place_bet.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/place_bet.rs @@ -1,6 +1,6 @@ use anchor_lang::prelude::*; -use crate::state::Event; +use crate::state::{betting_is_open, Event}; use anchor_spl::mint; use anchor_spl::{ associated_token::AssociatedToken, @@ -86,6 +86,11 @@ pub fn handle_place_bet( context.accounts.event.status == EventStatus::Open, BettingError::EventNotOpen ); + let now = Clock::get()?.unix_timestamp; + require!( + betting_is_open(now, context.accounts.event.betting_closes_at), + BettingError::BettingClosed + ); transfer_tokens_to_vault( &mut context.accounts.bettor_token_account, diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/settle_event.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/settle_event.rs index 94a6eb07..76589801 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/settle_event.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/settle_event.rs @@ -1,6 +1,6 @@ use anchor_lang::prelude::*; -use crate::state::Event; +use crate::state::{may_settle, Event}; use anchor_spl::mint; use anchor_spl::{ associated_token::AssociatedToken, @@ -77,6 +77,13 @@ pub fn handle_settle_event( context.accounts.event.status == EventStatus::Open, BettingError::EventNotOpen ); + // Settling before the close time would let the admin end a market early + // on bettors who were promised the full window. + let now = Clock::get()?.unix_timestamp; + require!( + may_settle(now, context.accounts.event.betting_closes_at), + BettingError::BettingStillOpen + ); require!( context.accounts.winning_outcome.total_amount > 0, BettingError::OutcomeHasNoBets diff --git a/finance/betting-market/anchor/programs/betting-market/src/lib.rs b/finance/betting-market/anchor/programs/betting-market/src/lib.rs index bedb55d1..a64b63e5 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/lib.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/lib.rs @@ -28,16 +28,23 @@ pub mod betting_market { ) } - // Admin opens a new market and creates its pool vault. + // Admin creates a new market as a draft, fixes when betting closes, and + // creates its pool vault. pub fn initialize_event( context: &mut Context, event_id: u64, + betting_closes_at: i64, description: String, ) -> Result<()> { - instructions::initialize_event::handle_initialize_event(context, event_id, description) + instructions::initialize_event::handle_initialize_event( + context, + event_id, + betting_closes_at, + description, + ) } - // Admin adds a possible result. Only allowed before betting starts. + // Admin adds a possible result. Only allowed while the event is a draft. pub fn add_outcome( context: &mut Context, label: String, @@ -45,12 +52,19 @@ pub mod betting_market { instructions::add_outcome::handle_add_outcome(context, label) } - // A bettor stakes tokens on one outcome. The stake joins the event's pool. + // Admin finalizes the outcome list and opens the market to bets. Needs at + // least two outcomes. + pub fn open_betting(context: &mut Context) -> Result<()> { + instructions::open_betting::handle_open_betting(context) + } + + // A bettor stakes tokens on one outcome, before betting closes. The stake + // joins the event's pool. pub fn place_bet(context: &mut Context, amount: u64) -> Result<()> { instructions::place_bet::handle_place_bet(context, amount) } - // Admin resolves the market: takes the fee from the losing pool and records + // Admin resolves the market once betting has closed: takes the fee from the losing pool and records // the figures winners need to claim their share. pub fn settle_event( context: &mut Context, @@ -71,7 +85,7 @@ pub mod betting_market { instructions::close_losing_bet::handle_close_losing_bet(context) } - // Admin voids an unresolved market so bettors can be made whole. + // Admin voids a draft or unresolved market so bettors can be made whole. pub fn cancel_event(context: &mut Context) -> Result<()> { instructions::cancel_event::handle_cancel_event(context) } diff --git a/finance/betting-market/anchor/programs/betting-market/src/state/config.rs b/finance/betting-market/anchor/programs/betting-market/src/state/config.rs index 4770ac65..470c64b6 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/state/config.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/state/config.rs @@ -1,8 +1,8 @@ use anchor_lang::prelude::*; // The global, single Config account. Its `admin` is the only key allowed to -// create events, add outcomes, settle, and cancel. `token_mint` fixes the one -// asset every market in this deployment accepts as a stake. +// create events, add outcomes, open betting, settle, and cancel. `token_mint` +// fixes the one asset every market in this deployment accepts as a stake. #[account(borsh)] #[derive(InitSpace)] pub struct Config { diff --git a/finance/betting-market/anchor/programs/betting-market/src/state/event.rs b/finance/betting-market/anchor/programs/betting-market/src/state/event.rs index 25e8f147..e4f5b978 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/state/event.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/state/event.rs @@ -2,7 +2,10 @@ use anchor_lang::prelude::*; #[derive(Clone, PartialEq, Eq, InitSpace, IdlType, wincode::SchemaRead, wincode::SchemaWrite)] pub enum EventStatus { - // Accepting bets. + // Being set up: the admin can add outcomes, and no one can bet yet. + Draft, + // The outcome list is final. Bets are accepted until `betting_closes_at`, + // and the event can be settled from that moment on. Open, // Resolved to a winning outcome; winners may claim. Settled, @@ -23,6 +26,10 @@ pub struct Event { // Sum of every stake placed across all outcomes. pub total_pool: u64, pub status: EventStatus, + // Unix timestamp at which betting stops, fixed at creation. Bets must land + // strictly before it and settlement can happen only at or after it, so no + // one can stake once the result could be known. + pub betting_closes_at: i64, // The fee settlement charges, copied from the config's `default_fee_bps` // at creation so later Config changes can't alter a market that bettors // have already joined. @@ -33,3 +40,15 @@ pub struct Event { pub distributable_losing_pool: u64, pub bump: u8, } + +// Bets are accepted while now < betting_closes_at. +pub fn betting_is_open(now: i64, betting_closes_at: i64) -> bool { + now < betting_closes_at +} + +// Settlement is allowed once now >= betting_closes_at: the exact complement of +// `betting_is_open`, so there is no instant at which a bet and a settlement +// could both land. +pub fn may_settle(now: i64, betting_closes_at: i64) -> bool { + now >= betting_closes_at +} diff --git a/finance/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs b/finance/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs index 44005876..3ee5329e 100644 --- a/finance/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs +++ b/finance/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs @@ -4,17 +4,25 @@ use { InstructionData, ToAccountMetas, }, anchor_v2_testing::{Keypair, LiteSVM, Signer}, - betting_market::{User, MAX_BETS_PER_USER}, + betting_market::{error::BettingError, User, MAX_BETS_PER_USER}, + // LiteSVM's get_sysvar / set_sysvar want the host-side Clock. + solana_clock::Clock, solana_kite::{ create_associated_token_account, create_token_mint, create_wallet, get_token_account_balance, mint_tokens_to_token_account, - send_transaction_from_instructions, + send_transaction_from_instructions, SolanaKiteError, }, }; const DECIMALS: u8 = 6; const FEE_BPS: u16 = 200; // 2% +// A fixed unix timestamp the clock is warped to before anything is written, +// so every market's betting window is deterministic: it closes a week later. +const START_TIME: i64 = 1_750_000_000; +const SECONDS_PER_DAY: i64 = 24 * 60 * 60; +const BETTING_CLOSES_AT: i64 = START_TIME + 7 * SECONDS_PER_DAY; + fn token_program_id() -> Address { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" .parse() @@ -79,6 +87,8 @@ fn setup() -> Market { svm.add_program(betting_market::id(), program_bytes) .unwrap(); + warp_to(&mut svm, START_TIME); + let admin = create_wallet(&mut svm, 100_000_000_000).unwrap(); let mint = create_token_mint(&mut svm, &admin, DECIMALS, None).unwrap(); @@ -95,6 +105,27 @@ fn setup() -> Market { } } +// Move the clock to `unix_timestamp`. Also expires the blockhash, so a retried +// instruction after the warp is not dropped as a duplicate. +fn warp_to(svm: &mut LiteSVM, unix_timestamp: i64) { + let mut clock: Clock = svm.get_sysvar(); + clock.unix_timestamp = unix_timestamp; + svm.set_sysvar(&clock); + svm.expire_blockhash(); +} + +// Anchor numbers a program's errors from 6000 in declaration order, and a +// failed transaction reports the number as `Custom(n)`. Matching it proves the +// transaction failed for the rule under test, not for some unrelated reason. +fn assert_fails_with(result: Result<(), SolanaKiteError>, expected: BettingError) { + let code = 6000 + expected as u32; + let error = format!("{:?}", result.expect_err("transaction should have failed")); + assert!( + error.contains(&format!("Custom({code})")), + "expected error {code}, got: {error}" + ); +} + // Create a funded bettor with a token ATA holding `amount` of the stake token. fn create_bettor(market: &mut Market, amount: u64) -> (Keypair, Address) { let bettor = create_wallet(&mut market.svm, 10_000_000_000).unwrap(); @@ -134,12 +165,23 @@ fn initialize_event_ix( mint: Address, event_id: u64, description: &str, +) -> Instruction { + initialize_event_closing_at_ix(admin, mint, event_id, description, BETTING_CLOSES_AT) +} + +fn initialize_event_closing_at_ix( + admin: Address, + mint: Address, + event_id: u64, + description: &str, + betting_closes_at: i64, ) -> Instruction { let event = event_pda(event_id); Instruction::new_with_bytes( betting_market::id(), &betting_market::instruction::InitializeEvent { event_id, + betting_closes_at, description: description.to_string(), } .data(), @@ -176,6 +218,19 @@ fn add_outcome_ix(admin: Address, event_id: u64, index: u8, label: &str) -> Inst ) } +fn open_betting_ix(admin: Address, event_id: u64) -> Instruction { + Instruction::new_with_bytes( + betting_market::id(), + &betting_market::instruction::OpenBetting {}.data(), + betting_market::accounts::OpenBettingAccountConstraints { + admin, + config: config_pda(), + event: event_pda(event_id), + } + .to_account_metas(None), + ) +} + fn place_bet_ix( mint: Address, bettor: &Address, @@ -363,6 +418,7 @@ fn test_full_lifecycle() { initialize_event_ix(admin, mint, event_id, "Will it rain tomorrow?"), add_outcome_ix(admin, event_id, 0, "Yes"), add_outcome_ix(admin, event_id, 1, "No"), + open_betting_ix(admin, event_id), ], &[&market.admin], &admin, @@ -426,6 +482,8 @@ fn test_full_lifecycle() { // Settle to "Yes" (index 0). Losing pool 200, fee = 2% = 4, distributable = 196. let fee_recipient = market.fee_recipient.pubkey(); let fee_recipient_ata = market.fee_recipient_ata; + // Betting has closed, so the event can be settled. + warp_to(&mut market.svm, BETTING_CLOSES_AT); send_transaction_from_instructions( &mut market.svm, vec![settle_event_ix( @@ -558,6 +616,7 @@ fn test_cannot_bet_after_settle() { initialize_event_ix(admin, mint, event_id, "Coin flip"), add_outcome_ix(admin, event_id, 0, "Heads"), add_outcome_ix(admin, event_id, 1, "Tails"), + open_betting_ix(admin, event_id), ], &[&market.admin], &admin, @@ -577,6 +636,8 @@ fn test_cannot_bet_after_settle() { &alice.pubkey(), ) .unwrap(); + // Betting has closed, so the event can be settled. + warp_to(&mut market.svm, BETTING_CLOSES_AT); send_transaction_from_instructions( &mut market.svm, vec![settle_event_ix( @@ -605,7 +666,7 @@ fn test_cannot_bet_after_settle() { &[&bob], &bob.pubkey(), ); - assert!(late_bet.is_err(), "betting after settlement must fail"); + assert_fails_with(late_bet, BettingError::EventNotOpen); } #[test] @@ -626,6 +687,7 @@ fn test_double_claim_fails() { initialize_event_ix(admin, mint, event_id, "Match winner"), add_outcome_ix(admin, event_id, 0, "Home"), add_outcome_ix(admin, event_id, 1, "Away"), + open_betting_ix(admin, event_id), ], &[&market.admin], &admin, @@ -659,6 +721,8 @@ fn test_double_claim_fails() { &carol.pubkey(), ) .unwrap(); + // Betting has closed, so the event can be settled. + warp_to(&mut market.svm, BETTING_CLOSES_AT); send_transaction_from_instructions( &mut market.svm, vec![settle_event_ix( @@ -721,6 +785,7 @@ fn test_settle_outcome_without_bets_fails() { initialize_event_ix(admin, mint, event_id, "Two horse race"), add_outcome_ix(admin, event_id, 0, "Horse A"), add_outcome_ix(admin, event_id, 1, "Horse B"), + open_betting_ix(admin, event_id), ], &[&market.admin], &admin, @@ -742,6 +807,8 @@ fn test_settle_outcome_without_bets_fails() { ) .unwrap(); + // Betting has closed, so the event can be settled. + warp_to(&mut market.svm, BETTING_CLOSES_AT); let result = send_transaction_from_instructions( &mut market.svm, vec![settle_event_ix( @@ -755,10 +822,7 @@ fn test_settle_outcome_without_bets_fails() { &[&market.admin], &admin, ); - assert!( - result.is_err(), - "settling to an outcome with no bets must fail" - ); + assert_fails_with(result, BettingError::OutcomeHasNoBets); } #[test] @@ -777,6 +841,7 @@ fn test_cancel_and_refund() { initialize_event_ix(admin, mint, event_id, "Voided event"), add_outcome_ix(admin, event_id, 0, "A"), add_outcome_ix(admin, event_id, 1, "B"), + open_betting_ix(admin, event_id), ], &[&market.admin], &admin, @@ -884,6 +949,7 @@ fn test_close_losing_bet_only_after_settle_and_only_for_losers() { initialize_event_ix(admin, mint, event_id, "Derby winner"), add_outcome_ix(admin, event_id, 0, "Red"), add_outcome_ix(admin, event_id, 1, "Blue"), + open_betting_ix(admin, event_id), ], &[&market.admin], &admin, @@ -930,6 +996,8 @@ fn test_close_losing_bet_only_after_settle_and_only_for_losers() { "closing before settlement must fail" ); + // Betting has closed, so the event can be settled. + warp_to(&mut market.svm, BETTING_CLOSES_AT); send_transaction_from_instructions( &mut market.svm, vec![settle_event_ix( @@ -1016,12 +1084,20 @@ fn test_closing_a_bet_frees_a_slot_for_a_new_bet() { ) .unwrap(); } + send_transaction_from_instructions( + &mut market.svm, + vec![open_betting_ix(admin, full_event_id)], + &[&market.admin], + &admin, + ) + .unwrap(); send_transaction_from_instructions( &mut market.svm, vec![ initialize_event_ix(admin, mint, second_event_id, "Second market"), add_outcome_ix(admin, second_event_id, 0, "Yes"), add_outcome_ix(admin, second_event_id, 1, "No"), + open_betting_ix(admin, second_event_id), ], &[&market.admin], &admin, @@ -1143,3 +1219,234 @@ fn test_closing_a_bet_frees_a_slot_for_a_new_bet() { "the new position must appear in the index" ); } + +// The outcome list is final once betting opens, and nobody can bet before it +// is. A bet on a draft would otherwise let anyone freeze a half-built market, +// and an outcome added after a bet would change the question under it. +#[test] +fn test_outcomes_lock_when_betting_opens() { + let mut market = setup(); + let event_id: u64 = 9; + let (alice, alice_ata) = create_bettor(&mut market, 1_000); + + init_config(&mut market); + let admin = market.admin.pubkey(); + let mint = market.mint; + send_transaction_from_instructions( + &mut market.svm, + vec![ + initialize_event_ix(admin, mint, event_id, "Top-grossing film"), + add_outcome_ix(admin, event_id, 0, "Toy Story 5"), + ], + &[&market.admin], + &admin, + ) + .unwrap(); + + // The draft has one outcome and no bettor can touch it yet. + let early_bet = send_transaction_from_instructions( + &mut market.svm, + vec![place_bet_ix( + mint, + &alice.pubkey(), + &alice_ata, + event_id, + 0, + 100, + )], + &[&alice], + &alice.pubkey(), + ); + assert_fails_with(early_bet, BettingError::EventNotOpen); + + // The admin finishes the list and opens the market. + send_transaction_from_instructions( + &mut market.svm, + vec![ + add_outcome_ix(admin, event_id, 1, "Backrooms"), + open_betting_ix(admin, event_id), + ], + &[&market.admin], + &admin, + ) + .unwrap(); + + // Now the list is fixed, with or without money in the pool. + let late_outcome = send_transaction_from_instructions( + &mut market.svm, + vec![add_outcome_ix(admin, event_id, 2, "Late entry")], + &[&market.admin], + &admin, + ); + assert_fails_with(late_outcome, BettingError::EventNotDraft); + + // Opening twice is refused too. + let reopen = send_transaction_from_instructions( + &mut market.svm, + vec![open_betting_ix(admin, event_id)], + &[&market.admin], + &admin, + ); + assert_fails_with(reopen, BettingError::EventNotDraft); +} + +#[test] +fn test_open_betting_needs_two_outcomes() { + let mut market = setup(); + let event_id: u64 = 10; + + init_config(&mut market); + let admin = market.admin.pubkey(); + let mint = market.mint; + send_transaction_from_instructions( + &mut market.svm, + vec![ + initialize_event_ix(admin, mint, event_id, "One horse race"), + add_outcome_ix(admin, event_id, 0, "The only horse"), + ], + &[&market.admin], + &admin, + ) + .unwrap(); + + let result = send_transaction_from_instructions( + &mut market.svm, + vec![open_betting_ix(admin, event_id)], + &[&market.admin], + &admin, + ); + assert_fails_with(result, BettingError::NotEnoughOutcomes); + + // Only the admin can open a market. + send_transaction_from_instructions( + &mut market.svm, + vec![add_outcome_ix(admin, event_id, 1, "A second horse")], + &[&market.admin], + &admin, + ) + .unwrap(); + let mallory = create_wallet(&mut market.svm, 10_000_000_000).unwrap(); + let unauthorized = send_transaction_from_instructions( + &mut market.svm, + vec![open_betting_ix(mallory.pubkey(), event_id)], + &[&mallory], + &mallory.pubkey(), + ); + assert_fails_with(unauthorized, BettingError::Unauthorized); +} + +// Bets land strictly before the close time and settlement only at or after +// it, so there is no second at which someone who knows the result can still +// stake, and none at which the admin can end the market early. +#[test] +fn test_betting_closes_at_close_time() { + let mut market = setup(); + let event_id: u64 = 11; + let (alice, alice_ata) = create_bettor(&mut market, 1_000); + let (carol, carol_ata) = create_bettor(&mut market, 1_000); + + init_config(&mut market); + let admin = market.admin.pubkey(); + let mint = market.mint; + let fee_recipient = market.fee_recipient.pubkey(); + let fee_recipient_ata = market.fee_recipient_ata; + send_transaction_from_instructions( + &mut market.svm, + vec![ + initialize_event_ix(admin, mint, event_id, "Final score"), + add_outcome_ix(admin, event_id, 0, "Home"), + add_outcome_ix(admin, event_id, 1, "Away"), + open_betting_ix(admin, event_id), + ], + &[&market.admin], + &admin, + ) + .unwrap(); + + // One second before the close, a bet still lands... + warp_to(&mut market.svm, BETTING_CLOSES_AT - 1); + send_transaction_from_instructions( + &mut market.svm, + vec![place_bet_ix( + mint, + &alice.pubkey(), + &alice_ata, + event_id, + 0, + 100, + )], + &[&alice], + &alice.pubkey(), + ) + .unwrap(); + + // ...and the admin cannot settle yet. + let early_settle = send_transaction_from_instructions( + &mut market.svm, + vec![settle_event_ix( + admin, + mint, + fee_recipient, + fee_recipient_ata, + event_id, + 0, + )], + &[&market.admin], + &admin, + ); + assert_fails_with(early_settle, BettingError::BettingStillOpen); + + // At the close time, betting stops, even though the event is still Open. + warp_to(&mut market.svm, BETTING_CLOSES_AT); + let late_bet = send_transaction_from_instructions( + &mut market.svm, + vec![place_bet_ix( + mint, + &carol.pubkey(), + &carol_ata, + event_id, + 1, + 100, + )], + &[&carol], + &carol.pubkey(), + ); + assert_fails_with(late_bet, BettingError::BettingClosed); + + send_transaction_from_instructions( + &mut market.svm, + vec![settle_event_ix( + admin, + mint, + fee_recipient, + fee_recipient_ata, + event_id, + 0, + )], + &[&market.admin], + &admin, + ) + .unwrap(); +} + +#[test] +fn test_close_time_must_be_in_the_future() { + let mut market = setup(); + init_config(&mut market); + let admin = market.admin.pubkey(); + let mint = market.mint; + + let result = send_transaction_from_instructions( + &mut market.svm, + vec![initialize_event_closing_at_ix( + admin, + mint, + 12, + "Already over", + START_TIME, + )], + &[&market.admin], + &admin, + ); + assert_fails_with(result, BettingError::CloseTimeInPast); +} diff --git a/finance/betting-market/kani-proofs/README.md b/finance/betting-market/kani-proofs/README.md index 457ec83c..b872e1a1 100644 --- a/finance/betting-market/kani-proofs/README.md +++ b/finance/betting-market/kani-proofs/README.md @@ -16,6 +16,8 @@ integer arithmetic. This crate reproduces it faithfully and proves: - `proof_winner_never_below_stake`: `payout = stake + winnings >= stake`: a winner is never paid less than they staked (the fee is charged only on losers). - `proof_parimutuel_solvency`: **Solvency** (centrepiece): the winners collectively never claim more than the vault holds after the fee (`Σ payout_i <= winning_pool + distributable`). - `proof_refund_conserves_pool`: On cancellation, refunds sum back to the total pool, neither over- nor under-drained. +- `proof_betting_and_settlement_windows_partition_time`: at every instant exactly one of `betting_is_open` and `may_settle` holds, so no bet can land once the event can be settled. +- `proof_outcomes_fixed_before_money_arrives`: a model of the event's lifecycle guards (`add_outcome`, `open_betting`, `place_bet`, `settle_event`, `cancel_event`), run over every sequence of six calls at arbitrary times from a fresh draft. The outcome list never changes once money is in the pool, every stake lands in a market with at least two outcomes and before the close time, and settlement happens only at or after it. ### The solvency proof @@ -40,6 +42,8 @@ the number of bettors). - `proof_winner_never_below_stake`: `winning_pool/distributable <= 255`, ~6s - `proof_parimutuel_solvency`: 3 winners, stakes `<= 7`, `distributable <= 63`, ~3s - `proof_refund_conserves_pool`: 4 bettors, full `u64`, <1s +- `proof_betting_and_settlement_windows_partition_time`: full `i64`, <1s +- `proof_outcomes_fixed_before_money_arrives`: 6 calls, full `u64` stakes and `i64` times, ~2s Run weekly in CI (the `.github/workflows/kani.yml` `verify` job), not on every push/PR, because the bounded nonlinear proofs are slow. A fast unit-test job runs per push/PR. diff --git a/finance/betting-market/kani-proofs/src/lib.rs b/finance/betting-market/kani-proofs/src/lib.rs index ea9ba152..89911842 100644 --- a/finance/betting-market/kani-proofs/src/lib.rs +++ b/finance/betting-market/kani-proofs/src/lib.rs @@ -10,7 +10,9 @@ //! `claim_winnings`) is pure integer arithmetic. This crate reproduces it //! faithfully and proves the two properties that matter: **solvency** (winners //! can never collectively claim more than the vault holds) and that a winner is -//! never paid less than their own stake. +//! never paid less than their own stake. A small model of the event's +//! lifecycle guards also proves the outcome list is fixed before any money +//! arrives and that the betting and settlement windows never overlap. //! //! The nonlinear harness uses bounded model checking (small symbolic inputs), as //! percolator does; the pro-rata identity is scale-invariant. @@ -177,6 +179,149 @@ fn proof_refund_conserves_pool() { assert_eq!(refunded, total_pool); } +// =========================================================================== +// 5. Lifecycle: the question is fixed before money arrives +// =========================================================================== + +/// Mirrors `betting_is_open` in the program's `state/event.rs`. +pub fn betting_is_open(now: i64, betting_closes_at: i64) -> bool { + now < betting_closes_at +} + +/// Mirrors `may_settle` in the program's `state/event.rs`. +pub fn may_settle(now: i64, betting_closes_at: i64) -> bool { + now >= betting_closes_at +} + +/// `EventStatus` from the program. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Status { + Draft, + Open, + Settled, + Cancelled, +} + +/// The fields of an `Event` the lifecycle guards read or write. +#[derive(Clone, Copy, Debug)] +pub struct EventModel { + pub status: Status, + pub outcome_count: u8, + pub total_pool: u64, + pub betting_closes_at: i64, +} + +/// One handler call, with the guards its `require!`s enforce. Returns `None` +/// where the handler rejects, leaving the event untouched. +#[derive(Clone, Copy, Debug)] +pub enum Action { + AddOutcome, + OpenBetting, + PlaceBet(u64), + Settle, + Cancel, +} + +pub fn step(event: EventModel, action: Action, now: i64) -> Option { + let mut next = event; + match action { + Action::AddOutcome => { + if event.status != Status::Draft { + return None; + } + next.outcome_count = event.outcome_count.checked_add(1)?; + } + Action::OpenBetting => { + if event.status != Status::Draft + || event.outcome_count < 2 + || !betting_is_open(now, event.betting_closes_at) + { + return None; + } + next.status = Status::Open; + } + Action::PlaceBet(amount) => { + if amount == 0 + || event.status != Status::Open + || !betting_is_open(now, event.betting_closes_at) + { + return None; + } + next.total_pool = event.total_pool.checked_add(amount)?; + } + Action::Settle => { + if event.status != Status::Open || !may_settle(now, event.betting_closes_at) { + return None; + } + next.status = Status::Settled; + } + Action::Cancel => { + if event.status != Status::Draft && event.status != Status::Open { + return None; + } + next.status = Status::Cancelled; + } + } + Some(next) +} + +#[cfg(kani)] +fn any_action() -> Action { + match kani::any::() % 5 { + 0 => Action::AddOutcome, + 1 => Action::OpenBetting, + 2 => Action::PlaceBet(kani::any()), + 3 => Action::Settle, + _ => Action::Cancel, + } +} + +/// At every instant exactly one of "a bet may land" and "the event may be +/// settled" holds, so no bet can arrive once settlement is possible, and the +/// admin cannot settle while the promised window is still open. +#[cfg(kani)] +#[kani::proof] +fn proof_betting_and_settlement_windows_partition_time() { + let now: i64 = kani::any(); + let betting_closes_at: i64 = kani::any(); + assert!(betting_is_open(now, betting_closes_at) != may_settle(now, betting_closes_at)); +} + +/// Over any sequence of handler calls at any times, starting from the draft +/// `initialize_event` creates: +/// - the outcome list never changes once any money is in the pool; +/// - every stake lands in a market with at least two outcomes; +/// - no stake lands at or after the close time; +/// - a settled event was settled at or after the close time. +#[cfg(kani)] +#[kani::proof] +#[kani::unwind(7)] +fn proof_outcomes_fixed_before_money_arrives() { + let mut event = EventModel { + status: Status::Draft, + outcome_count: 0, + total_pool: 0, + betting_closes_at: kani::any(), + }; + for _ in 0..6 { + let action = any_action(); + let now: i64 = kani::any(); + if let Some(next) = step(event, action, now) { + if event.total_pool > 0 { + assert_eq!(next.outcome_count, event.outcome_count); + } + if let Action::PlaceBet(_) = action { + assert!(event.outcome_count >= 2); + assert!(now < event.betting_closes_at); + } + if next.status == Status::Settled && event.status != Status::Settled { + assert!(now >= event.betting_closes_at); + } + event = next; + } + } +} + // =========================================================================== // Plain unit tests (meaningful without Kani installed). // =========================================================================== @@ -198,6 +343,30 @@ mod tests { assert_eq!(winnings(100, 588, 400).unwrap(), 147); } + #[test] + fn a_bet_needs_an_opened_market_before_the_close() { + let draft = EventModel { + status: Status::Draft, + outcome_count: 0, + total_pool: 0, + betting_closes_at: 100, + }; + assert!(step(draft, Action::PlaceBet(10), 0).is_none()); + assert!(step(draft, Action::OpenBetting, 0).is_none()); + let two = step( + step(draft, Action::AddOutcome, 0).unwrap(), + Action::AddOutcome, + 0, + ) + .unwrap(); + let open = step(two, Action::OpenBetting, 0).unwrap(); + assert!(step(open, Action::AddOutcome, 0).is_none()); + assert!(step(open, Action::PlaceBet(10), 99).is_some()); + assert!(step(open, Action::PlaceBet(10), 100).is_none()); + assert!(step(open, Action::Settle, 99).is_none()); + assert!(step(open, Action::Settle, 100).is_some()); + } + #[test] fn solvency_holds() { // 3 winners staking 100/150/150 (pool 400), distributable 588. diff --git a/finance/betting-market/quasar/CHANGELOG.md b/finance/betting-market/quasar/CHANGELOG.md index f43513d7..e82d86b9 100644 --- a/finance/betting-market/quasar/CHANGELOG.md +++ b/finance/betting-market/quasar/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [2026-09-22] + +### Changed + +- Events start as `Draft` and move to `Open` through a new admin handler, + `open_betting`, which needs at least two outcomes (`NotEnoughOutcomes`). + `add_outcome` works only on a draft (`EventNotDraft`, replacing + `BettingAlreadyStarted`), and `place_bet` on a draft fails with + `EventNotOpen`, so the outcome list is fixed before any bet can land. +- `initialize_event` takes `betting_closes_at` (after `event_id`, before the + description), which must be in the future (`CloseTimeInPast`). `place_bet` + requires `now < betting_closes_at` (`BettingClosed`) and `settle_event` + requires `now >= betting_closes_at` (`BettingStillOpen`). +- `cancel_event` accepts a draft as well as an open event. +- `EventStatus` gains `Draft = 0`, shifting `Open`, `Settled`, and `Cancelled` + to 1, 2, and 3 so they keep matching the Anchor build's borsh encoding. + `open_betting` takes discriminator 9, leaving the existing ones in place. + ## [2026-09-08] ### Changed diff --git a/finance/betting-market/quasar/README.md b/finance/betting-market/quasar/README.md index bf7daf40..0e3d459a 100644 --- a/finance/betting-market/quasar/README.md +++ b/finance/betting-market/quasar/README.md @@ -1,7 +1,7 @@ # Solana Betting Market (Quasar) -A parimutuel betting market on Solana, written with Quasar. An admin opens events (markets), adds the possible -outcomes, and later settles or cancels each one. Bettors stake a fixed token on +A parimutuel betting market on Solana, written with Quasar. An admin creates events (markets), adds the possible +outcomes, opens them to bets, and later settles or cancels each one. Bettors stake a fixed token on the outcome they think will happen; when the event is settled, the winners split the losing side's stakes in proportion to their own, after a protocol fee. This is the same mechanism a racetrack tote board or a prediction market runs on. @@ -18,14 +18,19 @@ A market pays out parimutuel-style: there is no fixed odds and no house taking the other side of your bet. Everyone's stake goes into one pool, and when the result is known the winners divide the pool. -- The **admin** (whoever ran `initialize_config`) opens an event with - `initialize_event`, then lists each possible result with `add_outcome`. Outcomes - can only be added before the first bet, so the field of choices can't change - under bettors who have already staked. -- A **bettor** stakes the market's token on one outcome with `place_bet`. The - stake joins the event's single pool vault. Re-betting the same outcome tops up - the existing position rather than opening a second one. -- The admin resolves the market with `settle_event`, naming the winning outcome. +- The **admin** (whoever ran `initialize_config`) creates an event as a draft + with `initialize_event`, fixing when betting closes, then lists each possible + result with `add_outcome`, and opens it with `open_betting`, which needs at + least two outcomes. Outcomes can only be added to a draft and nobody can bet on + one, so the field of choices is final before the first bet lands. +- A **bettor** stakes the market's token on one outcome with `place_bet`, any + time before the close. The stake joins the event's single pool vault. + Re-betting the same outcome tops up the existing position rather than opening a + second one. +- Once betting has closed, the admin resolves the market with `settle_event`, + naming the winning outcome. Bets are accepted only while `now < + betting_closes_at` and settlement only once `now >= betting_closes_at`, so + nobody can stake after the result could be known. The protocol fee is charged only on the losing pool, so a winner can never receive less than they staked. The fee moves to the fee recipient immediately; the figures winners need are recorded on the event. @@ -33,7 +38,8 @@ result is known the winners divide the pool. the losing pool (their stake divided by the total winning stake, times the distributable losing pool). A loser calls `close_losing_bet` to reclaim their Bet account's rent and free a slot in their position index. -- If a market cannot be resolved, the admin calls `cancel_event`, and every +- If a market cannot be resolved, the admin calls `cancel_event` (on a draft or + an open event), and every bettor reclaims their exact stake with `claim_refund`. No fee is taken. Closing the Bet account is what ends a position and prevents a double claim: a @@ -43,12 +49,13 @@ exists. ## Accounts and PDAs - **Config**, PDA `["config"]`. The single global account. Its `admin` is the - only key allowed to create, settle, and cancel events; `token_mint` fixes the - one stake asset; `fee_recipient` receives the settlement fee, and + only key allowed to create, open, settle, and cancel events; `token_mint` fixes + the one stake asset; `fee_recipient` receives the settlement fee, and `default_fee_bps` is the fee each new event copies at creation. - **Event**, PDA `["event", event_id]`. One market. Holds the running - `total_pool`, the status (Open, Settled, Cancelled), a fee snapshot taken at - creation, and the winning figures written at settlement. Its PDA is the token + `total_pool`, the status (Draft, Open, Settled, Cancelled), the + `betting_closes_at` timestamp and a fee snapshot both fixed at creation, and the + winning figures written at settlement. Its PDA is the token authority of the pool vault. - **Outcome**, PDA `["outcome", event, index]`. One possible result. `total_amount` is this outcome's share of the pool and the denominator for @@ -106,10 +113,13 @@ cargo test # QuasarSVM integration tests (they load the compiled .so) `quasar build` must run before `cargo test`, which loads the compiled `.so` into [QuasarSVM](https://github.com/blueshift-gg/quasar-svm), an in-process SVM. The -suite in `src/tests.rs` drives the full lifecycle (open a market, add outcomes, -place opposing bets, settle, claim the winnings, close the losing bet) and the -cancel-and-refund path, asserting onchain state, token balances, and fee -accounting at each step, plus an admin-authorization rejection. +suite in `src/tests.rs` drives the full lifecycle (create a market, add outcomes, +open betting, place opposing bets, settle after the close, claim the winnings, +close the losing bet) and the cancel-and-refund path, asserting onchain state, +token balances, and fee accounting at each step. It also checks the admin +authorization, that the outcome list locks when betting opens, the two-outcome +minimum, both edges of the betting close time, and that a close time in the past +is refused. ## Extending diff --git a/finance/betting-market/quasar/src/errors.rs b/finance/betting-market/quasar/src/errors.rs index aeb54977..2f3ff28a 100644 --- a/finance/betting-market/quasar/src/errors.rs +++ b/finance/betting-market/quasar/src/errors.rs @@ -18,7 +18,11 @@ pub enum BettingError { TooManyBets, BetNotInUserIndex, MathOverflow, - BettingAlreadyStarted, + EventNotDraft, DescriptionTooLong, LabelTooLong, + NotEnoughOutcomes, + CloseTimeInPast, + BettingClosed, + BettingStillOpen, } diff --git a/finance/betting-market/quasar/src/instructions/add_outcome.rs b/finance/betting-market/quasar/src/instructions/add_outcome.rs index 335255f2..69451ced 100644 --- a/finance/betting-market/quasar/src/instructions/add_outcome.rs +++ b/finance/betting-market/quasar/src/instructions/add_outcome.rs @@ -38,15 +38,11 @@ pub fn handle_add_outcome( label_bytes.len() <= MAX_LABEL_LEN, BettingError::LabelTooLong ); + // Outcomes can only be added to a draft. Once `open_betting` runs, the + // field of choices is final before the first bet can land. require!( - accounts.event.status == EventStatus::Open as u8, - BettingError::EventNotOpen - ); - // Lock the outcome set once betting starts so the field of choices can't - // change out from under existing bettors. - require!( - u64::from(accounts.event.total_pool) == 0, - BettingError::BettingAlreadyStarted + accounts.event.status == EventStatus::Draft as u8, + BettingError::EventNotDraft ); let index = accounts.event.outcome_count; diff --git a/finance/betting-market/quasar/src/instructions/cancel_event.rs b/finance/betting-market/quasar/src/instructions/cancel_event.rs index 40000efc..48569225 100644 --- a/finance/betting-market/quasar/src/instructions/cancel_event.rs +++ b/finance/betting-market/quasar/src/instructions/cancel_event.rs @@ -20,8 +20,11 @@ pub struct CancelEventAccountConstraints { pub fn handle_cancel_event( accounts: &mut CancelEventAccountConstraints, ) -> Result<(), ProgramError> { + // A draft or open event can be cancelled; a settled or cancelled one has + // already taken its exit. require!( - accounts.event.status == EventStatus::Open as u8, + accounts.event.status == EventStatus::Draft as u8 + || accounts.event.status == EventStatus::Open as u8, BettingError::EventNotOpen ); let mut event = snapshot_event(&accounts.event); diff --git a/finance/betting-market/quasar/src/instructions/initialize_event.rs b/finance/betting-market/quasar/src/instructions/initialize_event.rs index 726b8388..f6bab407 100644 --- a/finance/betting-market/quasar/src/instructions/initialize_event.rs +++ b/finance/betting-market/quasar/src/instructions/initialize_event.rs @@ -1,4 +1,4 @@ -use quasar_lang::prelude::*; +use quasar_lang::{prelude::*, sysvars::Sysvar as _}; use quasar_spl::prelude::*; use crate::errors::BettingError; @@ -44,6 +44,7 @@ pub struct InitializeEventAccountConstraints { pub fn handle_initialize_event( accounts: &mut InitializeEventAccountConstraints, event_id: u64, + betting_closes_at: i64, description: &str, bumps: &InitializeEventAccountConstraintsBumps, ) -> Result<(), ProgramError> { @@ -52,6 +53,9 @@ pub fn handle_initialize_event( description_bytes.len() <= MAX_DESCRIPTION_LEN, BettingError::DescriptionTooLong ); + // A close time already passed would make a market no one can bet on. + let now: i64 = Clock::get()?.unix_timestamp.into(); + require!(betting_closes_at > now, BettingError::CloseTimeInPast); let mut description_buffer = [0u8; MAX_DESCRIPTION_LEN]; description_buffer[..description_bytes.len()].copy_from_slice(description_bytes); @@ -62,7 +66,9 @@ pub fn handle_initialize_event( event_id, outcome_count: 0, total_pool: 0, - status: EventStatus::Open as u8, + // Starts as a draft: outcomes are added before anyone can bet. + status: EventStatus::Draft as u8, + betting_closes_at, fee_bps, winning_outcome_index: 0, winning_pool: 0, diff --git a/finance/betting-market/quasar/src/instructions/mod.rs b/finance/betting-market/quasar/src/instructions/mod.rs index 9056cbad..b86c5895 100644 --- a/finance/betting-market/quasar/src/instructions/mod.rs +++ b/finance/betting-market/quasar/src/instructions/mod.rs @@ -5,6 +5,7 @@ pub mod claim_winnings; pub mod close_losing_bet; pub mod initialize_config; pub mod initialize_event; +pub mod open_betting; pub mod place_bet; pub mod settle_event; pub mod shared; @@ -16,6 +17,7 @@ pub use claim_winnings::*; pub use close_losing_bet::*; pub use initialize_config::*; pub use initialize_event::*; +pub use open_betting::*; pub use place_bet::*; pub use settle_event::*; pub use shared::*; diff --git a/finance/betting-market/quasar/src/instructions/open_betting.rs b/finance/betting-market/quasar/src/instructions/open_betting.rs new file mode 100644 index 00000000..e5c261e1 --- /dev/null +++ b/finance/betting-market/quasar/src/instructions/open_betting.rs @@ -0,0 +1,42 @@ +use quasar_lang::{prelude::*, sysvars::Sysvar as _}; + +use crate::errors::BettingError; +use crate::state::{betting_is_open, snapshot_event, Config, Event, EventStatus}; + +// Move a draft event to Open. From here the outcome list is final and bettors +// can stake, so the question they bet on is fixed before any money arrives. +#[derive(Accounts)] +pub struct OpenBettingAccountConstraints { + pub admin: Signer, + + #[account(address = Config::seeds(), has_one(admin) @ BettingError::Unauthorized)] + pub config: Account, + + #[account(mut, address = Event::seeds(event.event_id.into()))] + pub event: Account, +} + +#[inline(always)] +pub fn handle_open_betting( + accounts: &mut OpenBettingAccountConstraints, +) -> Result<(), ProgramError> { + require!( + accounts.event.status == EventStatus::Draft as u8, + BettingError::EventNotDraft + ); + // A market with one outcome has no losing side to pay the winners from. + require!( + accounts.event.outcome_count >= 2, + BettingError::NotEnoughOutcomes + ); + // Opening after the close time would open a market no one can bet on. + let now: i64 = Clock::get()?.unix_timestamp.into(); + require!( + betting_is_open(now, i64::from(accounts.event.betting_closes_at)), + BettingError::BettingClosed + ); + let mut event = snapshot_event(&accounts.event); + event.status = EventStatus::Open as u8; + accounts.event.set_inner(event); + Ok(()) +} diff --git a/finance/betting-market/quasar/src/instructions/place_bet.rs b/finance/betting-market/quasar/src/instructions/place_bet.rs index 3f7d6001..aa8b2f37 100644 --- a/finance/betting-market/quasar/src/instructions/place_bet.rs +++ b/finance/betting-market/quasar/src/instructions/place_bet.rs @@ -1,10 +1,10 @@ -use quasar_lang::prelude::*; +use quasar_lang::{prelude::*, sysvars::Sysvar as _}; use quasar_spl::prelude::*; use crate::errors::BettingError; use crate::state::{ - add_bet, snapshot_event, snapshot_outcome, snapshot_user, Bet, BetInner, Config, Event, - EventStatus, EventVaultPda, Outcome, User, + add_bet, betting_is_open, snapshot_event, snapshot_outcome, snapshot_user, Bet, BetInner, + Config, Event, EventStatus, EventVaultPda, Outcome, User, }; use super::transfer_to_vault; @@ -67,6 +67,11 @@ pub fn handle_place_bet( accounts.event.status == EventStatus::Open as u8, BettingError::EventNotOpen ); + let now: i64 = Clock::get()?.unix_timestamp.into(); + require!( + betting_is_open(now, i64::from(accounts.event.betting_closes_at)), + BettingError::BettingClosed + ); transfer_to_vault( &accounts.token_program, diff --git a/finance/betting-market/quasar/src/instructions/settle_event.rs b/finance/betting-market/quasar/src/instructions/settle_event.rs index cb0986a3..6a8c1993 100644 --- a/finance/betting-market/quasar/src/instructions/settle_event.rs +++ b/finance/betting-market/quasar/src/instructions/settle_event.rs @@ -1,8 +1,10 @@ -use quasar_lang::prelude::*; +use quasar_lang::{prelude::*, sysvars::Sysvar as _}; use quasar_spl::prelude::*; use crate::errors::BettingError; -use crate::state::{snapshot_event, Config, Event, EventStatus, EventVaultPda, Outcome}; +use crate::state::{ + may_settle, snapshot_event, Config, Event, EventStatus, EventVaultPda, Outcome, +}; use super::transfer_from_vault; @@ -54,6 +56,13 @@ pub fn handle_settle_event( accounts.event.status == EventStatus::Open as u8, BettingError::EventNotOpen ); + // Settling before the close time would let the admin end a market early + // on bettors who were promised the full window. + let now: i64 = Clock::get()?.unix_timestamp.into(); + require!( + may_settle(now, i64::from(accounts.event.betting_closes_at)), + BettingError::BettingStillOpen + ); let winning_pool = u64::from(accounts.winning_outcome.total_amount); require!(winning_pool > 0, BettingError::OutcomeHasNoBets); diff --git a/finance/betting-market/quasar/src/lib.rs b/finance/betting-market/quasar/src/lib.rs index 519a385e..e2b58d02 100644 --- a/finance/betting-market/quasar/src/lib.rs +++ b/finance/betting-market/quasar/src/lib.rs @@ -13,8 +13,8 @@ mod tests; declare_id!("7LyqAeLR3mK9dfj9LqxWzfKH61VVHzuNpkgW5Y32De74"); -/// Parimutuel betting market. An admin opens events, adds outcomes, and settles -/// or cancels them; bettors stake a fixed token on an outcome, and winners +/// Parimutuel betting market. An admin creates events, adds outcomes, opens +/// them to bets, and settles or cancels them; bettors stake a fixed token on an outcome, and winners /// share the losing pool (net of a protocol fee) pro-rata to their stake. See /// README.md for the full walkthrough. #[program] @@ -38,22 +38,25 @@ mod quasar_betting_market { ) } - /// Admin opens a new market and creates its pool vault. + /// Admin creates a new market as a draft, fixes when betting closes, and + /// creates its pool vault. #[instruction(discriminator = 1)] pub fn initialize_event( ctx: Ctx, event_id: u64, + betting_closes_at: i64, description: String<200>, ) -> Result<(), ProgramError> { instructions::initialize_event::handle_initialize_event( &mut ctx.accounts, event_id, + betting_closes_at, description, &ctx.bumps, ) } - /// Admin adds a possible result. Only allowed before betting starts. + /// Admin adds a possible result. Only allowed while the event is a draft. #[instruction(discriminator = 2)] pub fn add_outcome( ctx: Ctx, @@ -62,7 +65,8 @@ mod quasar_betting_market { instructions::add_outcome::handle_add_outcome(&mut ctx.accounts, label, &ctx.bumps) } - /// A bettor stakes tokens on one outcome. The stake joins the event's pool. + /// A bettor stakes tokens on one outcome, before betting closes. The stake + /// joins the event's pool. #[instruction(discriminator = 3)] pub fn place_bet( ctx: Ctx, @@ -71,7 +75,7 @@ mod quasar_betting_market { instructions::place_bet::handle_place_bet(&mut ctx.accounts, amount, &ctx.bumps) } - /// Admin resolves the market: takes the fee from the losing pool and records + /// Admin resolves the market once betting has closed: takes the fee from the losing pool and records /// the figures winners need to claim their share. #[instruction(discriminator = 4)] pub fn settle_event( @@ -97,7 +101,7 @@ mod quasar_betting_market { instructions::close_losing_bet::handle_close_losing_bet(&mut ctx.accounts) } - /// Admin voids an unresolved market so bettors can be made whole. + /// Admin voids a draft or unresolved market so bettors can be made whole. #[instruction(discriminator = 7)] pub fn cancel_event(ctx: Ctx) -> Result<(), ProgramError> { instructions::cancel_event::handle_cancel_event(&mut ctx.accounts) @@ -109,4 +113,12 @@ mod quasar_betting_market { pub fn claim_refund(ctx: Ctx) -> Result<(), ProgramError> { instructions::claim_refund::handle_claim_refund(&mut ctx.accounts) } + + /// Admin finalizes the outcome list and opens the market to bets. Needs at + /// least two outcomes. Numbered after the existing handlers so their + /// discriminators stay put. + #[instruction(discriminator = 9)] + pub fn open_betting(ctx: Ctx) -> Result<(), ProgramError> { + instructions::open_betting::handle_open_betting(&mut ctx.accounts) + } } diff --git a/finance/betting-market/quasar/src/state/event.rs b/finance/betting-market/quasar/src/state/event.rs index d3a8972e..be86ed81 100644 --- a/finance/betting-market/quasar/src/state/event.rs +++ b/finance/betting-market/quasar/src/state/event.rs @@ -12,9 +12,13 @@ pub const MAX_DESCRIPTION_LEN: usize = 200; #[derive(Copy, Clone, PartialEq, Eq, Debug)] #[repr(u8)] pub enum EventStatus { - Open = 0, - Settled = 1, - Cancelled = 2, + /// Being set up: the admin can add outcomes, and no one can bet yet. + Draft = 0, + /// The outcome list is final. Bets are accepted until `betting_closes_at`, + /// and the event can be settled from that moment on. + Open = 1, + Settled = 2, + Cancelled = 3, } /// One betting market. All stakes across every outcome live in a single vault @@ -30,6 +34,10 @@ pub struct Event { /// Sum of every stake placed across all outcomes. pub total_pool: u64, pub status: u8, + /// Unix timestamp at which betting stops, fixed at creation. Bets must land + /// strictly before it and settlement can happen only at or after it, so no + /// one can stake once the result could be known. + pub betting_closes_at: i64, /// The fee settlement charges, copied from the config's `default_fee_bps` /// at creation so later Config changes can't alter a market bettors have /// already joined. @@ -57,6 +65,7 @@ pub fn snapshot_event(event: &Account) -> EventInner { outcome_count: event.outcome_count, total_pool: u64::from(event.total_pool), status: event.status, + betting_closes_at: i64::from(event.betting_closes_at), fee_bps: u16::from(event.fee_bps), winning_outcome_index: event.winning_outcome_index, winning_pool: u64::from(event.winning_pool), @@ -66,3 +75,15 @@ pub fn snapshot_event(event: &Account) -> EventInner { description: event.description, } } + +/// Bets are accepted while `now < betting_closes_at`. +pub fn betting_is_open(now: i64, betting_closes_at: i64) -> bool { + now < betting_closes_at +} + +/// Settlement is allowed once `now >= betting_closes_at`: the exact complement +/// of `betting_is_open`, so there is no instant at which a bet and a settlement +/// could both land. +pub fn may_settle(now: i64, betting_closes_at: i64) -> bool { + now >= betting_closes_at +} diff --git a/finance/betting-market/quasar/src/tests.rs b/finance/betting-market/quasar/src/tests.rs index ebe22d8c..a05338b4 100644 --- a/finance/betting-market/quasar/src/tests.rs +++ b/finance/betting-market/quasar/src/tests.rs @@ -1,14 +1,17 @@ //! quasar-test integration tests. They drive the real program instructions -//! end-to-end: initialize the config, open an event, add outcomes, place bets, -//! settle, and claim, asserting onchain state and token balances at each step. +//! end-to-end: initialize the config, create an event, add outcomes, open +//! betting, place bets, settle, and claim, asserting onchain state and token +//! balances at each step. use { crate::{ cpi::{ AddOutcomeInstruction, CancelEventInstruction, ClaimRefundInstruction, ClaimWinningsInstruction, CloseLosingBetInstruction, InitializeConfigInstruction, - InitializeEventInstruction, PlaceBetInstruction, SettleEventInstruction, + InitializeEventInstruction, OpenBettingInstruction, PlaceBetInstruction, + SettleEventInstruction, }, + errors::BettingError, state::{Bet, Config, Event, EventStatus, EventVaultPda, Outcome, User}, }, quasar_test::prelude::*, @@ -30,9 +33,16 @@ const DECIMALS: u8 = 6; const STARTING_TOKENS: u64 = 1_000; const EVENT_ID: u64 = 1; -/// Register the admin, the stake mint, and the fee recipient's token account, -/// then initialize the config. +// A fixed unix timestamp the clock is warped to before anything is written, +// so the betting window is deterministic: it closes a week later. +const START_TIME: i64 = 1_750_000_000; +const SECONDS_PER_DAY: i64 = 24 * 60 * 60; +const BETTING_CLOSES_AT: i64 = START_TIME + 7 * SECONDS_PER_DAY; + +/// Set the clock to `START_TIME`, register the admin, the stake mint, and the +/// fee recipient's token account, then initialize the config. fn base_world(test: &mut Test) { + test.warp_to_timestamp(START_TIME); test.add(Wallet::new().at(ADMIN)); test.add(Mint::new(ADMIN).at(TOKEN_MINT).decimals(DECIMALS)); test.add(TokenAccount::new(TOKEN_MINT, FEE_RECIPIENT).at(FEE_RECIPIENT_TOKEN)); @@ -98,6 +108,7 @@ fn full_lifecycle_settles_and_pays_the_winner(test: &mut Test) { admin: ADMIN, token_mint: TOKEN_MINT, event_id: EVENT_ID, + betting_closes_at: BETTING_CLOSES_AT, description: "Team A vs Team B".to_string().into(), }) .succeeds(); @@ -115,6 +126,11 @@ fn full_lifecycle_settles_and_pays_the_winner(test: &mut Test) { label: "Team B".to_string().into(), }) .succeeds(); + test.send(OpenBettingInstruction { + admin: ADMIN, + event_event_id_seed: EVENT_ID, + }) + .succeeds(); test.send(PlaceBetInstruction { bettor: BETTOR_A, token_mint: TOKEN_MINT, @@ -133,6 +149,8 @@ fn full_lifecycle_settles_and_pays_the_winner(test: &mut Test) { amount: STAKE_B, }) .succeeds(); + // Betting has closed, so the event can be settled. + test.warp_to_timestamp(BETTING_CLOSES_AT); test.send(SettleEventInstruction { admin: ADMIN, token_mint: TOKEN_MINT, @@ -206,6 +224,7 @@ fn cancelled_event_refunds_the_exact_stake(test: &mut Test) { admin: ADMIN, token_mint: TOKEN_MINT, event_id: EVENT_ID, + betting_closes_at: BETTING_CLOSES_AT, description: "Team A vs Team B".to_string().into(), }) .succeeds(); @@ -213,7 +232,19 @@ fn cancelled_event_refunds_the_exact_stake(test: &mut Test) { admin: ADMIN, event_event_id_seed: EVENT_ID, event_outcome_count_seed: 0, - label: "Only".to_string().into(), + label: "Team A".to_string().into(), + }) + .succeeds(); + test.send(AddOutcomeInstruction { + admin: ADMIN, + event_event_id_seed: EVENT_ID, + event_outcome_count_seed: 1, + label: "Team B".to_string().into(), + }) + .succeeds(); + test.send(OpenBettingInstruction { + admin: ADMIN, + event_event_id_seed: EVENT_ID, }) .succeeds(); test.send(PlaceBetInstruction { @@ -259,7 +290,162 @@ fn initialize_event_rejects_a_non_admin_signer(test: &mut Test) { admin: ATTACKER, token_mint: TOKEN_MINT, event_id: EVENT_ID, + betting_closes_at: BETTING_CLOSES_AT, description: "Team A vs Team B".to_string().into(), }) - .fails_with(crate::errors::BettingError::Unauthorized); + .fails_with(BettingError::Unauthorized); +} + +/// Create the event and add `labels` as its outcomes, leaving it a draft. +fn draft_event(test: &mut Test, labels: &[&str]) { + test.send(InitializeEventInstruction { + admin: ADMIN, + token_mint: TOKEN_MINT, + event_id: EVENT_ID, + betting_closes_at: BETTING_CLOSES_AT, + description: "Top-grossing film".to_string().into(), + }) + .succeeds(); + for (index, label) in labels.iter().enumerate() { + test.send(AddOutcomeInstruction { + admin: ADMIN, + event_event_id_seed: EVENT_ID, + event_outcome_count_seed: index as u8, + label: label.to_string().into(), + }) + .succeeds(); + } +} + +/// Give `bettor` a funded token account at `token_account`. +fn add_bettor(test: &mut Test, bettor: Pubkey, token_account: Pubkey) { + test.add(Wallet::new().at(bettor)); + test.add( + TokenAccount::new(TOKEN_MINT, bettor) + .at(token_account) + .amount(STARTING_TOKENS), + ); +} + +fn bet(bettor: Pubkey, token_account: Pubkey, outcome: u8, amount: u64) -> PlaceBetInstruction { + PlaceBetInstruction { + bettor, + token_mint: TOKEN_MINT, + event_event_id_seed: EVENT_ID, + outcome_index_seed: outcome, + bettor_token_account: token_account, + amount, + } +} + +fn settle(winning_outcome_index: u8) -> SettleEventInstruction { + SettleEventInstruction { + admin: ADMIN, + token_mint: TOKEN_MINT, + event_event_id_seed: EVENT_ID, + fee_recipient_token_account: FEE_RECIPIENT_TOKEN, + winning_outcome_index, + } +} + +fn open_betting(admin: Pubkey) -> OpenBettingInstruction { + OpenBettingInstruction { + admin, + event_event_id_seed: EVENT_ID, + } +} + +/// The outcome list is final once betting opens, and nobody can bet before it +/// is. A bet on a draft would otherwise let anyone freeze a half-built market, +/// and an outcome added after a bet would change the question under it. +#[quasar_test] +fn outcomes_lock_when_betting_opens(test: &mut Test) { + base_world(test); + add_bettor(test, BETTOR_A, TOKEN_A); + draft_event(test, &["Toy Story 5"]); + + test.send(bet(BETTOR_A, TOKEN_A, 0, 100)) + .fails_with(BettingError::EventNotOpen); + + test.send(AddOutcomeInstruction { + admin: ADMIN, + event_event_id_seed: EVENT_ID, + event_outcome_count_seed: 1, + label: "Backrooms".to_string().into(), + }) + .succeeds(); + test.send(open_betting(ADMIN)).succeeds(); + + test.send(AddOutcomeInstruction { + admin: ADMIN, + event_event_id_seed: EVENT_ID, + event_outcome_count_seed: 2, + label: "Late entry".to_string().into(), + }) + .fails_with(BettingError::EventNotDraft); + test.send(open_betting(ADMIN)) + .fails_with(BettingError::EventNotDraft); +} + +/// A market needs a losing side, and only the admin can open it. +#[quasar_test] +fn open_betting_needs_two_outcomes_and_the_admin(test: &mut Test) { + base_world(test); + test.add(Wallet::new().at(ATTACKER)); + draft_event(test, &["The only horse"]); + + test.send(open_betting(ADMIN)) + .fails_with(BettingError::NotEnoughOutcomes); + + test.send(AddOutcomeInstruction { + admin: ADMIN, + event_event_id_seed: EVENT_ID, + event_outcome_count_seed: 1, + label: "A second horse".to_string().into(), + }) + .succeeds(); + test.send(open_betting(ATTACKER)) + .fails_with(BettingError::Unauthorized); + test.send(open_betting(ADMIN)).succeeds(); + assert_eq!( + test.read::(test.derive_pda(Event::seeds(EVENT_ID))) + .status, + EventStatus::Open as u8 + ); +} + +/// Bets land strictly before the close time and settlement only at or after +/// it, so there is no second at which someone who knows the result can still +/// stake, and none at which the admin can end the market early. +#[quasar_test] +fn betting_closes_at_the_close_time(test: &mut Test) { + base_world(test); + add_bettor(test, BETTOR_A, TOKEN_A); + add_bettor(test, BETTOR_B, TOKEN_B); + draft_event(test, &["Home", "Away"]); + test.send(open_betting(ADMIN)).succeeds(); + + test.warp_to_timestamp(BETTING_CLOSES_AT - 1); + test.send(bet(BETTOR_A, TOKEN_A, 0, 100)).succeeds(); + test.send(settle(0)) + .fails_with(BettingError::BettingStillOpen); + + test.warp_to_timestamp(BETTING_CLOSES_AT); + test.send(bet(BETTOR_B, TOKEN_B, 1, 100)) + .fails_with(BettingError::BettingClosed); + test.send(settle(0)).succeeds(); +} + +#[quasar_test] +fn initialize_event_rejects_a_close_time_in_the_past(test: &mut Test) { + base_world(test); + + test.send(InitializeEventInstruction { + admin: ADMIN, + token_mint: TOKEN_MINT, + event_id: EVENT_ID, + betting_closes_at: START_TIME, + description: "Already over".to_string().into(), + }) + .fails_with(BettingError::CloseTimeInPast); }