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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion finance/betting-market/README.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
15 changes: 15 additions & 0 deletions finance/betting-market/anchor/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
66 changes: 46 additions & 20 deletions finance/betting-market/anchor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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.

Expand All @@ -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?

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))'] }
12 changes: 10 additions & 2 deletions finance/betting-market/anchor/programs/betting-market/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@ pub struct CancelEventAccountConstraints {
}

pub fn handle_cancel_event(context: &mut Context<CancelEventAccountConstraints>) -> 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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,19 +55,25 @@ pub struct InitializeEventAccountConstraints {
pub fn handle_initialize_event(
context: &mut Context<InitializeEventAccountConstraints>,
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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::*;
Original file line number Diff line number Diff line change
@@ -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<Config>,

#[account(
mut,
seeds = [b"event", event.event_id.to_le_bytes()],
bump = event.bump,
)]
pub event: BorshAccount<Event>,
}

pub fn handle_open_betting(context: &mut Context<OpenBettingAccountConstraints>) -> 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(())
}
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading