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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ 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-23] - Anchor v1 copies track their Anchor v2 counterparts

CONTRIBUTING.md described each `anchor-v1/` copy as a frozen snapshot that
changed only to keep its build green. Several v2 examples have since gained
behavior their v1 copies lack, so a reader on the v1 LTS line was learning a
different program from the one the v2 copy and the book describe.

- CONTRIBUTING.md now says a change to what an example does goes into both
copies, and the copies differ only where the Anchor version forces it.
- The betting market's Anchor v1 copy gains the draft state and betting close
time from the v2 copy: `open_betting`, `betting_closes_at`, the
`EventNotDraft`, `NotEnoughOutcomes`, `CloseTimeInPast`, `BettingClosed` and
`BettingStillOpen` errors, a draft-or-open `cancel_event`, and the same new
tests.

## [2026-09-22] - Lending interest and perpetual futures funding accrue by the wall clock

Both programs accrued over elapsed slots, so a rate quoted per year or per
Expand Down
11 changes: 8 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@ See [CHANGELOG.md](./CHANGELOG.md) for release history. This file had no changel

- Each example lives at `category/example-name/<framework>/`, e.g. `basics/counter/anchor/`.
- Supported frameworks: `anchor`, `anchor-v1`, `quasar`, `pinocchio`, `native`, `asm`. Use the existing layout as a reference.
- `anchor/` is Anchor v2 (2.0.0-rc.1) and is where new Anchor work goes. `anchor-v1/` is the
same example on Anchor v1 (1.2.0), kept for the v1 LTS line: it is a frozen snapshot and
changes only to keep the v1 build green, not to gain new features.
- `anchor/` is Anchor v2 (2.0.0-rc.1) and is where new Anchor work starts. `anchor-v1/` is the
same example on Anchor v1 (1.2.0), kept for programs staying on the v1 LTS line, and it
tracks its v2 counterpart. A change to what an example does (a new handler, a new check, a
fix, a renamed field) goes into both copies, in the same pull request where practical, so a
v1 reader gets the same program and the same tests. The copies differ only where the Anchor
version forces it: account types and lifetimes, context syntax, and the test harness. If a
change needs something only Anchor v2 has, say so in the pull request and in the v1 copy's
CHANGELOG.
- Anchor and Quasar programs usually keep Rust tests under `programs/<name>/tests/`.
- Native and Pinocchio tests are Rust + LiteSVM, kept under `program/tests/`.

Expand Down
16 changes: 16 additions & 0 deletions finance/betting-market/anchor-v1/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## [2026-09-23]

### Changed

- Ported the Anchor v2 copy's draft state and betting close time. 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
77 changes: 52 additions & 25 deletions finance/betting-market/anchor-v1/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
> `avm install 1.2.0 && avm use 1.2.0`. The Anchor v2 version of this example is in
> [`../anchor`](../anchor/).

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 @@ -111,12 +136,14 @@ anchor build

## Testing

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
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.
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, 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.

```sh
anchor test
Expand All @@ -128,7 +155,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 @@ -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 @@ -37,15 +37,11 @@ pub struct AddOutcomeAccountConstraints<'info> {

pub fn handle_add_outcome(context: Context<AddOutcomeAccountConstraints>, 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<'info> {
}

pub fn handle_cancel_event(context: 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 @@ -53,19 +53,25 @@ pub struct InitializeEventAccountConstraints<'info> {
pub fn handle_initialize_event(
context: 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.set_inner(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 @@ -4,6 +4,7 @@ pub mod claim_refund;
pub mod claim_winnings;
pub mod close_losing_bet;
pub mod initialize_event;
pub mod open_betting;
pub mod initialize_config;
pub mod place_bet;
pub mod settle_event;
Expand All @@ -15,6 +16,7 @@ pub use claim_refund::*;
pub use claim_winnings::*;
pub use close_losing_bet::*;
pub use initialize_event::*;
pub use open_betting::*;
pub use initialize_config::*;
pub use place_bet::*;
pub use settle_event::*;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
use anchor_lang::prelude::*;

use crate::{betting_is_open, error::BettingError, 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<'info> {
pub admin: Signer<'info>,

#[account(
seeds = [b"config"],
bump = config.bump,
has_one = admin @ BettingError::Unauthorized,
)]
pub config: Account<'info, Config>,

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

pub fn handle_open_betting(context: 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
Expand Up @@ -5,7 +5,8 @@ use anchor_spl::{
};

use crate::{
error::BettingError, Bet, Config, Event, EventStatus, Outcome, User, MAX_BETS_PER_USER,
betting_is_open, error::BettingError, Bet, Config, Event, EventStatus, Outcome, User,
MAX_BETS_PER_USER,
};

use super::transfer_tokens_to_vault;
Expand Down Expand Up @@ -85,6 +86,11 @@ pub fn handle_place_bet(context: Context<PlaceBetAccountConstraints>, amount: u6
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(
&context.accounts.bettor_token_account,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use anchor_spl::{
token_interface::{Mint, TokenAccount, TokenInterface},
};

use crate::{error::BettingError, Config, Event, EventStatus, Outcome};
use crate::{error::BettingError, may_settle, Config, Event, EventStatus, Outcome};

use super::transfer_tokens_from_vault;

Expand Down Expand Up @@ -75,6 +75,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