diff --git a/CHANGELOG.md b/CHANGELOG.md index d46a8663e..4c6ebb76b 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/CONTRIBUTING.md b/CONTRIBUTING.md index f7d1442f2..b00fb01e0 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/`. diff --git a/finance/betting-market/anchor-v1/CHANGELOG.md b/finance/betting-market/anchor-v1/CHANGELOG.md index 1f23dc2f6..945cf4dbb 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 72ca138e0..54ecca6f8 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 ecf7c14dd..a7d7c3437 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 0095688cd..94d5a66ed 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 2bec20497..5213834e8 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 6f0414152..75ded125f 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 29a4a92a8..cf46f83f9 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 000000000..bb97c91f7 --- /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 b7043e6f0..2c405df2b 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 debfae8e7..4e2ed16e4 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 abf93e169..9a346137e 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 1d23ff82a..43891cbc1 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 d7f0e155d..29bd6010d 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 784413ad8..62efaf525 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 1a9947045..fad21c7be 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