From 35c8e57d14bce6a83d7073f52f701f8c843bee38 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 01:28:42 +0000 Subject: [PATCH 1/2] CONTRIBUTING: Anchor v1 copies track their Anchor v2 counterparts anchor-v1/ was described 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 v1 reader learns a different program from the one the v2 copy and the book describe. A change to what an example does now goes into both copies; they differ only where the Anchor version forces it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F8Wo5zNLqZ2NQqSjGD7WpK --- CONTRIBUTING.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7d1442f..b00fb01e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,9 +13,14 @@ See [CHANGELOG.md](./CHANGELOG.md) for release history. This file had no changel - Each example lives at `category/example-name//`, 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//tests/`. - Native and Pinocchio tests are Rust + LiteSVM, kept under `program/tests/`. From c70a084e0e33a9335af8162cbd9aa5e0572af00b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 23 Sep 2026 01:28:42 +0000 Subject: [PATCH 2/2] finance/betting-market (Anchor v1): port the draft state and betting close time The v1 copy still locked the outcome list by checking for an empty pool and accepted bets until settlement. It now matches the v2 copy: events start as Draft, open_betting fixes the outcome list before any bet can land, bets stop at betting_closes_at and settlement is allowed only from then on, and cancel_event accepts a draft. Same errors, same argument order, same new tests; the README body now matches the v2 copy's, whose testing paragraph is rewrapped. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F8Wo5zNLqZ2NQqSjGD7WpK --- CHANGELOG.md | 15 + finance/betting-market/anchor-v1/CHANGELOG.md | 16 + finance/betting-market/anchor-v1/README.md | 77 +++-- .../programs/betting-market/src/error.rs | 12 +- .../src/instructions/add_outcome.rs | 12 +- .../src/instructions/cancel_event.rs | 7 +- .../src/instructions/initialize_event.rs | 8 +- .../betting-market/src/instructions/mod.rs | 2 + .../src/instructions/open_betting.rs | 44 +++ .../src/instructions/place_bet.rs | 8 +- .../src/instructions/settle_event.rs | 9 +- .../programs/betting-market/src/lib.rs | 26 +- .../betting-market/src/state/config.rs | 4 +- .../betting-market/src/state/event.rs | 21 +- .../tests/test_betting_market.rs | 274 +++++++++++++++++- finance/betting-market/anchor/README.md | 15 +- 16 files changed, 492 insertions(+), 58 deletions(-) create mode 100644 finance/betting-market/anchor-v1/programs/betting-market/src/instructions/open_betting.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d46a8663..4c6ebb76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/finance/betting-market/anchor-v1/CHANGELOG.md b/finance/betting-market/anchor-v1/CHANGELOG.md index 1f23dc2f..945cf4db 100644 --- a/finance/betting-market/anchor-v1/CHANGELOG.md +++ b/finance/betting-market/anchor-v1/CHANGELOG.md @@ -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 diff --git a/finance/betting-market/anchor-v1/README.md b/finance/betting-market/anchor-v1/README.md index 72ca138e..54ecca6f 100644 --- a/finance/betting-market/anchor-v1/README.md +++ b/finance/betting-market/anchor-v1/README.md @@ -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. @@ -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 @@ -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 @@ -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? diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/error.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/error.rs index ecf7c14d..a7d7c343 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/error.rs +++ b/finance/betting-market/anchor-v1/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-v1/programs/betting-market/src/instructions/add_outcome.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/add_outcome.rs index 0095688c..94d5a66e 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/add_outcome.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/add_outcome.rs @@ -37,15 +37,11 @@ pub struct AddOutcomeAccountConstraints<'info> { pub fn handle_add_outcome(context: Context, 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-v1/programs/betting-market/src/instructions/cancel_event.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/cancel_event.rs index 2bec2049..5213834e 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/cancel_event.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/cancel_event.rs @@ -24,8 +24,13 @@ pub struct CancelEventAccountConstraints<'info> { } pub fn handle_cancel_event(context: 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-v1/programs/betting-market/src/instructions/initialize_event.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/initialize_event.rs index 6f041415..75ded125 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/initialize_event.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/initialize_event.rs @@ -53,19 +53,25 @@ pub struct InitializeEventAccountConstraints<'info> { pub fn handle_initialize_event( context: 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.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, diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/mod.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/mod.rs index 29a4a92a..cf46f83f 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/mod.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/mod.rs @@ -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; @@ -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::*; diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/open_betting.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/open_betting.rs new file mode 100644 index 00000000..bb97c91f --- /dev/null +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/open_betting.rs @@ -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) -> 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-v1/programs/betting-market/src/instructions/place_bet.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/place_bet.rs index b7043e6f..2c405df2 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/place_bet.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/place_bet.rs @@ -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; @@ -85,6 +86,11 @@ pub fn handle_place_bet(context: Context, 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, diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/settle_event.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/settle_event.rs index debfae8e..4e2ed16e 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/settle_event.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/settle_event.rs @@ -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; @@ -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 diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/lib.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/lib.rs index abf93e16..9a346137 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/lib.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/lib.rs @@ -28,26 +28,40 @@ 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: 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: Context, label: String) -> Result<()> { 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: 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: 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: Context, winning_outcome_index: u8) -> Result<()> { instructions::settle_event::handle_settle_event(context, winning_outcome_index) @@ -65,7 +79,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: Context) -> Result<()> { instructions::cancel_event::handle_cancel_event(context) } diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/state/config.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/state/config.rs index 1d23ff82..43891cbc 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/state/config.rs +++ b/finance/betting-market/anchor-v1/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] #[derive(InitSpace)] pub struct Config { diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/state/event.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/state/event.rs index d7f0e155..29bd6010 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/state/event.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/state/event.rs @@ -2,7 +2,10 @@ use anchor_lang::prelude::*; #[derive(AnchorSerialize, AnchorDeserialize, Clone, PartialEq, Eq, InitSpace)] 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-v1/programs/betting-market/tests/test_betting_market.rs b/finance/betting-market/anchor-v1/programs/betting-market/tests/test_betting_market.rs index 784413ad..62efaf52 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/tests/test_betting_market.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/tests/test_betting_market.rs @@ -1,14 +1,16 @@ use { anchor_lang::{ + prelude::Clock, solana_program::{instruction::Instruction, pubkey::Pubkey, system_program}, AccountDeserialize, InstructionData, ToAccountMetas, }, - betting_market::{User, MAX_BETS_PER_USER}, + betting_market::{error::BettingError, User, MAX_BETS_PER_USER}, litesvm::LiteSVM, solana_keypair::Keypair, solana_kite::{ create_associated_token_account, create_token_mint, create_wallet, get_token_account_balance, mint_tokens_to_token_account, send_transaction_from_instructions, + SolanaKiteError, }, solana_signer::Signer, }; @@ -16,6 +18,12 @@ use { 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() -> Pubkey { "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" .parse() @@ -69,6 +77,7 @@ struct Market { // (admin is the mint authority), and a fee-recipient wallet with an ATA. fn setup() -> Market { let mut svm = LiteSVM::new(); + warp_to(&mut svm, START_TIME); let program_bytes = include_bytes!("../../../target/deploy/betting_market.so"); svm.add_program(betting_market::id(), program_bytes).unwrap(); @@ -88,6 +97,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, Pubkey) { let bettor = create_wallet(&mut market.svm, 10_000_000_000).unwrap(); @@ -123,11 +153,22 @@ fn initialize_config_ix(admin: Pubkey, mint: Pubkey, fee_recipient: Pubkey) -> I } fn initialize_event_ix(admin: Pubkey, mint: Pubkey, 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: Pubkey, + mint: Pubkey, + 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(), @@ -164,6 +205,19 @@ fn add_outcome_ix(admin: Pubkey, event_id: u64, index: u8, label: &str) -> Instr ) } +fn open_betting_ix(admin: Pubkey, 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: Pubkey, bettor: &Pubkey, @@ -351,6 +405,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, @@ -390,6 +445,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(admin, mint, fee_recipient, fee_recipient_ata, event_id, 0)], @@ -489,6 +546,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, @@ -501,6 +559,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(admin, mint, fee_recipient, fee_recipient_ata, event_id, 0)], @@ -515,7 +575,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] @@ -536,6 +596,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, @@ -555,6 +616,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(admin, mint, fee_recipient, fee_recipient_ata, event_id, 0)], @@ -598,6 +661,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, @@ -612,13 +676,15 @@ 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(admin, mint, fee_recipient, fee_recipient_ata, event_id, 1)], &[&market.admin], &admin, ); - assert!(result.is_err(), "settling to an outcome with no bets must fail"); + assert_fails_with(result, BettingError::OutcomeHasNoBets); } #[test] @@ -637,6 +703,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, @@ -712,6 +779,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, @@ -741,6 +809,8 @@ fn test_close_losing_bet_only_after_settle_and_only_for_losers() { ); assert!(premature_close.is_err(), "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(admin, mint, fee_recipient, fee_recipient_ata, event_id, 0)], @@ -807,12 +877,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, @@ -892,3 +970,193 @@ fn test_closing_a_bet_frees_a_slot_for_a_new_bet() { let new_bet = bet_pda(&outcome_pda(&event_pda(second_event_id), 0), &alice.pubkey()); assert!(final_bets.contains(&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/anchor/README.md b/finance/betting-market/anchor/README.md index 1a994704..fad21c7b 100644 --- a/finance/betting-market/anchor/README.md +++ b/finance/betting-market/anchor/README.md @@ -136,13 +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, 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. +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