From fc97284eda91eca601df62b80231a554cd7c627c Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Sat, 3 Oct 2026 21:04:00 +0000 Subject: [PATCH 1/2] Betting market tests: use invented film titles for the outcomes Claude-Session: https://claude.ai/code/session_015fQ8KASdfsckQAyGYfezs8 --- .../programs/betting-market/tests/test_betting_market.rs | 4 ++-- .../programs/betting-market/tests/test_betting_market.rs | 4 ++-- finance/betting-market/quasar/src/tests.rs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) 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 7317ee3c..5deb134e 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 @@ -1236,7 +1236,7 @@ fn test_outcomes_lock_when_betting_opens() { &mut market.svm, vec![ initialize_event_ix(admin, mint, event_id, "Top-grossing film"), - add_outcome_ix(admin, event_id, 0, "Toy Story 5"), + add_outcome_ix(admin, event_id, 0, "Glowbugs 3"), ], &[&market.admin], &admin, @@ -1263,7 +1263,7 @@ fn test_outcomes_lock_when_betting_opens() { send_transaction_from_instructions( &mut market.svm, vec![ - add_outcome_ix(admin, event_id, 1, "Backrooms"), + add_outcome_ix(admin, event_id, 1, "The Quiet Floor"), open_betting_ix(admin, event_id), ], &[&market.admin], diff --git a/finance/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs b/finance/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs index 3ee5329e..87bf4e41 100644 --- a/finance/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs +++ b/finance/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs @@ -1236,7 +1236,7 @@ fn test_outcomes_lock_when_betting_opens() { &mut market.svm, vec![ initialize_event_ix(admin, mint, event_id, "Top-grossing film"), - add_outcome_ix(admin, event_id, 0, "Toy Story 5"), + add_outcome_ix(admin, event_id, 0, "Glowbugs 3"), ], &[&market.admin], &admin, @@ -1263,7 +1263,7 @@ fn test_outcomes_lock_when_betting_opens() { send_transaction_from_instructions( &mut market.svm, vec![ - add_outcome_ix(admin, event_id, 1, "Backrooms"), + add_outcome_ix(admin, event_id, 1, "The Quiet Floor"), open_betting_ix(admin, event_id), ], &[&market.admin], diff --git a/finance/betting-market/quasar/src/tests.rs b/finance/betting-market/quasar/src/tests.rs index a05338b4..a02e9992 100644 --- a/finance/betting-market/quasar/src/tests.rs +++ b/finance/betting-market/quasar/src/tests.rs @@ -362,7 +362,7 @@ fn open_betting(admin: Pubkey) -> OpenBettingInstruction { fn outcomes_lock_when_betting_opens(test: &mut Test) { base_world(test); add_bettor(test, BETTOR_A, TOKEN_A); - draft_event(test, &["Toy Story 5"]); + draft_event(test, &["Glowbugs 3"]); test.send(bet(BETTOR_A, TOKEN_A, 0, 100)) .fails_with(BettingError::EventNotOpen); @@ -371,7 +371,7 @@ fn outcomes_lock_when_betting_opens(test: &mut Test) { admin: ADMIN, event_event_id_seed: EVENT_ID, event_outcome_count_seed: 1, - label: "Backrooms".to_string().into(), + label: "The Quiet Floor".to_string().into(), }) .succeeds(); test.send(open_betting(ADMIN)).succeeds(); From 6e7b5512887632243028438e433a502d95d4321f Mon Sep 17 00:00:00 2001 From: Mike MacCana Date: Sat, 3 Oct 2026 21:35:14 +0000 Subject: [PATCH 2/2] finance/betting-market: drop the per-wallet User index and its 32-bet cap The User account listed a wallet's open Bet addresses so a client could find them, which getProgramAccounts with a memcmp filter on Bet.bettor already does. Keeping the list cost every bettor rent, added a writable account to four handlers, and capped a wallet at 32 open positions with TooManyBets. Remove it from the Anchor v2, Anchor v1 and Quasar copies. The User account, MAX_BETS_PER_USER, TooManyBets and BetNotInUserIndex go; the handlers no longer take a user account; close_losing_bet now only returns the Bet account's rent. The slot-recycling test becomes test_no_cap_on_open_bets_per_wallet, which places forty open bets from one wallet plus one on a second market. Bet's doc comment and the READMEs give the memcmp offset (8 for Anchor, 1 for Quasar). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015fQ8KASdfsckQAyGYfezs8 --- finance/betting-market/anchor-v1/CHANGELOG.md | 13 ++ finance/betting-market/anchor-v1/README.md | 24 +-- .../programs/betting-market/Cargo.toml | 2 +- .../programs/betting-market/src/error.rs | 4 - .../src/instructions/claim_refund.rs | 15 +- .../src/instructions/claim_winnings.rs | 15 +- .../src/instructions/close_losing_bet.rs | 18 +- .../src/instructions/place_bet.rs | 29 +-- .../programs/betting-market/src/lib.rs | 6 +- .../programs/betting-market/src/state/bet.rs | 5 + .../programs/betting-market/src/state/mod.rs | 2 - .../programs/betting-market/src/state/user.rs | 38 ---- .../tests/test_betting_market.rs | 198 +++++++----------- finance/betting-market/anchor/CHANGELOG.md | 13 ++ finance/betting-market/anchor/README.md | 24 +-- .../anchor/programs/betting-market/Cargo.toml | 2 +- .../programs/betting-market/src/error.rs | 4 - .../src/instructions/claim_refund.rs | 15 +- .../src/instructions/claim_winnings.rs | 15 +- .../src/instructions/close_losing_bet.rs | 18 +- .../src/instructions/place_bet.rs | 26 +-- .../anchor/programs/betting-market/src/lib.rs | 6 +- .../programs/betting-market/src/state/bet.rs | 5 + .../programs/betting-market/src/state/mod.rs | 2 - .../programs/betting-market/src/state/user.rs | 38 ---- .../tests/test_betting_market.rs | 198 +++++++----------- finance/betting-market/quasar/CHANGELOG.md | 13 ++ finance/betting-market/quasar/README.md | 17 +- finance/betting-market/quasar/src/errors.rs | 2 - .../quasar/src/instructions/claim_refund.rs | 12 +- .../quasar/src/instructions/claim_winnings.rs | 12 +- .../src/instructions/close_losing_bet.rs | 14 +- .../quasar/src/instructions/place_bet.rs | 26 +-- finance/betting-market/quasar/src/lib.rs | 6 +- .../betting-market/quasar/src/state/bet.rs | 5 + .../betting-market/quasar/src/state/mod.rs | 2 - .../betting-market/quasar/src/state/user.rs | 97 --------- finance/betting-market/quasar/src/tests.rs | 37 +++- 38 files changed, 285 insertions(+), 693 deletions(-) delete mode 100644 finance/betting-market/anchor-v1/programs/betting-market/src/state/user.rs delete mode 100644 finance/betting-market/anchor/programs/betting-market/src/state/user.rs delete mode 100644 finance/betting-market/quasar/src/state/user.rs diff --git a/finance/betting-market/anchor-v1/CHANGELOG.md b/finance/betting-market/anchor-v1/CHANGELOG.md index 945cf4db..b3559f34 100644 --- a/finance/betting-market/anchor-v1/CHANGELOG.md +++ b/finance/betting-market/anchor-v1/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [2026-10-03] + +### Removed + +- The per-wallet `User` index account (`seeds = [b"user", wallet]`), its + `MAX_BETS_PER_USER` cap of 32 open positions, and the `TooManyBets` and + `BetNotInUserIndex` errors. `place_bet`, `claim_winnings`, `claim_refund` and + `close_losing_bet` no longer take a `user` account. A client lists a wallet's + open bets with `getProgramAccounts` and a `memcmp` filter on `Bet.bettor` at + offset 8, so a wallet can hold any number of open positions + (`test_no_cap_on_open_bets_per_wallet`). Error codes after `ZeroAmount` move down by two. +- Outcome labels in the tests are invented film titles. + ## [2026-09-23] ### Changed diff --git a/finance/betting-market/anchor-v1/README.md b/finance/betting-market/anchor-v1/README.md index 9ef7b6e1..ae65c97b 100644 --- a/finance/betting-market/anchor-v1/README.md +++ b/finance/betting-market/anchor-v1/README.md @@ -44,12 +44,9 @@ cancelling). the same outcome adds to the existing Bet, so there is exactly one per (outcome, bettor). The account exists only while the position is open: it closes (rent back to the bettor) via `claim_winnings`, `claim_refund`, or `close_losing_bet`, which is also what makes a second claim - impossible. -- **User** (`seeds = [b"user", wallet]`) - a per-wallet index listing the bettor's open Bet - addresses, so a client can find someone's positions without scanning every Bet on the program. - `place_bet` adds an entry and every instruction that closes a Bet removes it, so the cap (see - `MAX_BETS_PER_USER`) limits concurrent open positions, not lifetime bets. The fixed cap keeps the - account a constant size; the Bet accounts are the authoritative stake record. + impossible. `bettor` is the first field after the 8-byte discriminator, so a client lists a + wallet's open positions with `getProgramAccounts` and a `memcmp` filter on the wallet's address at + offset 8. The program keeps no per-wallet index, so a wallet can hold any number of open bets. ### The vault @@ -91,17 +88,14 @@ division floors each share, leaving at most a few minor units of dust in the vau - `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). + `betting_closes_at`; creates or tops up the Bet and updates the pools. - `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. + closes the Bet account. +- `close_losing_bet` - losing bettor. After settlement, closes a worthless Bet to reclaim its rent. - `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. +- `claim_refund` - bettor. After a cancellation, reclaims the exact stake; the Bet account closes. ### Lifecycle @@ -142,8 +136,8 @@ Tests are Rust integration tests running against 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. +`close_losing_bet` guards, and a wallet holding forty open bets at once, which shows there is no +per-wallet cap. ```sh anchor test diff --git a/finance/betting-market/anchor-v1/programs/betting-market/Cargo.toml b/finance/betting-market/anchor-v1/programs/betting-market/Cargo.toml index 24ac05e5..da0f66cf 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/Cargo.toml +++ b/finance/betting-market/anchor-v1/programs/betting-market/Cargo.toml @@ -20,7 +20,7 @@ custom-heap = [] custom-panic = [] [dependencies] -# init-if-needed: place_bet and the lazy User index create accounts only on first use. +# init-if-needed: place_bet creates the Bet account only on a bettor's first bet on an outcome. anchor-lang = { version = "1.2.0", features = ["init-if-needed"] } anchor-spl = "1.2.0" 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 a7d7c343..b81f57b0 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 @@ -22,10 +22,6 @@ pub enum BettingError { BetWon, #[msg("The bet amount must be greater than zero")] ZeroAmount, - #[msg("This bettor already holds the maximum number of open positions")] - TooManyBets, - #[msg("This bet is not in the bettor's User index")] - BetNotInUserIndex, #[msg("Arithmetic overflow")] MathOverflow, #[msg("Outcomes can only be added, and betting opened, while the event is a draft")] diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/claim_refund.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/claim_refund.rs index c922d9cc..0fa32f4d 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/claim_refund.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/claim_refund.rs @@ -1,7 +1,7 @@ use anchor_lang::prelude::*; use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; -use crate::{error::BettingError, Bet, Event, EventStatus, User}; +use crate::{error::BettingError, Bet, Event, EventStatus}; use super::transfer_tokens_from_vault; @@ -31,13 +31,6 @@ pub struct ClaimRefundAccountConstraints<'info> { )] pub bet: Account<'info, Bet>, - #[account( - mut, - seeds = [b"user", bettor.key().as_ref()], - bump = user.bump, - )] - pub user: Account<'info, User>, - #[account( mut, associated_token::mint = token_mint, @@ -65,12 +58,6 @@ pub fn handle_claim_refund(context: Context) -> R let stake = context.accounts.bet.amount; - // The position is over, so drop the Bet from the bettor's index before the - // transfer (effects before interactions); the Bet account itself closes - // when the instruction finishes. - let bet_key = context.accounts.bet.key(); - context.accounts.user.remove_bet(&bet_key)?; - let event_id = context.accounts.event.event_id; let event_bump = context.accounts.event.bump; transfer_tokens_from_vault( diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/claim_winnings.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/claim_winnings.rs index 912945d0..8be0dded 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/claim_winnings.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/claim_winnings.rs @@ -1,7 +1,7 @@ use anchor_lang::prelude::*; use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; -use crate::{error::BettingError, Bet, Event, EventStatus, User}; +use crate::{error::BettingError, Bet, Event, EventStatus}; use super::transfer_tokens_from_vault; @@ -31,13 +31,6 @@ pub struct ClaimWinningsAccountConstraints<'info> { )] pub bet: Account<'info, Bet>, - #[account( - mut, - seeds = [b"user", bettor.key().as_ref()], - bump = user.bump, - )] - pub user: Account<'info, User>, - #[account( mut, associated_token::mint = token_mint, @@ -90,12 +83,6 @@ pub fn handle_claim_winnings(context: Context) .checked_add(winnings) .ok_or(BettingError::MathOverflow)?; - // The position is over, so drop the Bet from the bettor's index before the - // transfer (effects before interactions); the Bet account itself closes - // when the instruction finishes. - let bet_key = context.accounts.bet.key(); - context.accounts.user.remove_bet(&bet_key)?; - let event_id = context.accounts.event.event_id; let event_bump = context.accounts.event.bump; transfer_tokens_from_vault( diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/close_losing_bet.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/close_losing_bet.rs index ed533ddd..31b15712 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/close_losing_bet.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/instructions/close_losing_bet.rs @@ -1,11 +1,10 @@ use anchor_lang::prelude::*; -use crate::{error::BettingError, Bet, Event, EventStatus, User}; +use crate::{error::BettingError, Bet, Event, EventStatus}; -// A losing bet pays nothing, but it still occupies a slot in the bettor's -// User index and holds rent. Closing it frees the slot (so the bettor can -// open a new position) and returns the rent. Winning bets must go through -// claim_winnings instead, which also pays out the stake and winnings. +// A losing bet pays nothing, but its account still holds rent. Closing it +// returns the rent to the bettor. Winning bets must go through claim_winnings +// instead, which also pays out the stake and winnings. #[derive(Accounts)] pub struct CloseLosingBetAccountConstraints<'info> { #[account(mut)] @@ -26,13 +25,6 @@ pub struct CloseLosingBetAccountConstraints<'info> { bump = bet.bump, )] pub bet: Account<'info, Bet>, - - #[account( - mut, - seeds = [b"user", bettor.key().as_ref()], - bump = user.bump, - )] - pub user: Account<'info, User>, } pub fn handle_close_losing_bet(context: Context) -> Result<()> { @@ -45,7 +37,5 @@ pub fn handle_close_losing_bet(context: Context { )] pub bet: Box>, - #[account( - init_if_needed, - payer = bettor, - space = User::DISCRIMINATOR.len() + User::INIT_SPACE, - seeds = [b"user", bettor.key().as_ref()], - bump - )] - pub user: Box>, - pub associated_token_program: Program<'info, AssociatedToken>, pub token_program: Interface<'info, TokenInterface>, pub system_program: Program<'info, System>, @@ -105,9 +93,7 @@ pub fn handle_place_bet(context: Context, amount: u6 let event_key = context.accounts.event.key(); let outcome_key = context.accounts.outcome.key(); let outcome_index = context.accounts.outcome.index; - let bet_key = context.accounts.bet.key(); let bet_bump = context.bumps.bet; - let user_bump = context.bumps.user; let bet = &mut context.accounts.bet; // A fresh init_if_needed Bet has amount 0; that is how we tell a first bet @@ -143,18 +129,5 @@ pub fn handle_place_bet(context: Context, amount: u6 .checked_add(amount) .ok_or(BettingError::MathOverflow)?; - let user = &mut context.accounts.user; - if user.authority == Pubkey::default() { - user.authority = bettor_key; - user.bump = user_bump; - } - if is_new_bet { - require!( - user.bets.len() < MAX_BETS_PER_USER, - BettingError::TooManyBets - ); - user.bets.push(bet_key); - } - Ok(()) } 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 db1a41ff..e01ea31b 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 @@ -74,13 +74,13 @@ pub mod betting_market { } // A winner withdraws their stake plus their pro-rata share of the losing - // pool. The Bet account closes and leaves the bettor's User index. + // pool. The Bet account closes. pub fn claim_winnings(context: Context) -> Result<()> { instructions::claim_winnings::handle_claim_winnings(context) } // A loser closes their worthless bet after settlement, reclaiming the - // Bet account's rent and freeing the slot in their User index. + // Bet account's rent. pub fn close_losing_bet(context: Context) -> Result<()> { instructions::close_losing_bet::handle_close_losing_bet(context) } @@ -91,7 +91,7 @@ pub mod betting_market { } // After a cancellation, a bettor reclaims their exact stake. The Bet - // account closes and leaves the bettor's User index. + // account closes. pub fn claim_refund(context: Context) -> Result<()> { instructions::claim_refund::handle_claim_refund(context) } diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/state/bet.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/state/bet.rs index 65330d78..00565b96 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/state/bet.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/state/bet.rs @@ -5,6 +5,11 @@ use anchor_lang::prelude::*; // one Bet per (outcome, bettor). The account lives only while the position is // open: it closes (rent back to the bettor) on claim_winnings, claim_refund, // or close_losing_bet, which is also what prevents double claims. +// +// `bettor` is the first field, right after the 8-byte discriminator, so a +// client lists a wallet's open positions with getProgramAccounts and a memcmp +// filter on the wallet's address at offset 8. The program keeps no per-wallet +// index, so there is no limit on how many positions a wallet holds. #[account] #[derive(InitSpace)] pub struct Bet { diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/state/mod.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/state/mod.rs index 7d3f7a61..1dbbfc3e 100644 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/state/mod.rs +++ b/finance/betting-market/anchor-v1/programs/betting-market/src/state/mod.rs @@ -2,10 +2,8 @@ pub mod bet; pub mod config; pub mod event; pub mod outcome; -pub mod user; pub use bet::*; pub use config::*; pub use event::*; pub use outcome::*; -pub use user::*; diff --git a/finance/betting-market/anchor-v1/programs/betting-market/src/state/user.rs b/finance/betting-market/anchor-v1/programs/betting-market/src/state/user.rs deleted file mode 100644 index 44e41b0f..00000000 --- a/finance/betting-market/anchor-v1/programs/betting-market/src/state/user.rs +++ /dev/null @@ -1,38 +0,0 @@ -use anchor_lang::prelude::*; - -use crate::error::BettingError; - -// A bettor can hold at most this many OPEN positions at once (one per outcome -// they currently back). Re-betting an outcome adds to the existing Bet, and -// closing a Bet (claim_winnings, claim_refund, close_losing_bet) removes its -// entry, so this caps concurrent positions, not lifetime bets. A fixed cap -// keeps the account a constant size - no reallocation on each bet. -pub const MAX_BETS_PER_USER: usize = 32; - -// Per-wallet index of a bettor's open Bet accounts, so a client can list -// someone's positions without scanning every Bet account on the program. The -// authoritative stake state lives in the Bet accounts; this is a convenience -// index. Entries are added by place_bet and removed whenever the Bet account -// closes. -#[account] -#[derive(InitSpace)] -pub struct User { - pub authority: Pubkey, - #[max_len(MAX_BETS_PER_USER)] - pub bets: Vec, - pub bump: u8, -} - -impl User { - // Drop a closed Bet's entry from the index. Order is not meaningful, so a - // swap_remove (move the last entry into the gap) is the cheapest removal. - pub fn remove_bet(&mut self, bet_key: &Pubkey) -> Result<()> { - let index = self - .bets - .iter() - .position(|entry| entry == bet_key) - .ok_or(BettingError::BetNotInUserIndex)?; - self.bets.swap_remove(index); - Ok(()) - } -} 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 5deb134e..822bb83f 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 @@ -4,7 +4,7 @@ use { solana_program::{instruction::Instruction, pubkey::Pubkey, system_program}, AccountDeserialize, InstructionData, ToAccountMetas, }, - betting_market::{error::BettingError, User, MAX_BETS_PER_USER}, + betting_market::{error::BettingError, Bet}, litesvm::LiteSVM, solana_keypair::Keypair, solana_kite::{ @@ -68,10 +68,6 @@ fn bet_pda(outcome: &Pubkey, bettor: &Pubkey) -> Pubkey { .0 } -fn user_pda(bettor: &Pubkey) -> Pubkey { - Pubkey::find_program_address(&[b"user", bettor.as_ref()], &betting_market::id()).0 -} - struct Market { svm: LiteSVM, admin: Keypair, @@ -253,7 +249,6 @@ fn place_bet_ix( bettor_token_account: *bettor_ata, vault: derive_ata(&event, &mint), bet: bet_pda(&outcome, bettor), - user: user_pda(bettor), associated_token_program: ata_program_id(), token_program: token_program_id(), system_program: system_program::id(), @@ -311,7 +306,6 @@ fn claim_winnings_ix( token_mint: mint, event, bet: bet_pda(&outcome, bettor), - user: user_pda(bettor), bettor_token_account: *bettor_ata, vault: derive_ata(&event, &mint), token_program: token_program_id(), @@ -350,7 +344,6 @@ fn claim_refund_ix( token_mint: mint, event, bet: bet_pda(&outcome, bettor), - user: user_pda(bettor), bettor_token_account: *bettor_ata, vault: derive_ata(&event, &mint), token_program: token_program_id(), @@ -369,19 +362,29 @@ fn close_losing_bet_ix(bettor: &Pubkey, event_id: u64, outcome_index: u8) -> Ins bettor: *bettor, event, bet: bet_pda(&outcome, bettor), - user: user_pda(bettor), } .to_account_metas(None), ) } -// Decode a User account so tests can assert exactly which Bet addresses the -// per-wallet index currently holds. -fn read_user_bets(market: &Market, bettor: &Pubkey) -> Vec { - let account = market.svm.get_account(&user_pda(bettor)).unwrap(); - User::try_deserialize(&mut account.data.as_slice()) - .unwrap() - .bets +// A Bet account lives only while its position is open: claiming, refunding or +// closing it as a loser closes the account. +fn bet_is_open(market: &Market, bet: &Pubkey) -> bool { + market + .svm + .get_account(bet) + .is_some_and(|account| account.lamports > 0 && !account.data.is_empty()) +} + +// A bettor's open positions are the Bet accounts whose `bettor` field is their +// address. An RPC client finds them with getProgramAccounts and a memcmp filter +// at this offset, so the program keeps no per-wallet index (and no cap on +// how many positions a wallet holds). +const BET_BETTOR_OFFSET: usize = 8; + +fn read_bet(market: &Market, bet: &Pubkey) -> Bet { + let account = market.svm.get_account(bet).unwrap(); + Bet::try_deserialize(&mut account.data.as_slice()).unwrap() } fn init_config(market: &mut Market) { @@ -471,12 +474,14 @@ fn test_full_lifecycle() { // Vault holds the entire pool. let vault = derive_ata(&event_pda(event_id), &mint); assert_eq!(get_token_account_balance(&market.svm, &vault).unwrap(), 600); + // Alice's position is a Bet account that records her as the bettor, at + // the offset a client filters on to list her open bets. + let alice_bet_address = bet_pda(&outcome_pda(&event_pda(event_id), 0), &alice.pubkey()); + assert_eq!(read_bet(&market, &alice_bet_address).bettor, alice.pubkey()); + let alice_bet_data = market.svm.get_account(&alice_bet_address).unwrap().data; assert_eq!( - read_user_bets(&market, &alice.pubkey()), - vec![bet_pda( - &outcome_pda(&event_pda(event_id), 0), - &alice.pubkey() - )] + &alice_bet_data[BET_BETTOR_OFFSET..BET_BETTOR_OFFSET + 32], + alice.pubkey().as_ref() ); // Settle to "Yes" (index 0). Losing pool 200, fee = 2% = 4, distributable = 196. @@ -542,9 +547,16 @@ fn test_full_lifecycle() { // Pool fully distributed: 400 stakes + 196 winnings + 4 fee = 600. assert_eq!(get_token_account_balance(&market.svm, &vault).unwrap(), 0); - // Claiming closed the winners' Bet accounts and emptied their indexes. - assert!(read_user_bets(&market, &alice.pubkey()).is_empty()); - assert!(read_user_bets(&market, &bob.pubkey()).is_empty()); + // Claiming closed the winners' Bet accounts. + let winning_outcome = outcome_pda(&event_pda(event_id), 0); + assert!(!bet_is_open( + &market, + &bet_pda(&winning_outcome, &alice.pubkey()) + )); + assert!(!bet_is_open( + &market, + &bet_pda(&winning_outcome, &bob.pubkey()) + )); // Carol bet the losing outcome, so she has nothing to claim. let carol_claim = send_transaction_from_instructions( @@ -564,9 +576,9 @@ fn test_full_lifecycle() { "loser must not be able to claim winnings" ); - // Her losing position stays in the index until she closes it. + // Her losing position stays open until she closes it. let carol_bet = bet_pda(&outcome_pda(&event_pda(event_id), 1), &carol.pubkey()); - assert_eq!(read_user_bets(&market, &carol.pubkey()), vec![carol_bet]); + assert!(bet_is_open(&market, &carol_bet)); send_transaction_from_instructions( &mut market.svm, vec![close_losing_bet_ix(&carol.pubkey(), event_id, 1)], @@ -574,7 +586,7 @@ fn test_full_lifecycle() { &carol.pubkey(), ) .unwrap(); - assert!(read_user_bets(&market, &carol.pubkey()).is_empty()); + assert!(!bet_is_open(&market, &carol_bet)); } #[test] @@ -885,7 +897,7 @@ fn test_cancel_and_refund() { .unwrap(); let alice_bet = bet_pda(&outcome_pda(&event_pda(event_id), 0), &alice.pubkey()); - assert_eq!(read_user_bets(&market, &alice.pubkey()), vec![alice_bet]); + assert!(bet_is_open(&market, &alice_bet)); send_transaction_from_instructions( &mut market.svm, @@ -901,8 +913,8 @@ fn test_cancel_and_refund() { ) .unwrap(); - // The refund closed Alice's Bet account and removed it from her index. - assert!(read_user_bets(&market, &alice.pubkey()).is_empty()); + // The refund closed Alice's Bet account. + assert!(!bet_is_open(&market, &alice_bet)); send_transaction_from_instructions( &mut market.svm, @@ -1025,9 +1037,9 @@ fn test_close_losing_bet_only_after_settle_and_only_for_losers() { "a winning bet must not be closed as losing" ); let alice_bet = bet_pda(&outcome_pda(&event_pda(event_id), 0), &alice.pubkey()); - assert_eq!(read_user_bets(&market, &alice.pubkey()), vec![alice_bet]); + assert!(bet_is_open(&market, &alice_bet)); - // Carol lost; closing frees her index slot. A fresh blockhash so this is + // Carol lost; closing returns her Bet account's rent. A fresh blockhash so this is // a distinct transaction from her premature attempt above. market.svm.expire_blockhash(); send_transaction_from_instructions( @@ -1037,22 +1049,21 @@ fn test_close_losing_bet_only_after_settle_and_only_for_losers() { &carol.pubkey(), ) .unwrap(); - assert!(read_user_bets(&market, &carol.pubkey()).is_empty()); + let carol_bet = bet_pda(&outcome_pda(&event_pda(event_id), 1), &carol.pubkey()); + assert!(!bet_is_open(&market, &carol_bet)); } -// Regression test: closing a Bet must free its User index slot, so a wallet -// that fills all MAX_BETS_PER_USER slots can bet again after unwinding a -// position. Without the removal, a full index rejects every future bet on -// every market, permanently. +// A wallet can hold as many open positions as it likes: forty bets across +// forty outcomes, plus one on another market, all land. The program keeps no +// per-wallet index of open bets, so there is no list to fill. #[test] -fn test_closing_a_bet_frees_a_slot_for_a_new_bet() { +fn test_no_cap_on_open_bets_per_wallet() { const STAKE: u64 = 10; + const OUTCOME_COUNT: u8 = 40; let mut market = setup(); - let full_event_id: u64 = 7; + let wide_event_id: u64 = 7; let second_event_id: u64 = 8; - // Enough outcomes to fill the index and attempt one more bet. - let outcome_count = (MAX_BETS_PER_USER + 1) as u8; - let (alice, alice_ata) = create_bettor(&mut market, outcome_count as u64 * STAKE); + let (alice, alice_ata) = create_bettor(&mut market, (OUTCOME_COUNT as u64 + 1) * STAKE); init_config(&mut market); let admin = market.admin.pubkey(); @@ -1063,19 +1074,19 @@ fn test_closing_a_bet_frees_a_slot_for_a_new_bet() { vec![initialize_event_ix( admin, mint, - full_event_id, + wide_event_id, "Wide field", )], &[&market.admin], &admin, ) .unwrap(); - for index in 0..outcome_count { + for index in 0..OUTCOME_COUNT { send_transaction_from_instructions( &mut market.svm, vec![add_outcome_ix( admin, - full_event_id, + wide_event_id, index, &format!("Runner {index}"), )], @@ -1086,7 +1097,7 @@ fn test_closing_a_bet_frees_a_slot_for_a_new_bet() { } send_transaction_from_instructions( &mut market.svm, - vec![open_betting_ix(admin, full_event_id)], + vec![open_betting_ix(admin, wide_event_id)], &[&market.admin], &admin, ) @@ -1104,15 +1115,14 @@ fn test_closing_a_bet_frees_a_slot_for_a_new_bet() { ) .unwrap(); - // Fill every slot in Alice's index. - for index in 0..MAX_BETS_PER_USER as u8 { + for index in 0..OUTCOME_COUNT { send_transaction_from_instructions( &mut market.svm, vec![place_bet_ix( mint, &alice.pubkey(), &alice_ata, - full_event_id, + wide_event_id, index, STAKE, )], @@ -1121,30 +1131,7 @@ fn test_closing_a_bet_frees_a_slot_for_a_new_bet() { ) .unwrap(); } - assert_eq!( - read_user_bets(&market, &alice.pubkey()).len(), - MAX_BETS_PER_USER - ); - - // With the index full, any new position is rejected - on this event or another. - let one_too_many = send_transaction_from_instructions( - &mut market.svm, - vec![place_bet_ix( - mint, - &alice.pubkey(), - &alice_ata, - full_event_id, - MAX_BETS_PER_USER as u8, - STAKE, - )], - &[&alice], - &alice.pubkey(), - ); - assert!( - one_too_many.is_err(), - "a full index must reject a new position" - ); - let other_market_bet = send_transaction_from_instructions( + send_transaction_from_instructions( &mut market.svm, vec![place_bet_ix( mint, @@ -1156,67 +1143,24 @@ fn test_closing_a_bet_frees_a_slot_for_a_new_bet() { )], &[&alice], &alice.pubkey(), - ); - assert!( - other_market_bet.is_err(), - "a full index must reject bets on any market" - ); - - // Unwind one position: cancel the event and refund the first bet. - send_transaction_from_instructions( - &mut market.svm, - vec![cancel_event_ix(admin, full_event_id)], - &[&market.admin], - &admin, ) .unwrap(); - send_transaction_from_instructions( - &mut market.svm, - vec![claim_refund_ix( - mint, - &alice.pubkey(), - &alice_ata, - full_event_id, - 0, - )], - &[&alice], - &alice.pubkey(), - ) - .unwrap(); - let bets_after_refund = read_user_bets(&market, &alice.pubkey()); - assert_eq!(bets_after_refund.len(), MAX_BETS_PER_USER - 1); - let refunded_bet = bet_pda(&outcome_pda(&event_pda(full_event_id), 0), &alice.pubkey()); - assert!( - !bets_after_refund.contains(&refunded_bet), - "the refunded bet must leave the index" - ); - // The freed slot lets the wallet bet again. A fresh blockhash so this is - // a distinct transaction from the rejected attempt above. - market.svm.expire_blockhash(); - send_transaction_from_instructions( - &mut market.svm, - vec![place_bet_ix( - mint, + for index in 0..OUTCOME_COUNT { + let bet = bet_pda( + &outcome_pda(&event_pda(wide_event_id), index), &alice.pubkey(), - &alice_ata, - second_event_id, - 0, - STAKE, - )], - &[&alice], - &alice.pubkey(), - ) - .unwrap(); - let final_bets = read_user_bets(&market, &alice.pubkey()); - assert_eq!(final_bets.len(), MAX_BETS_PER_USER); - let new_bet = bet_pda( + ); + assert!(bet_is_open(&market, &bet), "bet {index} must be open"); + } + let other_market_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" + assert!(bet_is_open(&market, &other_market_bet)); + assert_eq!( + get_token_account_balance(&market.svm, &alice_ata).unwrap(), + 0 ); } diff --git a/finance/betting-market/anchor/CHANGELOG.md b/finance/betting-market/anchor/CHANGELOG.md index c489ddd4..01d4c3d5 100644 --- a/finance/betting-market/anchor/CHANGELOG.md +++ b/finance/betting-market/anchor/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [2026-10-03] + +### Removed + +- The per-wallet `User` index account (`seeds = [b"user", wallet]`), its + `MAX_BETS_PER_USER` cap of 32 open positions, and the `TooManyBets` and + `BetNotInUserIndex` errors. `place_bet`, `claim_winnings`, `claim_refund` and + `close_losing_bet` no longer take a `user` account. A client lists a wallet's + open bets with `getProgramAccounts` and a `memcmp` filter on `Bet.bettor` at + offset 8, so a wallet can hold any number of open positions + (`test_no_cap_on_open_bets_per_wallet`). Error codes after `ZeroAmount` move down by two. +- Outcome labels in the tests are invented film titles. + ## [2026-09-22] ### Changed diff --git a/finance/betting-market/anchor/README.md b/finance/betting-market/anchor/README.md index 615e85eb..770ee446 100644 --- a/finance/betting-market/anchor/README.md +++ b/finance/betting-market/anchor/README.md @@ -44,12 +44,9 @@ cancelling). the same outcome adds to the existing Bet, so there is exactly one per (outcome, bettor). The account exists only while the position is open: it closes (rent back to the bettor) via `claim_winnings`, `claim_refund`, or `close_losing_bet`, which is also what makes a second claim - impossible. -- **User** (`seeds = [b"user", wallet]`) - a per-wallet index listing the bettor's open Bet - addresses, so a client can find someone's positions without scanning every Bet on the program. - `place_bet` adds an entry and every instruction that closes a Bet removes it, so the cap (see - `MAX_BETS_PER_USER`) limits concurrent open positions, not lifetime bets. The fixed cap keeps the - account a constant size; the Bet accounts are the authoritative stake record. + impossible. `bettor` is the first field after the 8-byte discriminator, so a client lists a + wallet's open positions with `getProgramAccounts` and a `memcmp` filter on the wallet's address at + offset 8. The program keeps no per-wallet index, so a wallet can hold any number of open bets. ### The vault @@ -91,17 +88,14 @@ division floors each share, leaving at most a few minor units of dust in the vau - `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). + `betting_closes_at`; creates or tops up the Bet and updates the pools. - `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. + closes the Bet account. +- `close_losing_bet` - losing bettor. After settlement, closes a worthless Bet to reclaim its rent. - `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. +- `claim_refund` - bettor. After a cancellation, reclaims the exact stake; the Bet account closes. ### Lifecycle @@ -142,8 +136,8 @@ Tests are Rust integration tests running against 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. +`close_losing_bet` guards, and a wallet holding forty open bets at once, which shows there is no +per-wallet cap. ```sh anchor test diff --git a/finance/betting-market/anchor/programs/betting-market/Cargo.toml b/finance/betting-market/anchor/programs/betting-market/Cargo.toml index 9a3e13d1..256d3ffc 100644 --- a/finance/betting-market/anchor/programs/betting-market/Cargo.toml +++ b/finance/betting-market/anchor/programs/betting-market/Cargo.toml @@ -22,7 +22,7 @@ custom-panic = [] profile = ["anchor-v2-testing/profile"] [dependencies] -# init-if-needed: place_bet and the lazy User index create accounts only on first use. +# init-if-needed: place_bet creates the Bet account only on a bettor's first bet on an outcome. anchor-lang = "2.0.0-rc.1" # The `#[program]` macro expands to `wincode` paths for instruction-data # (de)serialization, so the crate has to be a direct dependency. diff --git a/finance/betting-market/anchor/programs/betting-market/src/error.rs b/finance/betting-market/anchor/programs/betting-market/src/error.rs index a7d7c343..b81f57b0 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/error.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/error.rs @@ -22,10 +22,6 @@ pub enum BettingError { BetWon, #[msg("The bet amount must be greater than zero")] ZeroAmount, - #[msg("This bettor already holds the maximum number of open positions")] - TooManyBets, - #[msg("This bet is not in the bettor's User index")] - BetNotInUserIndex, #[msg("Arithmetic overflow")] MathOverflow, #[msg("Outcomes can only be added, and betting opened, while the event is a draft")] diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/claim_refund.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/claim_refund.rs index 854cd846..edb0f6ba 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/claim_refund.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/claim_refund.rs @@ -4,7 +4,7 @@ use crate::state::Event; use anchor_spl::mint; use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; -use crate::{error::BettingError, Bet, EventStatus, User}; +use crate::{error::BettingError, Bet, EventStatus}; use super::{transfer_tokens_from_vault, EventSigner}; @@ -37,13 +37,6 @@ pub struct ClaimRefundAccountConstraints { )] pub bet: BorshAccount, - #[account( - mut, - seeds = [b"user", bettor.address().as_ref()], - bump = user.bump, - )] - pub user: BorshAccount, - #[account( mut, associated_token::mint = token_mint, @@ -71,12 +64,6 @@ pub fn handle_claim_refund(context: &mut Context) let stake = context.accounts.bet.amount; - // The position is over, so drop the Bet from the bettor's index before the - // transfer (effects before interactions); the Bet account itself closes - // when the instruction finishes. - let bet_key = context.accounts.bet.address(); - context.accounts.user.remove_bet(bet_key)?; - // Gather the signing material before the borrow goes away. let event_signer = EventSigner::new(&context.accounts.event); // `event` signs the transfer below. Release its borrow across diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/claim_winnings.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/claim_winnings.rs index 1f5ea610..4441cdf8 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/claim_winnings.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/claim_winnings.rs @@ -4,7 +4,7 @@ use crate::state::Event; use anchor_spl::mint; use anchor_spl::token_interface::{Mint, TokenAccount, TokenInterface}; -use crate::{error::BettingError, Bet, EventStatus, User}; +use crate::{error::BettingError, Bet, EventStatus}; use super::{transfer_tokens_from_vault, EventSigner}; @@ -37,13 +37,6 @@ pub struct ClaimWinningsAccountConstraints { )] pub bet: BorshAccount, - #[account( - mut, - seeds = [b"user", bettor.address().as_ref()], - bump = user.bump, - )] - pub user: BorshAccount, - #[account( mut, associated_token::mint = token_mint, @@ -96,12 +89,6 @@ pub fn handle_claim_winnings(context: &mut Context, - - #[account( - mut, - seeds = [b"user", bettor.address().as_ref()], - bump = user.bump, - )] - pub user: BorshAccount, } pub fn handle_close_losing_bet( @@ -48,7 +40,5 @@ pub fn handle_close_losing_bet( BettingError::BetWon ); - let bet_key = context.accounts.bet.address(); - context.accounts.user.remove_bet(bet_key)?; Ok(()) } diff --git a/finance/betting-market/anchor/programs/betting-market/src/instructions/place_bet.rs b/finance/betting-market/anchor/programs/betting-market/src/instructions/place_bet.rs index 2e53ae11..85465eba 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/instructions/place_bet.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/instructions/place_bet.rs @@ -7,7 +7,7 @@ use anchor_spl::{ token_interface::{Mint, TokenAccount, TokenInterface}, }; -use crate::{error::BettingError, Bet, Config, EventStatus, Outcome, User, MAX_BETS_PER_USER}; +use crate::{error::BettingError, Bet, Config, EventStatus, Outcome}; use super::transfer_tokens_to_vault; @@ -63,15 +63,6 @@ pub struct PlaceBetAccountConstraints { )] pub bet: Box>, - #[account( - init_if_needed, - payer = bettor, - space = User::DISCRIMINATOR.len() + User::INIT_SPACE, - seeds = [b"user", bettor.address().as_ref()], - bump - )] - pub user: Box>, - pub associated_token_program: Program, pub token_program: Interface<'static, TokenInterface>, pub system_program: Program, @@ -105,9 +96,7 @@ pub fn handle_place_bet( let event_key = *context.accounts.event.address(); let outcome_key = *context.accounts.outcome.address(); let outcome_index = context.accounts.outcome.index; - let bet_key = *context.accounts.bet.address(); let bet_bump = context.bumps.bet; - let user_bump = context.bumps.user; let bet = &mut context.accounts.bet; // A fresh init_if_needed Bet has amount 0; that is how we tell a first bet @@ -143,18 +132,5 @@ pub fn handle_place_bet( .checked_add(amount) .ok_or(BettingError::MathOverflow)?; - let user = &mut context.accounts.user; - if user.authority == Address::default() { - user.authority = bettor_key; - user.bump = user_bump; - } - if is_new_bet { - require!( - user.bets.len() < MAX_BETS_PER_USER, - BettingError::TooManyBets - ); - user.bets.push(bet_key); - } - Ok(()) } diff --git a/finance/betting-market/anchor/programs/betting-market/src/lib.rs b/finance/betting-market/anchor/programs/betting-market/src/lib.rs index a64b63e5..32617846 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/lib.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/lib.rs @@ -74,13 +74,13 @@ pub mod betting_market { } // A winner withdraws their stake plus their pro-rata share of the losing - // pool. The Bet account closes and leaves the bettor's User index. + // pool. The Bet account closes. pub fn claim_winnings(context: &mut Context) -> Result<()> { instructions::claim_winnings::handle_claim_winnings(context) } // A loser closes their worthless bet after settlement, reclaiming the - // Bet account's rent and freeing the slot in their User index. + // Bet account's rent. pub fn close_losing_bet(context: &mut Context) -> Result<()> { instructions::close_losing_bet::handle_close_losing_bet(context) } @@ -91,7 +91,7 @@ pub mod betting_market { } // After a cancellation, a bettor reclaims their exact stake. The Bet - // account closes and leaves the bettor's User index. + // account closes. pub fn claim_refund(context: &mut Context) -> Result<()> { instructions::claim_refund::handle_claim_refund(context) } diff --git a/finance/betting-market/anchor/programs/betting-market/src/state/bet.rs b/finance/betting-market/anchor/programs/betting-market/src/state/bet.rs index 1b19414c..b7fe9e3b 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/state/bet.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/state/bet.rs @@ -5,6 +5,11 @@ use anchor_lang::prelude::*; // one Bet per (outcome, bettor). The account lives only while the position is // open: it closes (rent back to the bettor) on claim_winnings, claim_refund, // or close_losing_bet, which is also what prevents double claims. +// +// `bettor` is the first field, right after the 8-byte discriminator, so a +// client lists a wallet's open positions with getProgramAccounts and a memcmp +// filter on the wallet's address at offset 8. The program keeps no per-wallet +// index, so there is no limit on how many positions a wallet holds. #[account(borsh)] #[derive(InitSpace)] pub struct Bet { diff --git a/finance/betting-market/anchor/programs/betting-market/src/state/mod.rs b/finance/betting-market/anchor/programs/betting-market/src/state/mod.rs index 7d3f7a61..1dbbfc3e 100644 --- a/finance/betting-market/anchor/programs/betting-market/src/state/mod.rs +++ b/finance/betting-market/anchor/programs/betting-market/src/state/mod.rs @@ -2,10 +2,8 @@ pub mod bet; pub mod config; pub mod event; pub mod outcome; -pub mod user; pub use bet::*; pub use config::*; pub use event::*; pub use outcome::*; -pub use user::*; diff --git a/finance/betting-market/anchor/programs/betting-market/src/state/user.rs b/finance/betting-market/anchor/programs/betting-market/src/state/user.rs deleted file mode 100644 index afa3cd3f..00000000 --- a/finance/betting-market/anchor/programs/betting-market/src/state/user.rs +++ /dev/null @@ -1,38 +0,0 @@ -use anchor_lang::prelude::*; - -use crate::error::BettingError; - -// A bettor can hold at most this many OPEN positions at once (one per outcome -// they currently back). Re-betting an outcome adds to the existing Bet, and -// closing a Bet (claim_winnings, claim_refund, close_losing_bet) removes its -// entry, so this caps concurrent positions, not lifetime bets. A fixed cap -// keeps the account a constant size - no reallocation on each bet. -pub const MAX_BETS_PER_USER: usize = 32; - -// Per-wallet index of a bettor's open Bet accounts, so a client can list -// someone's positions without scanning every Bet account on the program. The -// authoritative stake state lives in the Bet accounts; this is a convenience -// index. Entries are added by place_bet and removed whenever the Bet account -// closes. -#[account(borsh)] -#[derive(InitSpace)] -pub struct User { - pub authority: Address, - #[max_len(MAX_BETS_PER_USER)] - pub bets: Vec
, - pub bump: u8, -} - -impl User { - // Drop a closed Bet's entry from the index. Order is not meaningful, so a - // swap_remove (move the last entry into the gap) is the cheapest removal. - pub fn remove_bet(&mut self, bet_key: &Address) -> Result<()> { - let index = self - .bets - .iter() - .position(|entry| entry == bet_key) - .ok_or(BettingError::BetNotInUserIndex)?; - self.bets.swap_remove(index); - Ok(()) - } -} diff --git a/finance/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs b/finance/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs index 87bf4e41..2b75b19c 100644 --- a/finance/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs +++ b/finance/betting-market/anchor/programs/betting-market/tests/test_betting_market.rs @@ -4,7 +4,7 @@ use { InstructionData, ToAccountMetas, }, anchor_v2_testing::{Keypair, LiteSVM, Signer}, - betting_market::{error::BettingError, User, MAX_BETS_PER_USER}, + betting_market::{error::BettingError, Bet}, // LiteSVM's get_sysvar / set_sysvar want the host-side Clock. solana_clock::Clock, solana_kite::{ @@ -67,10 +67,6 @@ fn bet_pda(outcome: &Address, bettor: &Address) -> Address { .0 } -fn user_pda(bettor: &Address) -> Address { - Address::find_program_address(&[b"user", bettor.as_ref()], &betting_market::id()).0 -} - struct Market { svm: LiteSVM, admin: Keypair, @@ -253,7 +249,6 @@ fn place_bet_ix( bettor_token_account: *bettor_ata, vault: derive_ata(&event, &mint), bet: bet_pda(&outcome, bettor), - user: user_pda(bettor), associated_token_program: ata_program_id(), token_program: token_program_id(), system_program: system_program::ID, @@ -311,7 +306,6 @@ fn claim_winnings_ix( token_mint: mint, event, bet: bet_pda(&outcome, bettor), - user: user_pda(bettor), bettor_token_account: *bettor_ata, vault: derive_ata(&event, &mint), token_program: token_program_id(), @@ -350,7 +344,6 @@ fn claim_refund_ix( token_mint: mint, event, bet: bet_pda(&outcome, bettor), - user: user_pda(bettor), bettor_token_account: *bettor_ata, vault: derive_ata(&event, &mint), token_program: token_program_id(), @@ -369,19 +362,29 @@ fn close_losing_bet_ix(bettor: &Address, event_id: u64, outcome_index: u8) -> In bettor: *bettor, event, bet: bet_pda(&outcome, bettor), - user: user_pda(bettor), } .to_account_metas(None), ) } -// Decode a User account so tests can assert exactly which Bet addresses the -// per-wallet index currently holds. -fn read_user_bets(market: &Market, bettor: &Address) -> Vec
{ - let account = market.svm.get_account(&user_pda(bettor)).unwrap(); - User::try_deserialize(&mut account.data.as_slice()) - .unwrap() - .bets +// A Bet account lives only while its position is open: claiming, refunding or +// closing it as a loser closes the account. +fn bet_is_open(market: &Market, bet: &Address) -> bool { + market + .svm + .get_account(bet) + .is_some_and(|account| account.lamports > 0 && !account.data.is_empty()) +} + +// A bettor's open positions are the Bet accounts whose `bettor` field is their +// address. An RPC client finds them with getProgramAccounts and a memcmp filter +// at this offset, so the program keeps no per-wallet index (and no cap on +// how many positions a wallet holds). +const BET_BETTOR_OFFSET: usize = 8; + +fn read_bet(market: &Market, bet: &Address) -> Bet { + let account = market.svm.get_account(bet).unwrap(); + Bet::try_deserialize(&mut account.data.as_slice()).unwrap() } fn init_config(market: &mut Market) { @@ -471,12 +474,14 @@ fn test_full_lifecycle() { // Vault holds the entire pool. let vault = derive_ata(&event_pda(event_id), &mint); assert_eq!(get_token_account_balance(&market.svm, &vault).unwrap(), 600); + // Alice's position is a Bet account that records her as the bettor, at + // the offset a client filters on to list her open bets. + let alice_bet_address = bet_pda(&outcome_pda(&event_pda(event_id), 0), &alice.pubkey()); + assert_eq!(read_bet(&market, &alice_bet_address).bettor, alice.pubkey()); + let alice_bet_data = market.svm.get_account(&alice_bet_address).unwrap().data; assert_eq!( - read_user_bets(&market, &alice.pubkey()), - vec![bet_pda( - &outcome_pda(&event_pda(event_id), 0), - &alice.pubkey() - )] + &alice_bet_data[BET_BETTOR_OFFSET..BET_BETTOR_OFFSET + 32], + alice.pubkey().as_ref() ); // Settle to "Yes" (index 0). Losing pool 200, fee = 2% = 4, distributable = 196. @@ -542,9 +547,16 @@ fn test_full_lifecycle() { // Pool fully distributed: 400 stakes + 196 winnings + 4 fee = 600. assert_eq!(get_token_account_balance(&market.svm, &vault).unwrap(), 0); - // Claiming closed the winners' Bet accounts and emptied their indexes. - assert!(read_user_bets(&market, &alice.pubkey()).is_empty()); - assert!(read_user_bets(&market, &bob.pubkey()).is_empty()); + // Claiming closed the winners' Bet accounts. + let winning_outcome = outcome_pda(&event_pda(event_id), 0); + assert!(!bet_is_open( + &market, + &bet_pda(&winning_outcome, &alice.pubkey()) + )); + assert!(!bet_is_open( + &market, + &bet_pda(&winning_outcome, &bob.pubkey()) + )); // Carol bet the losing outcome, so she has nothing to claim. let carol_claim = send_transaction_from_instructions( @@ -564,9 +576,9 @@ fn test_full_lifecycle() { "loser must not be able to claim winnings" ); - // Her losing position stays in the index until she closes it. + // Her losing position stays open until she closes it. let carol_bet = bet_pda(&outcome_pda(&event_pda(event_id), 1), &carol.pubkey()); - assert_eq!(read_user_bets(&market, &carol.pubkey()), vec![carol_bet]); + assert!(bet_is_open(&market, &carol_bet)); send_transaction_from_instructions( &mut market.svm, vec![close_losing_bet_ix(&carol.pubkey(), event_id, 1)], @@ -574,7 +586,7 @@ fn test_full_lifecycle() { &carol.pubkey(), ) .unwrap(); - assert!(read_user_bets(&market, &carol.pubkey()).is_empty()); + assert!(!bet_is_open(&market, &carol_bet)); } #[test] @@ -885,7 +897,7 @@ fn test_cancel_and_refund() { .unwrap(); let alice_bet = bet_pda(&outcome_pda(&event_pda(event_id), 0), &alice.pubkey()); - assert_eq!(read_user_bets(&market, &alice.pubkey()), vec![alice_bet]); + assert!(bet_is_open(&market, &alice_bet)); send_transaction_from_instructions( &mut market.svm, @@ -901,8 +913,8 @@ fn test_cancel_and_refund() { ) .unwrap(); - // The refund closed Alice's Bet account and removed it from her index. - assert!(read_user_bets(&market, &alice.pubkey()).is_empty()); + // The refund closed Alice's Bet account. + assert!(!bet_is_open(&market, &alice_bet)); send_transaction_from_instructions( &mut market.svm, @@ -1025,9 +1037,9 @@ fn test_close_losing_bet_only_after_settle_and_only_for_losers() { "a winning bet must not be closed as losing" ); let alice_bet = bet_pda(&outcome_pda(&event_pda(event_id), 0), &alice.pubkey()); - assert_eq!(read_user_bets(&market, &alice.pubkey()), vec![alice_bet]); + assert!(bet_is_open(&market, &alice_bet)); - // Carol lost; closing frees her index slot. A fresh blockhash so this is + // Carol lost; closing returns her Bet account's rent. A fresh blockhash so this is // a distinct transaction from her premature attempt above. market.svm.expire_blockhash(); send_transaction_from_instructions( @@ -1037,22 +1049,21 @@ fn test_close_losing_bet_only_after_settle_and_only_for_losers() { &carol.pubkey(), ) .unwrap(); - assert!(read_user_bets(&market, &carol.pubkey()).is_empty()); + let carol_bet = bet_pda(&outcome_pda(&event_pda(event_id), 1), &carol.pubkey()); + assert!(!bet_is_open(&market, &carol_bet)); } -// Regression test: closing a Bet must free its User index slot, so a wallet -// that fills all MAX_BETS_PER_USER slots can bet again after unwinding a -// position. Without the removal, a full index rejects every future bet on -// every market, permanently. +// A wallet can hold as many open positions as it likes: forty bets across +// forty outcomes, plus one on another market, all land. The program keeps no +// per-wallet index of open bets, so there is no list to fill. #[test] -fn test_closing_a_bet_frees_a_slot_for_a_new_bet() { +fn test_no_cap_on_open_bets_per_wallet() { const STAKE: u64 = 10; + const OUTCOME_COUNT: u8 = 40; let mut market = setup(); - let full_event_id: u64 = 7; + let wide_event_id: u64 = 7; let second_event_id: u64 = 8; - // Enough outcomes to fill the index and attempt one more bet. - let outcome_count = (MAX_BETS_PER_USER + 1) as u8; - let (alice, alice_ata) = create_bettor(&mut market, outcome_count as u64 * STAKE); + let (alice, alice_ata) = create_bettor(&mut market, (OUTCOME_COUNT as u64 + 1) * STAKE); init_config(&mut market); let admin = market.admin.pubkey(); @@ -1063,19 +1074,19 @@ fn test_closing_a_bet_frees_a_slot_for_a_new_bet() { vec![initialize_event_ix( admin, mint, - full_event_id, + wide_event_id, "Wide field", )], &[&market.admin], &admin, ) .unwrap(); - for index in 0..outcome_count { + for index in 0..OUTCOME_COUNT { send_transaction_from_instructions( &mut market.svm, vec![add_outcome_ix( admin, - full_event_id, + wide_event_id, index, &format!("Runner {index}"), )], @@ -1086,7 +1097,7 @@ fn test_closing_a_bet_frees_a_slot_for_a_new_bet() { } send_transaction_from_instructions( &mut market.svm, - vec![open_betting_ix(admin, full_event_id)], + vec![open_betting_ix(admin, wide_event_id)], &[&market.admin], &admin, ) @@ -1104,15 +1115,14 @@ fn test_closing_a_bet_frees_a_slot_for_a_new_bet() { ) .unwrap(); - // Fill every slot in Alice's index. - for index in 0..MAX_BETS_PER_USER as u8 { + for index in 0..OUTCOME_COUNT { send_transaction_from_instructions( &mut market.svm, vec![place_bet_ix( mint, &alice.pubkey(), &alice_ata, - full_event_id, + wide_event_id, index, STAKE, )], @@ -1121,30 +1131,7 @@ fn test_closing_a_bet_frees_a_slot_for_a_new_bet() { ) .unwrap(); } - assert_eq!( - read_user_bets(&market, &alice.pubkey()).len(), - MAX_BETS_PER_USER - ); - - // With the index full, any new position is rejected - on this event or another. - let one_too_many = send_transaction_from_instructions( - &mut market.svm, - vec![place_bet_ix( - mint, - &alice.pubkey(), - &alice_ata, - full_event_id, - MAX_BETS_PER_USER as u8, - STAKE, - )], - &[&alice], - &alice.pubkey(), - ); - assert!( - one_too_many.is_err(), - "a full index must reject a new position" - ); - let other_market_bet = send_transaction_from_instructions( + send_transaction_from_instructions( &mut market.svm, vec![place_bet_ix( mint, @@ -1156,67 +1143,24 @@ fn test_closing_a_bet_frees_a_slot_for_a_new_bet() { )], &[&alice], &alice.pubkey(), - ); - assert!( - other_market_bet.is_err(), - "a full index must reject bets on any market" - ); - - // Unwind one position: cancel the event and refund the first bet. - send_transaction_from_instructions( - &mut market.svm, - vec![cancel_event_ix(admin, full_event_id)], - &[&market.admin], - &admin, ) .unwrap(); - send_transaction_from_instructions( - &mut market.svm, - vec![claim_refund_ix( - mint, - &alice.pubkey(), - &alice_ata, - full_event_id, - 0, - )], - &[&alice], - &alice.pubkey(), - ) - .unwrap(); - let bets_after_refund = read_user_bets(&market, &alice.pubkey()); - assert_eq!(bets_after_refund.len(), MAX_BETS_PER_USER - 1); - let refunded_bet = bet_pda(&outcome_pda(&event_pda(full_event_id), 0), &alice.pubkey()); - assert!( - !bets_after_refund.contains(&refunded_bet), - "the refunded bet must leave the index" - ); - // The freed slot lets the wallet bet again. A fresh blockhash so this is - // a distinct transaction from the rejected attempt above. - market.svm.expire_blockhash(); - send_transaction_from_instructions( - &mut market.svm, - vec![place_bet_ix( - mint, + for index in 0..OUTCOME_COUNT { + let bet = bet_pda( + &outcome_pda(&event_pda(wide_event_id), index), &alice.pubkey(), - &alice_ata, - second_event_id, - 0, - STAKE, - )], - &[&alice], - &alice.pubkey(), - ) - .unwrap(); - let final_bets = read_user_bets(&market, &alice.pubkey()); - assert_eq!(final_bets.len(), MAX_BETS_PER_USER); - let new_bet = bet_pda( + ); + assert!(bet_is_open(&market, &bet), "bet {index} must be open"); + } + let other_market_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" + assert!(bet_is_open(&market, &other_market_bet)); + assert_eq!( + get_token_account_balance(&market.svm, &alice_ata).unwrap(), + 0 ); } diff --git a/finance/betting-market/quasar/CHANGELOG.md b/finance/betting-market/quasar/CHANGELOG.md index e82d86b9..8e643c78 100644 --- a/finance/betting-market/quasar/CHANGELOG.md +++ b/finance/betting-market/quasar/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [2026-10-03] + +### Removed + +- The per-wallet `User` index account (`seeds = [b"user", wallet]`), its + `MAX_BETS_PER_USER` cap of 32 open positions, and the `TooManyBets` and + `BetNotInUserIndex` errors. `place_bet`, `claim_winnings`, `claim_refund` and + `close_losing_bet` no longer take a `user` account. A client lists a wallet's + open bets with `getProgramAccounts` and a `memcmp` filter on `Bet.bettor` at + offset 1, so a wallet can hold any number of open positions + (`no_cap_on_open_bets_per_wallet`). Error codes after `ZeroAmount` move down by two. +- Outcome labels in the tests are invented film titles. + ## [2026-09-22] ### Changed diff --git a/finance/betting-market/quasar/README.md b/finance/betting-market/quasar/README.md index 2550a3b6..e69ea97c 100644 --- a/finance/betting-market/quasar/README.md +++ b/finance/betting-market/quasar/README.md @@ -37,7 +37,7 @@ result is known the winners divide the pool. - A winner calls `claim_winnings` to withdraw their stake plus their share of the losing pool (their stake divided by the total winning stake, times the distributable losing pool). A loser calls `close_losing_bet` to reclaim their - Bet account's rent and free a slot in their position index. + Bet account's rent. - If a market cannot be resolved, the admin calls `cancel_event` (on a draft or an open event), and every bettor reclaims their exact stake with `claim_refund`. No fee is taken. @@ -62,10 +62,9 @@ exists. pro-rata payouts when it wins. - **Bet**, PDA `["bet", outcome, bettor]`. One bettor's total stake on one outcome. Exactly one per (outcome, bettor); it closes on claim, refund, or - loser-close. -- **User**, PDA `["user", bettor]`. A per-wallet index of a bettor's open Bet - accounts, so a client can list a wallet's positions without scanning every Bet - on the program. Capped at 32 concurrent positions. + loser-close. `bettor` is the first field after the 1-byte discriminator, so a + client lists a wallet's open positions with `getProgramAccounts` and a + `memcmp` filter at offset 1; there is no per-wallet index and no cap. - **Pool vault**, PDA `["vault", event]`. One token account per event, holding every stake across all outcomes, with the Event PDA as its authority. @@ -87,11 +86,9 @@ exists. The mechanics, fee model, and payout math are identical to the Anchor build. The differences follow from Quasar being zero-copy and fixed-layout: -- **Variable-length text and the position index are fixed-capacity.** The Anchor - build stores `Event.description` and `Outcome.label` as borsh `String`s and - `User.bets` as a `Vec`. This port stores them as fixed byte buffers - plus a length (`[u8; 200]`, `[u8; 64]`, and a packed `[u8; 1024]` of up to 32 - addresses). Keeping every account fixed-size makes each mutation a plain +- **Variable-length text is fixed-capacity.** The Anchor build stores + `Event.description` and `Outcome.label` as borsh `String`s. This port stores + them as fixed byte buffers plus a length (`[u8; 200]` and `[u8; 64]`). Keeping every account fixed-size makes each mutation a plain in-place write, with no reallocation and no read-your-own-buffer aliasing when an account is updated after creation. - **The pool vault is a program-derived token account** (`["vault", event]`) diff --git a/finance/betting-market/quasar/src/errors.rs b/finance/betting-market/quasar/src/errors.rs index 2f3ff28a..bfa30e2e 100644 --- a/finance/betting-market/quasar/src/errors.rs +++ b/finance/betting-market/quasar/src/errors.rs @@ -15,8 +15,6 @@ pub enum BettingError { NothingToClaim, BetWon, ZeroAmount, - TooManyBets, - BetNotInUserIndex, MathOverflow, EventNotDraft, DescriptionTooLong, diff --git a/finance/betting-market/quasar/src/instructions/claim_refund.rs b/finance/betting-market/quasar/src/instructions/claim_refund.rs index b5b02381..2183050d 100644 --- a/finance/betting-market/quasar/src/instructions/claim_refund.rs +++ b/finance/betting-market/quasar/src/instructions/claim_refund.rs @@ -2,7 +2,7 @@ use quasar_lang::prelude::*; use quasar_spl::prelude::*; use crate::errors::BettingError; -use crate::state::{remove_bet, snapshot_user, Bet, Event, EventStatus, EventVaultPda, User}; +use crate::state::{Bet, Event, EventStatus, EventVaultPda}; use super::transfer_from_vault; @@ -26,9 +26,6 @@ pub struct ClaimRefundAccountConstraints { )] pub bet: Account, - #[account(mut, address = User::seeds(bettor.address()))] - pub user: Account, - #[account(mut)] pub bettor_token_account: Account, @@ -64,13 +61,6 @@ pub fn handle_claim_refund( let stake = u64::from(accounts.bet.amount); - // Drop the Bet from the bettor's index before the transfer (effects before - // interactions); the Bet account itself closes when the instruction ends. - let bet_key = *accounts.bet.address(); - let mut user = snapshot_user(&accounts.user); - remove_bet(&mut user.bets, &mut user.bet_count, &bet_key)?; - accounts.user.set_inner(user); - let event_id = u64::from(accounts.event.event_id); let event_bump = accounts.event.bump; transfer_from_vault( diff --git a/finance/betting-market/quasar/src/instructions/claim_winnings.rs b/finance/betting-market/quasar/src/instructions/claim_winnings.rs index 832718f8..a62d9cea 100644 --- a/finance/betting-market/quasar/src/instructions/claim_winnings.rs +++ b/finance/betting-market/quasar/src/instructions/claim_winnings.rs @@ -2,7 +2,7 @@ use quasar_lang::prelude::*; use quasar_spl::prelude::*; use crate::errors::BettingError; -use crate::state::{remove_bet, snapshot_user, Bet, Event, EventStatus, EventVaultPda, User}; +use crate::state::{Bet, Event, EventStatus, EventVaultPda}; use super::transfer_from_vault; @@ -26,9 +26,6 @@ pub struct ClaimWinningsAccountConstraints { )] pub bet: Account, - #[account(mut, address = User::seeds(bettor.address()))] - pub user: Account, - #[account(mut)] pub bettor_token_account: Account, @@ -86,13 +83,6 @@ pub fn handle_claim_winnings( .checked_add(winnings) .ok_or(BettingError::MathOverflow)?; - // Drop the Bet from the bettor's index before the transfer (effects before - // interactions); the Bet account itself closes when the instruction ends. - let bet_key = *accounts.bet.address(); - let mut user = snapshot_user(&accounts.user); - remove_bet(&mut user.bets, &mut user.bet_count, &bet_key)?; - accounts.user.set_inner(user); - let event_id = u64::from(accounts.event.event_id); let event_bump = accounts.event.bump; transfer_from_vault( diff --git a/finance/betting-market/quasar/src/instructions/close_losing_bet.rs b/finance/betting-market/quasar/src/instructions/close_losing_bet.rs index 25d32a02..c5be64ba 100644 --- a/finance/betting-market/quasar/src/instructions/close_losing_bet.rs +++ b/finance/betting-market/quasar/src/instructions/close_losing_bet.rs @@ -1,11 +1,10 @@ use quasar_lang::prelude::*; use crate::errors::BettingError; -use crate::state::{remove_bet, snapshot_user, Bet, Event, EventStatus, User}; +use crate::state::{Bet, Event, EventStatus}; -// A losing bet pays nothing, but it still occupies a slot in the bettor's User -// index and holds rent. Closing it frees the slot (so the bettor can open a new -// position) and returns the rent. Winning bets must go through claim_winnings +// A losing bet pays nothing, but its account still holds rent. Closing it +// returns the rent to the bettor. Winning bets must go through claim_winnings // instead, which also pays out the stake and winnings. #[derive(Accounts)] pub struct CloseLosingBetAccountConstraints { @@ -22,9 +21,6 @@ pub struct CloseLosingBetAccountConstraints { has_one(event), )] pub bet: Account, - - #[account(mut, address = User::seeds(bettor.address()))] - pub user: Account, } #[inline(always)] @@ -55,9 +51,5 @@ pub fn handle_close_losing_bet( BettingError::BetWon ); - let bet_key = *accounts.bet.address(); - let mut user = snapshot_user(&accounts.user); - remove_bet(&mut user.bets, &mut user.bet_count, &bet_key)?; - accounts.user.set_inner(user); Ok(()) } diff --git a/finance/betting-market/quasar/src/instructions/place_bet.rs b/finance/betting-market/quasar/src/instructions/place_bet.rs index aa8b2f37..a7783825 100644 --- a/finance/betting-market/quasar/src/instructions/place_bet.rs +++ b/finance/betting-market/quasar/src/instructions/place_bet.rs @@ -3,8 +3,8 @@ use quasar_spl::prelude::*; use crate::errors::BettingError; use crate::state::{ - add_bet, betting_is_open, snapshot_event, snapshot_outcome, snapshot_user, Bet, BetInner, - Config, Event, EventStatus, EventVaultPda, Outcome, User, + betting_is_open, snapshot_event, snapshot_outcome, Bet, BetInner, Config, Event, EventStatus, + EventVaultPda, Outcome, }; use super::transfer_to_vault; @@ -44,13 +44,6 @@ pub struct PlaceBetAccountConstraints { )] pub bet: Account, - #[account( - init(idempotent), - payer = bettor, - address = User::seeds(bettor.address()), - )] - pub user: Account, - pub rent: Sysvar, pub token_program: Program, pub system_program: Program, @@ -87,11 +80,10 @@ pub fn handle_place_bet( let event_key = *accounts.event.address(); let outcome_key = *accounts.outcome.address(); let outcome_index = accounts.outcome.index; - let bet_key = *accounts.bet.address(); // A fresh init(idempotent) Bet has amount 0; that is how we tell a first - // bet on this outcome from a top-up, and it gates the per-outcome and - // per-user bookkeeping. + // bet on this outcome from a top-up, and it gates the per-outcome + // bookkeeping. let current_amount = u64::from(accounts.bet.amount); let is_new_bet = current_amount == 0; let new_amount = current_amount @@ -127,15 +119,5 @@ pub fn handle_place_bet( .ok_or(BettingError::MathOverflow)?; accounts.event.set_inner(event); - let mut user = snapshot_user(&accounts.user); - if user.authority == Address::default() { - user.authority = bettor_key; - user.bump = bumps.user; - } - if is_new_bet { - add_bet(&mut user.bets, &mut user.bet_count, &bet_key)?; - } - accounts.user.set_inner(user); - Ok(()) } diff --git a/finance/betting-market/quasar/src/lib.rs b/finance/betting-market/quasar/src/lib.rs index 64aba874..350e7402 100644 --- a/finance/betting-market/quasar/src/lib.rs +++ b/finance/betting-market/quasar/src/lib.rs @@ -86,14 +86,14 @@ mod quasar_betting_market { } /// A winner withdraws their stake plus their pro-rata share of the losing - /// pool. The Bet account closes and leaves the bettor's User index. + /// pool. The Bet account closes. #[instruction(discriminator = 5)] pub fn claim_winnings(ctx: Ctx) -> Result<(), ProgramError> { instructions::claim_winnings::handle_claim_winnings(&mut ctx.accounts) } /// A loser closes their worthless bet after settlement, reclaiming the Bet - /// account's rent and freeing the slot in their User index. + /// account's rent. #[instruction(discriminator = 6)] pub fn close_losing_bet( ctx: Ctx, @@ -108,7 +108,7 @@ mod quasar_betting_market { } /// After a cancellation, a bettor reclaims their exact stake. The Bet - /// account closes and leaves the bettor's User index. + /// account closes. #[instruction(discriminator = 8)] pub fn claim_refund(ctx: Ctx) -> Result<(), ProgramError> { instructions::claim_refund::handle_claim_refund(&mut ctx.accounts) diff --git a/finance/betting-market/quasar/src/state/bet.rs b/finance/betting-market/quasar/src/state/bet.rs index 96abbc2e..f0f1eb96 100644 --- a/finance/betting-market/quasar/src/state/bet.rs +++ b/finance/betting-market/quasar/src/state/bet.rs @@ -8,6 +8,11 @@ pub const BET_SEED: &[u8] = b"bet"; /// open: it closes (rent back to the bettor) on claim_winnings, claim_refund, /// or close_losing_bet, which is also what prevents double claims. /// +/// `bettor` is the first field, right after the 1-byte discriminator, so a +/// client lists a wallet's open positions with getProgramAccounts and a memcmp +/// filter on the wallet's address at offset 1. The program keeps no per-wallet +/// index, so there is no limit on how many positions a wallet holds. +/// /// PDA: `["bet", outcome, bettor]`. #[account(discriminator = 4, set_inner)] #[seeds(b"bet", outcome: Address, bettor: Address)] diff --git a/finance/betting-market/quasar/src/state/mod.rs b/finance/betting-market/quasar/src/state/mod.rs index 7d3f7a61..1dbbfc3e 100644 --- a/finance/betting-market/quasar/src/state/mod.rs +++ b/finance/betting-market/quasar/src/state/mod.rs @@ -2,10 +2,8 @@ pub mod bet; pub mod config; pub mod event; pub mod outcome; -pub mod user; pub use bet::*; pub use config::*; pub use event::*; pub use outcome::*; -pub use user::*; diff --git a/finance/betting-market/quasar/src/state/user.rs b/finance/betting-market/quasar/src/state/user.rs deleted file mode 100644 index e37a9581..00000000 --- a/finance/betting-market/quasar/src/state/user.rs +++ /dev/null @@ -1,97 +0,0 @@ -use quasar_lang::prelude::*; - -use crate::errors::BettingError; - -pub const USER_SEED: &[u8] = b"user"; - -/// A bettor can hold at most this many OPEN positions at once (one per outcome -/// they currently back). Re-betting an outcome adds to the existing Bet, and -/// closing a Bet removes its entry, so this caps concurrent positions, not -/// lifetime bets. -pub const MAX_BETS_PER_USER: usize = 32; - -/// Byte length of the packed open-bet index: `MAX_BETS_PER_USER` 32-byte -/// addresses. -pub const USER_BETS_BYTES: usize = MAX_BETS_PER_USER * 32; - -/// Per-wallet index of a bettor's open Bet accounts, so a client can list a -/// wallet's positions without scanning every Bet account. The authoritative -/// stake state lives in the Bet accounts; this is a convenience index. -/// -/// The Anchor build stores the index as a borsh `Vec`. This port packs -/// the addresses into a fixed `[u8; 1024]` buffer plus `bet_count`, keeping the -/// account fixed-size (no realloc on each bet, a plain in-place `set_inner`). -/// -/// PDA: `["user", authority]`. -#[account(discriminator = 5, set_inner)] -#[seeds(b"user", authority: Address)] -pub struct User { - pub authority: Address, - pub bump: u8, - pub bet_count: u8, - pub bets: [u8; USER_BETS_BYTES], -} - -fn read_bet(bets: &[u8; USER_BETS_BYTES], index: usize) -> Address { - let start = index * 32; - let mut key = [0u8; 32]; - key.copy_from_slice(&bets[start..start + 32]); - Address::from(key) -} - -fn write_bet(bets: &mut [u8; USER_BETS_BYTES], index: usize, bet_key: &Address) { - let start = index * 32; - bets[start..start + 32].copy_from_slice(bet_key.as_ref()); -} - -fn position_of(bets: &[u8; USER_BETS_BYTES], bet_count: u8, bet_key: &Address) -> Option { - (0..bet_count as usize).find(|&index| read_bet(bets, index) == *bet_key) -} - -/// Append a Bet key. Returns `TooManyBets` when the index is full. Callers only -/// add on a genuinely new position, so a duplicate is not expected; if one -/// slips in it is a silent no-op. -pub fn add_bet( - bets: &mut [u8; USER_BETS_BYTES], - bet_count: &mut u8, - bet_key: &Address, -) -> Result<(), ProgramError> { - if position_of(bets, *bet_count, bet_key).is_some() { - return Ok(()); - } - let count = *bet_count as usize; - if count >= MAX_BETS_PER_USER { - return Err(BettingError::TooManyBets.into()); - } - write_bet(bets, count, bet_key); - *bet_count = (count + 1) as u8; - Ok(()) -} - -/// Drop a closed Bet's entry by swapping in the last entry (order within the -/// index is not meaningful). Errors if the key isn't tracked, matching the -/// Anchor build's `BetNotInUserIndex`. -pub fn remove_bet( - bets: &mut [u8; USER_BETS_BYTES], - bet_count: &mut u8, - bet_key: &Address, -) -> Result<(), ProgramError> { - let position = position_of(bets, *bet_count, bet_key).ok_or(BettingError::BetNotInUserIndex)?; - let last = *bet_count as usize - 1; - if position != last { - let moved = read_bet(bets, last); - write_bet(bets, position, &moved); - } - write_bet(bets, last, &Address::default()); - *bet_count = last as u8; - Ok(()) -} - -pub fn snapshot_user(user: &Account) -> UserInner { - UserInner { - authority: user.authority, - bump: user.bump, - bet_count: user.bet_count, - bets: user.bets, - } -} diff --git a/finance/betting-market/quasar/src/tests.rs b/finance/betting-market/quasar/src/tests.rs index a02e9992..a1438866 100644 --- a/finance/betting-market/quasar/src/tests.rs +++ b/finance/betting-market/quasar/src/tests.rs @@ -12,7 +12,7 @@ use { SettleEventInstruction, }, errors::BettingError, - state::{Bet, Config, Event, EventStatus, EventVaultPda, Outcome, User}, + state::{Bet, Config, Event, EventStatus, EventVaultPda, Outcome}, }, quasar_test::prelude::*, }; @@ -96,8 +96,6 @@ fn full_lifecycle_settles_and_pays_the_winner(test: &mut Test) { let outcome1 = test.derive_pda(Outcome::seeds(&event, 1)); let bet_a = test.derive_pda(Bet::seeds(&outcome0, &BETTOR_A)); let bet_b = test.derive_pda(Bet::seeds(&outcome1, &BETTOR_B)); - let user_a = test.derive_pda(User::seeds(&BETTOR_A)); - let user_b = test.derive_pda(User::seeds(&BETTOR_B)); const STAKE_A: u64 = 100; const STAKE_B: u64 = 300; @@ -196,10 +194,6 @@ fn full_lifecycle_settles_and_pays_the_winner(test: &mut Test) { assert_eq!(test.tokens(TOKEN_B), STARTING_TOKENS - STAKE_B + PAYOUT_B); assert_eq!(test.tokens(TOKEN_A), STARTING_TOKENS - STAKE_A); assert_eq!(test.tokens(vault), 0, "vault drained"); - - // Both bets closed; user indexes emptied. - assert_eq!(test.read::(user_a).bet_count, 0); - assert_eq!(test.read::(user_b).bet_count, 0); } /// A cancelled event refunds each bettor their exact stake. @@ -449,3 +443,32 @@ fn initialize_event_rejects_a_close_time_in_the_past(test: &mut Test) { }) .fails_with(BettingError::CloseTimeInPast); } + +/// A wallet can hold as many open positions as it likes: forty bets across +/// forty outcomes all land. The program keeps no per-wallet index of open bets, +/// so there is no list to fill. +#[quasar_test] +fn no_cap_on_open_bets_per_wallet(test: &mut Test) { + const OUTCOME_COUNT: u8 = 40; + const STAKE: u64 = 10; + base_world(test); + add_bettor(test, BETTOR_A, TOKEN_A); + let labels: Vec = (0..OUTCOME_COUNT) + .map(|index| format!("Runner {index}")) + .collect(); + let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect(); + draft_event(test, &label_refs); + test.send(open_betting(ADMIN)).succeeds(); + + let event = test.derive_pda(Event::seeds(EVENT_ID)); + for index in 0..OUTCOME_COUNT { + test.send(bet(BETTOR_A, TOKEN_A, index, STAKE)).succeeds(); + let outcome = test.derive_pda(Outcome::seeds(&event, index)); + let bet_state = test.read::(test.derive_pda(Bet::seeds(&outcome, &BETTOR_A))); + assert_eq!(bet_state.bettor, BETTOR_A, "bet {index} records its bettor"); + } + assert_eq!( + test.tokens(TOKEN_A), + STARTING_TOKENS - OUTCOME_COUNT as u64 * STAKE + ); +}