Skip to content

Commit a6006a2

Browse files
committed
Add parimutuel betting market Anchor example
A pooled prediction market under tokens/betting-market/anchor: admins open events with multiple outcomes, bettors stake an SPL token on an outcome into a vault owned by the event PDA, and at settlement the losing pool (minus an admin basis-points fee) is split among winners pro-rata to their stake. Accounts: Config (admin/fee/mint), Event, Outcome, Bet, and a per-wallet User index. Handlers: initialize_config, create_event, add_outcome, place_bet, settle_event, claim_winnings, cancel_event, claim_refund. Includes Rust + LiteSVM tests covering the full lifecycle, payout/fee math, authorization guards, and the cancel/refund path.
1 parent 5540080 commit a6006a2

26 files changed

Lines changed: 1756 additions & 0 deletions

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ Constant product AMM (x·y=k) — create liquidity pools, deposit and withdraw l
2323

2424
[⚓ Anchor](./tokens/token-swap/anchor) [💫 Quasar](./tokens/token-swap/quasar)
2525

26+
### Betting Market
27+
28+
Parimutuel (pooled) prediction market — an admin opens an event with multiple outcomes, bettors stake tokens on an outcome, and at settlement the losing pool (minus a protocol fee) is split among winners in proportion to their stake.
29+
30+
[⚓ Anchor](./tokens/betting-market/anchor)
31+
2632
### Escrow
2733

2834
Peer-to-peer OTC trade — one user deposits token A and specifies how much token B they want. A counterparty fulfills the offer and both sides receive their tokens atomically.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.anchor
2+
.DS_Store
3+
target
4+
**/*.rs.bk
5+
node_modules
6+
test-ledger
7+
.yarn
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[toolchain]
2+
solana_version = "3.1.8"
3+
4+
[features]
5+
resolution = true
6+
skip-lint = false
7+
8+
[programs.localnet]
9+
betting_market = "7LyqAeLR3mK9dfj9LqxWzfKH61VVHzuNpkgW5Y32De74"
10+
11+
[provider]
12+
cluster = "Localnet"
13+
wallet = "~/.config/solana/id.json"
14+
15+
[scripts]
16+
test = "cargo test"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[workspace]
2+
members = [
3+
"programs/*"
4+
]
5+
resolver = "2"
6+
7+
[profile.release]
8+
overflow-checks = true
9+
lto = "fat"
10+
codegen-units = 1
11+
12+
[profile.release.build-override]
13+
opt-level = 3
14+
incremental = false
15+
codegen-units = 1
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# Betting Market
2+
3+
A parimutuel (pooled) betting market. An admin opens an **event**, adds the possible
4+
**outcomes**, and bettors stake a token on the outcome they think will win. Every stake across
5+
every outcome goes into one pool. When the admin settles the event to the winning outcome, the
6+
losing stakes — minus a protocol fee — are split among the winners in proportion to their stake.
7+
8+
This is the pooled model used by Solana prediction-market platforms such as Hedgehog Markets,
9+
where odds are set by the crowd's stakes rather than by an order book or a fixed-odds bookmaker.
10+
11+
## Purpose
12+
13+
It solves the core problem of trustless betting: collecting stakes from many bettors, holding them
14+
in one place no single bettor controls, and paying winners by a fixed, public formula. The pool is
15+
a token account owned by the event's PDA, so payouts are signed by the program with the event's
16+
seeds — there is no admin key that can move bettors' stakes out of the pool. The admin's only
17+
powers are creating events/outcomes and choosing the winning outcome (or cancelling).
18+
19+
## Major Concepts
20+
21+
### Accounts
22+
23+
- **Config** (`seeds = [b"config"]`) — one per deployment. Holds the `admin` (the only key that can
24+
create events/outcomes, settle, and cancel), the `token_mint` every market accepts, the
25+
`fee_recipient`, and the `fee_bps`.
26+
- **Event** (`seeds = [b"event", event_id]`) — one betting market. Tracks `total_pool`, `status`
27+
(`Open` / `Settled` / `Cancelled`), and — once settled — the `winning_outcome_index`,
28+
`winning_pool`, and `distributable_losing_pool` that the payout formula reads. The `fee_bps` is
29+
snapshotted at creation so later Config changes can't alter a market bettors have already joined.
30+
- **Outcome** (`seeds = [b"outcome", event, index]`) — one possible result. Its `total_amount` is
31+
the outcome's share of the pool and the denominator for pro-rata payouts when it wins.
32+
- **Bet** (`seeds = [b"bet", outcome, bettor]`) — a bettor's total stake on one outcome. Re-betting
33+
the same outcome adds to the existing Bet, so there is exactly one per (outcome, bettor).
34+
- **User** (`seeds = [b"user", wallet]`) — a per-wallet index listing the bettor's Bet addresses, so
35+
a client can find someone's positions without scanning every Bet on the program. The list is
36+
capped (see `MAX_BETS_PER_USER`) to keep the account a fixed size; the Bet accounts are the
37+
authoritative stake record.
38+
39+
### The vault
40+
41+
Each event owns a single vault token account — the associated token account of the Event PDA for
42+
`config.token_mint`. `place_bet` moves the stake from the bettor's token account into this vault.
43+
`settle_event`, `claim_winnings`, and `claim_refund` move tokens back out, with the program signing
44+
as the Event PDA (`seeds = [b"event", event_id, bump]`).
45+
46+
### Payout formula
47+
48+
When an event settles to a winning outcome:
49+
50+
```
51+
losing_pool = total_pool - winning_pool
52+
fee = losing_pool * fee_bps / 10000 // charged only on the losing side
53+
distributable_losing = losing_pool - fee
54+
```
55+
56+
Each winning bet then claims:
57+
58+
```
59+
payout = stake + stake * distributable_losing / winning_pool
60+
```
61+
62+
A winner always gets their own stake back; the fee is only ever taken from losing stakes. Integer
63+
division floors each share, leaving at most a few base units of dust in the vault.
64+
65+
**Worked example:** Outcome A pool 100, Outcome B pool 50, `fee_bps = 200` (2%). A wins.
66+
`losing_pool = 50`, `fee = 1`, `distributable_losing = 49`. A bettor who staked 40 claims
67+
`40 + 40 * 49 / 100 = 59`.
68+
69+
### Instruction handlers
70+
71+
| Handler | Who | What it does |
72+
| --- | --- | --- |
73+
| `initialize_config` | anyone (becomes admin) | One-time setup: sets admin, stake token, fee, fee recipient. |
74+
| `create_event` | admin | Opens a market and creates its vault. |
75+
| `add_outcome` | admin | Adds a possible result. Only before any bet is placed. |
76+
| `place_bet` | bettor | Stakes tokens on one outcome; updates the pools and the user's index. |
77+
| `settle_event` | admin | Resolves to a winning outcome, takes the fee, records the payout figures. |
78+
| `claim_winnings` | winning bettor | Withdraws stake plus pro-rata share of the losing pool. |
79+
| `cancel_event` | admin | Voids an unresolved market. |
80+
| `claim_refund` | bettor | After a cancellation, reclaims the exact stake. |
81+
82+
`add_outcome` is locked once betting starts, so the field of choices can't change under existing
83+
bettors. `settle_event` rejects a winning outcome with no bets — use `cancel_event` to unwind an
84+
event that can't be resolved fairly.
85+
86+
## Setup
87+
88+
Install the [Solana CLI](https://docs.anza.xyz/cli/install) (provides `cargo-build-sbf`) and
89+
[Anchor](https://www.anchor-lang.com/docs/installation). Build the program so the test binary
90+
exists on disk:
91+
92+
```sh
93+
anchor build
94+
```
95+
96+
## Testing
97+
98+
Tests are Rust integration tests running against [LiteSVM](https://www.anchor-lang.com/docs/testing/litesvm)
99+
with [solana-kite](https://crates.io/crates/solana-kite) helpers. They cover the full lifecycle
100+
(bet → settle → claim with exact payout and fee assertions), admin authorization, the
101+
bet-after-settle and double-claim guards, settling an outcome with no bets, and the cancel/refund
102+
path.
103+
104+
```sh
105+
anchor test
106+
```
107+
108+
(`Anchor.toml` sets `test = "cargo test"`, so `cargo test` works too.)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
[package]
2+
name = "betting-market"
3+
version = "0.1.0"
4+
description = "Created with Anchor"
5+
edition = "2021"
6+
7+
[lib]
8+
crate-type = ["cdylib", "lib"]
9+
name = "betting_market"
10+
11+
[features]
12+
default = []
13+
cpi = ["no-entrypoint"]
14+
no-entrypoint = []
15+
no-idl = []
16+
no-log-ix-name = []
17+
idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]
18+
anchor-debug = []
19+
custom-heap = []
20+
custom-panic = []
21+
22+
[dependencies]
23+
# init-if-needed: place_bet and the lazy User index create accounts only on first use.
24+
anchor-lang = { version = "1.0.0", features = ["init-if-needed"] }
25+
anchor-spl = "1.0.0"
26+
27+
[dev-dependencies]
28+
litesvm = "0.11.0"
29+
solana-signer = "3.0.0"
30+
solana-keypair = "3.0.1"
31+
solana-kite = "0.3.0"
32+
borsh = "1.6.1"
33+
34+
[lints.rust]
35+
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] }
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[target.bpfel-unknown-unknown.dependencies.std]
2+
features = []
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
use anchor_lang::prelude::*;
2+
3+
#[error_code]
4+
pub enum BettingError {
5+
#[msg("Fee in basis points cannot exceed 10000 (100%)")]
6+
FeeTooHigh,
7+
#[msg("Only the admin may perform this action")]
8+
Unauthorized,
9+
#[msg("The event is not open for this action")]
10+
EventNotOpen,
11+
#[msg("The event has not been settled")]
12+
EventNotSettled,
13+
#[msg("The event has not been cancelled")]
14+
EventNotCancelled,
15+
#[msg("The winning outcome has no bets, so the event cannot be settled to it")]
16+
OutcomeHasNoBets,
17+
#[msg("The winning outcome index does not match the provided outcome account")]
18+
InvalidWinningOutcome,
19+
#[msg("This bet did not win, so there is nothing to claim")]
20+
NothingToClaim,
21+
#[msg("This bet has already been claimed")]
22+
AlreadyClaimed,
23+
#[msg("The bet amount must be greater than zero")]
24+
ZeroAmount,
25+
#[msg("This bettor already holds the maximum number of distinct bets")]
26+
TooManyBets,
27+
#[msg("Outcomes can only be added before any bets are placed")]
28+
BettingAlreadyStarted,
29+
#[msg("The event description is too long")]
30+
DescriptionTooLong,
31+
#[msg("The outcome label is too long")]
32+
LabelTooLong,
33+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
use anchor_lang::prelude::*;
2+
3+
use crate::{error::BettingError, Config, Event, EventStatus, Outcome};
4+
5+
pub const MAX_LABEL_LEN: usize = 64;
6+
7+
#[derive(Accounts)]
8+
pub struct AddOutcome<'info> {
9+
#[account(mut)]
10+
pub admin: Signer<'info>,
11+
12+
#[account(
13+
seeds = [b"config"],
14+
bump = config.bump,
15+
has_one = admin @ BettingError::Unauthorized,
16+
)]
17+
pub config: Account<'info, Config>,
18+
19+
#[account(
20+
mut,
21+
seeds = [b"event", event.event_id.to_le_bytes().as_ref()],
22+
bump = event.bump,
23+
)]
24+
pub event: Account<'info, Event>,
25+
26+
#[account(
27+
init,
28+
payer = admin,
29+
space = Outcome::DISCRIMINATOR.len() + Outcome::INIT_SPACE,
30+
seeds = [b"outcome", event.key().as_ref(), &[event.outcome_count]],
31+
bump
32+
)]
33+
pub outcome: Account<'info, Outcome>,
34+
35+
pub system_program: Program<'info, System>,
36+
}
37+
38+
pub fn handle_add_outcome(context: Context<AddOutcome>, label: String) -> Result<()> {
39+
require!(label.len() <= MAX_LABEL_LEN, BettingError::LabelTooLong);
40+
require!(
41+
context.accounts.event.status == EventStatus::Open,
42+
BettingError::EventNotOpen
43+
);
44+
// Lock the outcome set once betting starts so the field of choices can't
45+
// change out from under existing bettors.
46+
require!(
47+
context.accounts.event.total_pool == 0,
48+
BettingError::BettingAlreadyStarted
49+
);
50+
51+
let index = context.accounts.event.outcome_count;
52+
context.accounts.outcome.set_inner(Outcome {
53+
event: context.accounts.event.key(),
54+
index,
55+
label,
56+
total_amount: 0,
57+
bet_count: 0,
58+
bump: context.bumps.outcome,
59+
});
60+
61+
context.accounts.event.outcome_count += 1;
62+
Ok(())
63+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
use anchor_lang::prelude::*;
2+
3+
use crate::{error::BettingError, Config, Event, EventStatus};
4+
5+
// Abandon an event that can't be resolved (e.g. the real-world result is void).
6+
// Bettors then reclaim their exact stakes via `claim_refund`; no fee is taken.
7+
#[derive(Accounts)]
8+
pub struct CancelEvent<'info> {
9+
pub admin: Signer<'info>,
10+
11+
#[account(
12+
seeds = [b"config"],
13+
bump = config.bump,
14+
has_one = admin @ BettingError::Unauthorized,
15+
)]
16+
pub config: Account<'info, Config>,
17+
18+
#[account(
19+
mut,
20+
seeds = [b"event", event.event_id.to_le_bytes().as_ref()],
21+
bump = event.bump,
22+
)]
23+
pub event: Account<'info, Event>,
24+
}
25+
26+
pub fn handle_cancel_event(context: Context<CancelEvent>) -> Result<()> {
27+
require!(
28+
context.accounts.event.status == EventStatus::Open,
29+
BettingError::EventNotOpen
30+
);
31+
context.accounts.event.status = EventStatus::Cancelled;
32+
Ok(())
33+
}

0 commit comments

Comments
 (0)