From 9d94bb6aa4906f1b0c3a0e2b926ac508d257945c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 16:50:06 +0000 Subject: [PATCH] Add Quasar port of the betting-market example Port finance/betting-market/anchor to finance/betting-market/quasar, sharing the same program ID so clients and PDA derivations work against either build, and link it in the root README example index. The parimutuel mechanics, fee model, and payout math are preserved. Quasar-specific adaptations: - Variable-length fields are fixed-capacity: Event.description ([u8; 200]), Outcome.label ([u8; 64]), and the User position index (a packed [u8; 1024] of up to 32 addresses) plus explicit lengths, instead of borsh String / Vec. Every account stays fixed-size, so each mutation is a plain in-place set_inner with no realloc. - The pool vault is a program-derived token account (["vault", event]) rather than an associated token account, matching the other Quasar finance examples. - Enums (event status) are stored as u8. Includes QuasarSVM integration tests covering the full lifecycle (open a market, add outcomes, place opposing bets, settle, claim winnings, close the losing bet), the cancel-and-refund path, and an admin-authorization rejection, plus a README. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016ATxCcgrZxaL5dMZSyWj1K --- README.md | 2 +- finance/betting-market/quasar/Cargo.toml | 36 ++ finance/betting-market/quasar/Quasar.toml | 22 + finance/betting-market/quasar/README.md | 118 ++++ finance/betting-market/quasar/src/errors.rs | 24 + .../quasar/src/instructions/add_outcome.rs | 70 +++ .../quasar/src/instructions/cancel_event.rs | 31 + .../quasar/src/instructions/claim_refund.rs | 77 +++ .../quasar/src/instructions/claim_winnings.rs | 97 +++ .../src/instructions/close_losing_bet.rs | 49 ++ .../quasar/src/instructions/create_event.rs | 82 +++ .../src/instructions/initialize_config.rs | 42 ++ .../quasar/src/instructions/mod.rs | 21 + .../quasar/src/instructions/place_bet.rs | 136 +++++ .../quasar/src/instructions/settle_event.rs | 110 ++++ .../quasar/src/instructions/shared.rs | 50 ++ finance/betting-market/quasar/src/lib.rs | 111 ++++ .../betting-market/quasar/src/state/bet.rs | 21 + .../betting-market/quasar/src/state/config.rs | 31 + .../betting-market/quasar/src/state/event.rs | 67 ++ .../betting-market/quasar/src/state/mod.rs | 11 + .../quasar/src/state/outcome.rs | 36 ++ .../betting-market/quasar/src/state/user.rs | 98 +++ finance/betting-market/quasar/src/tests.rs | 573 ++++++++++++++++++ 24 files changed, 1914 insertions(+), 1 deletion(-) create mode 100644 finance/betting-market/quasar/Cargo.toml create mode 100644 finance/betting-market/quasar/Quasar.toml create mode 100644 finance/betting-market/quasar/README.md create mode 100644 finance/betting-market/quasar/src/errors.rs create mode 100644 finance/betting-market/quasar/src/instructions/add_outcome.rs create mode 100644 finance/betting-market/quasar/src/instructions/cancel_event.rs create mode 100644 finance/betting-market/quasar/src/instructions/claim_refund.rs create mode 100644 finance/betting-market/quasar/src/instructions/claim_winnings.rs create mode 100644 finance/betting-market/quasar/src/instructions/close_losing_bet.rs create mode 100644 finance/betting-market/quasar/src/instructions/create_event.rs create mode 100644 finance/betting-market/quasar/src/instructions/initialize_config.rs create mode 100644 finance/betting-market/quasar/src/instructions/mod.rs create mode 100644 finance/betting-market/quasar/src/instructions/place_bet.rs create mode 100644 finance/betting-market/quasar/src/instructions/settle_event.rs create mode 100644 finance/betting-market/quasar/src/instructions/shared.rs create mode 100644 finance/betting-market/quasar/src/lib.rs create mode 100644 finance/betting-market/quasar/src/state/bet.rs create mode 100644 finance/betting-market/quasar/src/state/config.rs create mode 100644 finance/betting-market/quasar/src/state/event.rs create mode 100644 finance/betting-market/quasar/src/state/mod.rs create mode 100644 finance/betting-market/quasar/src/state/outcome.rs create mode 100644 finance/betting-market/quasar/src/state/user.rs create mode 100644 finance/betting-market/quasar/src/tests.rs diff --git a/README.md b/README.md index 02f94fa4..938b0dd9 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ A managed investment fund onchain, like an ETF or mutual fund. Investors deposit Parimutuel (pooled) prediction market - an admin opens an event with multiple outcomes, bettors stake tokens on an outcome, and at settlement the losing pool (minus a protocol fee) is split among winners in proportion to their stake. -[⚓ Anchor](./finance/betting-market/anchor) +[⚓ Anchor](./finance/betting-market/anchor) [💫 Quasar](./finance/betting-market/quasar) ### Perpetual Futures diff --git a/finance/betting-market/quasar/Cargo.toml b/finance/betting-market/quasar/Cargo.toml new file mode 100644 index 00000000..be8b71c6 --- /dev/null +++ b/finance/betting-market/quasar/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "quasar-betting-market" +version = "0.1.0" +edition = "2021" + +# Standalone workspace - not part of the root program-examples workspace. +# Quasar uses a different resolver and dependency tree. +[workspace] + +[lints.rust.unexpected_cfgs] +level = "warn" +check-cfg = [ + 'cfg(target_os, values("solana"))', +] + +[lib] +crate-type = ["cdylib", "lib"] + +[features] +alloc = [] +client = [] +debug = [] + +[dependencies] +# Pinned to match the finance/escrow Quasar example: 623bb70 is the last rev on +# master before a zeropod 0.3 bump that breaks quasar-spl. Unpin (back to +# branch = "master") once upstream lands the fix. +quasar-lang = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +quasar-spl = { git = "https://github.com/blueshift-gg/quasar", rev = "623bb70" } +solana-address = { version = "2.2.0" } +solana-instruction = { version = "3.2.0" } + +[dev-dependencies] +quasar-svm = { git = "https://github.com/blueshift-gg/quasar-svm" } +spl-token-interface = { version = "2.0.0" } +solana-program-pack = { version = "3.1.0" } diff --git a/finance/betting-market/quasar/Quasar.toml b/finance/betting-market/quasar/Quasar.toml new file mode 100644 index 00000000..e04d5cf2 --- /dev/null +++ b/finance/betting-market/quasar/Quasar.toml @@ -0,0 +1,22 @@ +[project] +name = "quasar_betting_market" + +[toolchain] +type = "solana" + +[testing] +language = "rust" + +[testing.rust] +framework = "quasar-svm" + +[testing.rust.test] +program = "cargo" +args = [ + "test", + "tests::", +] + +[clients] +path = "target/client" +languages = ["rust"] diff --git a/finance/betting-market/quasar/README.md b/finance/betting-market/quasar/README.md new file mode 100644 index 00000000..a8e30b4f --- /dev/null +++ b/finance/betting-market/quasar/README.md @@ -0,0 +1,118 @@ +# Betting Market, Quasar port + +A parimutuel betting market. An admin opens events (markets), adds the possible +outcomes, and later settles or cancels each one. Bettors stake a fixed token on +the outcome they think will happen; when the event is settled, the winners split +the losing side's stakes in proportion to their own, after a protocol fee. This +is the same mechanism a racetrack tote board or a prediction market runs on. + +This is a [Quasar](https://github.com/blueshift-gg/quasar) port of the Anchor +example in [`../anchor`](../anchor). Quasar is a zero-copy, `no_std`, +zero-allocation Solana framework with Anchor-like syntax. Both builds use the +same program ID (`7LyqAeLR3mK9dfj9LqxWzfKH61VVHzuNpkgW5Y32De74`), so clients and +PDA derivations work against either unchanged. + +## How a market plays out + +A market pays out parimutuel-style: there is no fixed odds and no house taking +the other side of your bet. Everyone's stake goes into one pool, and when the +result is known the winners divide the pool. + +- The **admin** (whoever ran `initialize_config`) opens an event with + `create_event`, then lists each possible result with `add_outcome`. Outcomes + can only be added before the first bet, so the field of choices can't change + under bettors who have already staked. +- A **bettor** stakes the market's token on one outcome with `place_bet`. The + stake joins the event's single pool vault. Re-betting the same outcome tops up + the existing position rather than opening a second one. +- The admin resolves the market with `settle_event`, naming the winning outcome. + The protocol fee is charged only on the losing pool, so a winner can never + receive less than they staked. The fee moves to the fee recipient immediately; + the figures winners need are recorded on the event. +- 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. +- If a market cannot be resolved, the admin calls `cancel_event`, and every + bettor reclaims their exact stake with `claim_refund`. No fee is taken. + +Closing the Bet account is what ends a position and prevents a double claim: a +second `claim_winnings` or `claim_refund` fails because the account no longer +exists. + +## Accounts and PDAs + +- **Config**, PDA `["config"]`. The single global account. Its `admin` is the + only key allowed to create, settle, and cancel events; `token_mint` fixes the + one stake asset; `fee_recipient` and `fee_bps` set the settlement fee. +- **Event**, PDA `["event", event_id]`. One market. Holds the running + `total_pool`, the status (Open, Settled, Cancelled), a fee snapshot taken at + creation, and the winning figures written at settlement. Its PDA is the token + authority of the pool vault. +- **Outcome**, PDA `["outcome", event, index]`. One possible result. + `total_amount` is this outcome's share of the pool and the denominator for + 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. +- **Pool vault**, PDA `["vault", event]`. One token account per event, holding + every stake across all outcomes, with the Event PDA as its authority. + +## Safety and custody + +- Stakes sit in the program-owned pool vault from `place_bet` until a claim or + refund. Every transfer out is signed by the Event PDA with `invoke_signed`, so + only the deployed program can move pooled funds. There is no admin path to + withdraw stakes, only to settle or cancel. +- Payouts credit and close before transferring (effects before interactions), + and the fee uses integer division that floors in the pool's favor, leaving at + most a few minor units of dust rather than ever overpaying. +- Admin-gated instructions bind the signer to `config.admin` with `has_one`, and + the winning outcome is tied to its index through the account's PDA derivation, + so a mismatched outcome can't be settled to. + +## What the Quasar port does differently + +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 + 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]`) + rather than an associated token account, matching how the other Quasar finance + examples (lending, perpetual-futures) hold pool funds. +- **Enums are stored as `u8`** (zero-copy accounts hold POD scalars). `side` and + status values match the Anchor build's byte encodings. + +## Building and testing + +Requires the [Solana toolchain](https://docs.anza.xyz/cli/install) and the +[Quasar CLI](https://github.com/blueshift-gg/quasar): + +```sh +cargo install --git https://github.com/blueshift-gg/quasar quasar-cli --locked +quasar build # compiles the program to target/deploy/quasar_betting_market.so +cargo test # QuasarSVM integration tests (they load the compiled .so) +``` + +`quasar build` must run before `cargo test`, which loads the compiled `.so` into +[QuasarSVM](https://github.com/blueshift-gg/quasar-svm), an in-process SVM. The +suite in `src/tests.rs` drives the full lifecycle (open a market, add outcomes, +place opposing bets, settle, claim the winnings, close the losing bet) and the +cancel-and-refund path, asserting on-chain state, token balances, and fee +accounting at each step, plus an admin-authorization rejection. + +## Extending + +- Per-market stake tokens instead of one deployment-wide mint. +- A minimum settlement delay so an event can't be settled the instant it opens. +- Partial cash-out of a position before settlement. +- Oracle-driven settlement instead of an admin call. diff --git a/finance/betting-market/quasar/src/errors.rs b/finance/betting-market/quasar/src/errors.rs new file mode 100644 index 00000000..aeb54977 --- /dev/null +++ b/finance/betting-market/quasar/src/errors.rs @@ -0,0 +1,24 @@ +use quasar_lang::prelude::*; + +/// Program errors. `#[error_code]` assigns the numeric codes (starting at 6000, +/// matching Anchor's base) and generates the `From for +/// ProgramError` conversion that `?` and `require!` use. +#[error_code] +pub enum BettingError { + FeeTooHigh = 6000, + Unauthorized, + EventNotOpen, + EventNotSettled, + EventNotCancelled, + OutcomeHasNoBets, + InvalidWinningOutcome, + NothingToClaim, + BetWon, + ZeroAmount, + TooManyBets, + BetNotInUserIndex, + MathOverflow, + BettingAlreadyStarted, + DescriptionTooLong, + LabelTooLong, +} diff --git a/finance/betting-market/quasar/src/instructions/add_outcome.rs b/finance/betting-market/quasar/src/instructions/add_outcome.rs new file mode 100644 index 00000000..465cfb05 --- /dev/null +++ b/finance/betting-market/quasar/src/instructions/add_outcome.rs @@ -0,0 +1,70 @@ +use quasar_lang::prelude::*; + +use crate::errors::BettingError; +use crate::state::{ + snapshot_event, Config, Event, EventStatus, Outcome, OutcomeInner, MAX_LABEL_LEN, +}; + +#[derive(Accounts)] +pub struct AddOutcomeAccountConstraints { + #[account(mut)] + pub admin: Signer, + + #[account(address = Config::seeds(), has_one(admin) @ BettingError::Unauthorized)] + pub config: Account, + + #[account(mut, address = Event::seeds(event.event_id.into()))] + pub event: Account, + + #[account( + init, + payer = admin, + address = Outcome::seeds(event.address(), event.outcome_count), + )] + pub outcome: Account, + + pub rent: Sysvar, + pub system_program: Program, +} + +#[inline(always)] +pub fn handle_add_outcome( + accounts: &mut AddOutcomeAccountConstraints, + label: &str, + bumps: &AddOutcomeAccountConstraintsBumps, +) -> Result<(), ProgramError> { + let label_bytes = label.as_bytes(); + require!(label_bytes.len() <= MAX_LABEL_LEN, BettingError::LabelTooLong); + require!( + accounts.event.status == EventStatus::Open as u8, + BettingError::EventNotOpen + ); + // Lock the outcome set once betting starts so the field of choices can't + // change out from under existing bettors. + require!( + u64::from(accounts.event.total_pool) == 0, + BettingError::BettingAlreadyStarted + ); + + let index = accounts.event.outcome_count; + let mut label_buffer = [0u8; MAX_LABEL_LEN]; + label_buffer[..label_bytes.len()].copy_from_slice(label_bytes); + + accounts.outcome.set_inner(OutcomeInner { + event: *accounts.event.address(), + index, + total_amount: 0, + bet_count: 0, + bump: bumps.outcome, + label_len: label_bytes.len() as u8, + label: label_buffer, + }); + + let mut event = snapshot_event(&accounts.event); + event.outcome_count = event + .outcome_count + .checked_add(1) + .ok_or(BettingError::MathOverflow)?; + accounts.event.set_inner(event); + Ok(()) +} diff --git a/finance/betting-market/quasar/src/instructions/cancel_event.rs b/finance/betting-market/quasar/src/instructions/cancel_event.rs new file mode 100644 index 00000000..40000efc --- /dev/null +++ b/finance/betting-market/quasar/src/instructions/cancel_event.rs @@ -0,0 +1,31 @@ +use quasar_lang::prelude::*; + +use crate::errors::BettingError; +use crate::state::{snapshot_event, Config, Event, EventStatus}; + +// Abandon an event that can't be resolved (e.g. the real-world result is void). +// Bettors then reclaim their exact stakes via `claim_refund`; no fee is taken. +#[derive(Accounts)] +pub struct CancelEventAccountConstraints { + pub admin: Signer, + + #[account(address = Config::seeds(), has_one(admin) @ BettingError::Unauthorized)] + pub config: Account, + + #[account(mut, address = Event::seeds(event.event_id.into()))] + pub event: Account, +} + +#[inline(always)] +pub fn handle_cancel_event( + accounts: &mut CancelEventAccountConstraints, +) -> Result<(), ProgramError> { + require!( + accounts.event.status == EventStatus::Open as u8, + BettingError::EventNotOpen + ); + let mut event = snapshot_event(&accounts.event); + event.status = EventStatus::Cancelled as u8; + accounts.event.set_inner(event); + Ok(()) +} diff --git a/finance/betting-market/quasar/src/instructions/claim_refund.rs b/finance/betting-market/quasar/src/instructions/claim_refund.rs new file mode 100644 index 00000000..7ca8273d --- /dev/null +++ b/finance/betting-market/quasar/src/instructions/claim_refund.rs @@ -0,0 +1,77 @@ +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 super::transfer_from_vault; + +#[derive(Accounts)] +pub struct ClaimRefundAccountConstraints { + #[account(mut)] + pub bettor: Signer, + + pub token_mint: Account, + + #[account(address = Event::seeds(event.event_id.into()))] + pub event: Account, + + // Closing the Bet ends the position: the rent goes back to the bettor and a + // second refund fails because the account no longer exists. + #[account( + mut, + close(dest = bettor), + has_one(bettor), + has_one(event), + address = Bet::seeds(&bet.outcome, bettor.address()), + )] + pub bet: Account, + + #[account(mut, address = User::seeds(bettor.address()))] + pub user: Account, + + #[account(mut)] + pub bettor_token_account: Account, + + #[account(mut, address = EventVaultPda::seeds(event.address()))] + pub vault: InterfaceAccount, + + pub token_program: Program, +} + +#[inline(always)] +pub fn handle_claim_refund( + accounts: &mut ClaimRefundAccountConstraints, +) -> Result<(), ProgramError> { + require!( + accounts.event.status == EventStatus::Cancelled as u8, + BettingError::EventNotCancelled + ); + + 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( + &accounts.token_program, + &accounts.vault, + &accounts.token_mint, + &accounts.bettor_token_account, + &accounts.event, + stake, + accounts.token_mint.decimals, + event_id, + event_bump, + )?; + + Ok(()) +} diff --git a/finance/betting-market/quasar/src/instructions/claim_winnings.rs b/finance/betting-market/quasar/src/instructions/claim_winnings.rs new file mode 100644 index 00000000..ad76d000 --- /dev/null +++ b/finance/betting-market/quasar/src/instructions/claim_winnings.rs @@ -0,0 +1,97 @@ +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 super::transfer_from_vault; + +#[derive(Accounts)] +pub struct ClaimWinningsAccountConstraints { + #[account(mut)] + pub bettor: Signer, + + pub token_mint: Account, + + #[account(address = Event::seeds(event.event_id.into()))] + pub event: Account, + + // Closing the Bet ends the position: the rent goes back to the bettor and a + // second claim fails because the account no longer exists. + #[account( + mut, + close(dest = bettor), + has_one(bettor), + has_one(event), + address = Bet::seeds(&bet.outcome, bettor.address()), + )] + pub bet: Account, + + #[account(mut, address = User::seeds(bettor.address()))] + pub user: Account, + + #[account(mut)] + pub bettor_token_account: Account, + + #[account(mut, address = EventVaultPda::seeds(event.address()))] + pub vault: InterfaceAccount, + + pub token_program: Program, +} + +#[inline(always)] +pub fn handle_claim_winnings( + accounts: &mut ClaimWinningsAccountConstraints, +) -> Result<(), ProgramError> { + require!( + accounts.event.status == EventStatus::Settled as u8, + BettingError::EventNotSettled + ); + require!( + accounts.bet.outcome_index == accounts.event.winning_outcome_index, + BettingError::NothingToClaim + ); + + let stake = u64::from(accounts.bet.amount); + let winning_pool = u64::from(accounts.event.winning_pool); + let distributable_losing_pool = u64::from(accounts.event.distributable_losing_pool); + + // Parimutuel split: winners share the losing pool in proportion to their + // own stake. Multiply in u128 and divide once (floor a single time) so no + // precision is thrown away. + let winnings: u64 = (stake as u128) + .checked_mul(distributable_losing_pool as u128) + .ok_or(BettingError::MathOverflow)? + .checked_div(winning_pool as u128) + .ok_or(BettingError::MathOverflow)? + .try_into() + .map_err(|_| BettingError::MathOverflow)?; + + // Winners always get their own stake back on top of their winnings. + let payout = stake.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( + &accounts.token_program, + &accounts.vault, + &accounts.token_mint, + &accounts.bettor_token_account, + &accounts.event, + payout, + accounts.token_mint.decimals, + event_id, + event_bump, + )?; + + Ok(()) +} diff --git a/finance/betting-market/quasar/src/instructions/close_losing_bet.rs b/finance/betting-market/quasar/src/instructions/close_losing_bet.rs new file mode 100644 index 00000000..ec59970e --- /dev/null +++ b/finance/betting-market/quasar/src/instructions/close_losing_bet.rs @@ -0,0 +1,49 @@ +use quasar_lang::prelude::*; + +use crate::errors::BettingError; +use crate::state::{remove_bet, snapshot_user, Bet, Event, EventStatus, User}; + +// 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. +#[derive(Accounts)] +pub struct CloseLosingBetAccountConstraints { + #[account(mut)] + pub bettor: Signer, + + #[account(address = Event::seeds(event.event_id.into()))] + pub event: Account, + + #[account( + mut, + close(dest = bettor), + has_one(bettor), + has_one(event), + address = Bet::seeds(&bet.outcome, bettor.address()), + )] + pub bet: Account, + + #[account(mut, address = User::seeds(bettor.address()))] + pub user: Account, +} + +#[inline(always)] +pub fn handle_close_losing_bet( + accounts: &mut CloseLosingBetAccountConstraints, +) -> Result<(), ProgramError> { + require!( + accounts.event.status == EventStatus::Settled as u8, + BettingError::EventNotSettled + ); + require!( + accounts.bet.outcome_index != accounts.event.winning_outcome_index, + 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/create_event.rs b/finance/betting-market/quasar/src/instructions/create_event.rs new file mode 100644 index 00000000..fd5dc8ee --- /dev/null +++ b/finance/betting-market/quasar/src/instructions/create_event.rs @@ -0,0 +1,82 @@ +use quasar_lang::prelude::*; +use quasar_spl::prelude::*; + +use crate::errors::BettingError; +use crate::state::{ + snapshot_config, Config, Event, EventInner, EventStatus, EventVaultPda, MAX_DESCRIPTION_LEN, +}; + +#[derive(Accounts)] +#[instruction(event_id: u64)] +pub struct CreateEventAccountConstraints { + #[account(mut)] + pub admin: Signer, + + #[account( + mut, + address = Config::seeds(), + has_one(admin) @ BettingError::Unauthorized, + has_one(token_mint), + )] + pub config: Account, + + pub token_mint: Account, + + #[account(init, payer = admin, address = Event::seeds(event_id))] + pub event: Account, + + // The single pool for the whole market: a program-derived token account + // whose authority is the Event PDA. + #[account( + init, + payer = admin, + token(mint = token_mint, authority = event, token_program = token_program), + address = EventVaultPda::seeds(event.address()), + )] + pub vault: InterfaceAccount, + + pub rent: Sysvar, + pub token_program: Program, + pub system_program: Program, +} + +#[inline(always)] +pub fn handle_create_event( + accounts: &mut CreateEventAccountConstraints, + event_id: u64, + description: &str, + bumps: &CreateEventAccountConstraintsBumps, +) -> Result<(), ProgramError> { + let description_bytes = description.as_bytes(); + require!( + description_bytes.len() <= MAX_DESCRIPTION_LEN, + BettingError::DescriptionTooLong + ); + + let mut description_buffer = [0u8; MAX_DESCRIPTION_LEN]; + description_buffer[..description_bytes.len()].copy_from_slice(description_bytes); + + let fee_bps = u16::from(accounts.config.fee_bps); + + accounts.event.set_inner(EventInner { + event_id, + outcome_count: 0, + total_pool: 0, + status: EventStatus::Open as u8, + fee_bps, + winning_outcome_index: 0, + winning_pool: 0, + distributable_losing_pool: 0, + bump: bumps.event, + description_len: description_bytes.len() as u8, + description: description_buffer, + }); + + let mut config = snapshot_config(&accounts.config); + config.event_count = config + .event_count + .checked_add(1) + .ok_or(BettingError::MathOverflow)?; + accounts.config.set_inner(config); + Ok(()) +} diff --git a/finance/betting-market/quasar/src/instructions/initialize_config.rs b/finance/betting-market/quasar/src/instructions/initialize_config.rs new file mode 100644 index 00000000..5904d8f3 --- /dev/null +++ b/finance/betting-market/quasar/src/instructions/initialize_config.rs @@ -0,0 +1,42 @@ +use quasar_lang::prelude::*; +use quasar_spl::prelude::*; + +use crate::errors::BettingError; +use crate::state::{Config, ConfigInner}; + +pub const MAX_FEE_BPS: u16 = 10_000; + +#[derive(Accounts)] +pub struct InitializeConfigAccountConstraints { + #[account(mut)] + pub admin: Signer, + + pub token_mint: Account, + + #[account(init, payer = admin, address = Config::seeds())] + pub config: Account, + + pub rent: Sysvar, + pub token_program: Program, + pub system_program: Program, +} + +#[inline(always)] +pub fn handle_initialize_config( + accounts: &mut InitializeConfigAccountConstraints, + fee_bps: u16, + fee_recipient: Address, + bumps: &InitializeConfigAccountConstraintsBumps, +) -> Result<(), ProgramError> { + require!(fee_bps <= MAX_FEE_BPS, BettingError::FeeTooHigh); + + accounts.config.set_inner(ConfigInner { + admin: *accounts.admin.address(), + token_mint: *accounts.token_mint.address(), + fee_recipient, + fee_bps, + event_count: 0, + bump: bumps.config, + }); + Ok(()) +} diff --git a/finance/betting-market/quasar/src/instructions/mod.rs b/finance/betting-market/quasar/src/instructions/mod.rs new file mode 100644 index 00000000..47ba4600 --- /dev/null +++ b/finance/betting-market/quasar/src/instructions/mod.rs @@ -0,0 +1,21 @@ +pub mod add_outcome; +pub mod cancel_event; +pub mod claim_refund; +pub mod claim_winnings; +pub mod close_losing_bet; +pub mod create_event; +pub mod initialize_config; +pub mod place_bet; +pub mod settle_event; +pub mod shared; + +pub use add_outcome::*; +pub use cancel_event::*; +pub use claim_refund::*; +pub use claim_winnings::*; +pub use close_losing_bet::*; +pub use create_event::*; +pub use initialize_config::*; +pub use place_bet::*; +pub use settle_event::*; +pub use shared::*; diff --git a/finance/betting-market/quasar/src/instructions/place_bet.rs b/finance/betting-market/quasar/src/instructions/place_bet.rs new file mode 100644 index 00000000..3f7d6001 --- /dev/null +++ b/finance/betting-market/quasar/src/instructions/place_bet.rs @@ -0,0 +1,136 @@ +use quasar_lang::prelude::*; +use quasar_spl::prelude::*; + +use crate::errors::BettingError; +use crate::state::{ + add_bet, snapshot_event, snapshot_outcome, snapshot_user, Bet, BetInner, Config, Event, + EventStatus, EventVaultPda, Outcome, User, +}; + +use super::transfer_to_vault; + +#[derive(Accounts)] +pub struct PlaceBetAccountConstraints { + #[account(mut)] + pub bettor: Signer, + + #[account(address = Config::seeds(), has_one(token_mint))] + pub config: Account, + + pub token_mint: Account, + + #[account(mut, address = Event::seeds(event.event_id.into()))] + pub event: Account, + + #[account( + mut, + has_one(event), + address = Outcome::seeds(event.address(), outcome.index), + )] + pub outcome: Account, + + #[account(mut)] + pub bettor_token_account: Account, + + #[account(mut, address = EventVaultPda::seeds(event.address()))] + pub vault: InterfaceAccount, + + // init(idempotent): the first bet on this outcome creates the Bet; a + // re-bet reuses it and tops up `amount`. + #[account( + init(idempotent), + payer = bettor, + address = Bet::seeds(outcome.address(), bettor.address()), + )] + 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, +} + +#[inline(always)] +pub fn handle_place_bet( + accounts: &mut PlaceBetAccountConstraints, + amount: u64, + bumps: &PlaceBetAccountConstraintsBumps, +) -> Result<(), ProgramError> { + require!(amount > 0, BettingError::ZeroAmount); + require!( + accounts.event.status == EventStatus::Open as u8, + BettingError::EventNotOpen + ); + + transfer_to_vault( + &accounts.token_program, + &accounts.bettor_token_account, + &accounts.token_mint, + &accounts.vault, + &accounts.bettor, + amount, + accounts.token_mint.decimals, + )?; + + let bettor_key = *accounts.bettor.address(); + 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. + let current_amount = u64::from(accounts.bet.amount); + let is_new_bet = current_amount == 0; + let new_amount = current_amount + .checked_add(amount) + .ok_or(BettingError::MathOverflow)?; + + accounts.bet.set_inner(BetInner { + bettor: bettor_key, + event: event_key, + outcome: outcome_key, + outcome_index, + amount: new_amount, + bump: bumps.bet, + }); + + let mut outcome = snapshot_outcome(&accounts.outcome); + outcome.total_amount = outcome + .total_amount + .checked_add(amount) + .ok_or(BettingError::MathOverflow)?; + if is_new_bet { + outcome.bet_count = outcome + .bet_count + .checked_add(1) + .ok_or(BettingError::MathOverflow)?; + } + accounts.outcome.set_inner(outcome); + + let mut event = snapshot_event(&accounts.event); + event.total_pool = event + .total_pool + .checked_add(amount) + .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/instructions/settle_event.rs b/finance/betting-market/quasar/src/instructions/settle_event.rs new file mode 100644 index 00000000..3b761e15 --- /dev/null +++ b/finance/betting-market/quasar/src/instructions/settle_event.rs @@ -0,0 +1,110 @@ +use quasar_lang::prelude::*; +use quasar_spl::prelude::*; + +use crate::errors::BettingError; +use crate::state::{ + snapshot_event, Config, Event, EventStatus, EventVaultPda, Outcome, +}; + +use super::transfer_from_vault; + +const BPS_DENOMINATOR: u128 = 10_000; + +#[derive(Accounts)] +#[instruction(winning_outcome_index: u8)] +pub struct SettleEventAccountConstraints { + #[account(mut)] + pub admin: Signer, + + #[account( + address = Config::seeds(), + has_one(admin) @ BettingError::Unauthorized, + has_one(token_mint), + )] + pub config: Account, + + pub token_mint: Account, + + #[account(mut, address = Event::seeds(event.event_id.into()))] + pub event: Account, + + // The `address` derivation ties this account to `winning_outcome_index`, so + // a mismatched index can't be settled to. + #[account( + has_one(event), + address = Outcome::seeds(event.address(), winning_outcome_index), + )] + pub winning_outcome: Account, + + #[account(mut, address = EventVaultPda::seeds(event.address()))] + pub vault: InterfaceAccount, + + // The fee destination. Must be a token account owned by the config's + // fee_recipient; the transfer verifies the mint. + #[account(mut)] + pub fee_recipient_token_account: Account, + + pub token_program: Program, +} + +#[inline(always)] +pub fn handle_settle_event( + accounts: &mut SettleEventAccountConstraints, + winning_outcome_index: u8, +) -> Result<(), ProgramError> { + require!( + accounts.event.status == EventStatus::Open as u8, + BettingError::EventNotOpen + ); + let winning_pool = u64::from(accounts.winning_outcome.total_amount); + require!(winning_pool > 0, BettingError::OutcomeHasNoBets); + + require_keys_eq!( + accounts.fee_recipient_token_account.owner, + accounts.config.fee_recipient, + BettingError::Unauthorized + ); + + let total_pool = u64::from(accounts.event.total_pool); + let losing_pool = total_pool + .checked_sub(winning_pool) + .ok_or(BettingError::MathOverflow)?; + + // The fee is only ever charged on the losing side, so a winner can never + // receive less than they staked. + let fee_bps = u16::from(accounts.event.fee_bps); + let fee: u64 = (losing_pool as u128) + .checked_mul(fee_bps as u128) + .ok_or(BettingError::MathOverflow)? + .checked_div(BPS_DENOMINATOR) + .ok_or(BettingError::MathOverflow)? + .try_into() + .map_err(|_| BettingError::MathOverflow)?; + let distributable_losing_pool = losing_pool + .checked_sub(fee) + .ok_or(BettingError::MathOverflow)?; + + if fee > 0 { + let event_id = u64::from(accounts.event.event_id); + let event_bump = accounts.event.bump; + transfer_from_vault( + &accounts.token_program, + &accounts.vault, + &accounts.token_mint, + &accounts.fee_recipient_token_account, + &accounts.event, + fee, + accounts.token_mint.decimals, + event_id, + event_bump, + )?; + } + + let mut event = snapshot_event(&accounts.event); + event.status = EventStatus::Settled as u8; + event.winning_outcome_index = winning_outcome_index; + event.winning_pool = winning_pool; + event.distributable_losing_pool = distributable_losing_pool; + accounts.event.set_inner(event); + Ok(()) +} diff --git a/finance/betting-market/quasar/src/instructions/shared.rs b/finance/betting-market/quasar/src/instructions/shared.rs new file mode 100644 index 00000000..1dffcd4e --- /dev/null +++ b/finance/betting-market/quasar/src/instructions/shared.rs @@ -0,0 +1,50 @@ +use quasar_lang::prelude::*; +use quasar_spl::prelude::*; + +use crate::state::EVENT_SEED; + +/// Move tokens from a wallet-owned account into a vault. The authority is a +/// plain Signer (the bettor), so no PDA seeds are needed. The token-account and +/// mint params are generic so callers can pass either `Account` or +/// `InterfaceAccount`. +#[inline(always)] +pub fn transfer_to_vault( + token_program: &Program, + from: &impl AsAccountView, + mint: &impl AsAccountView, + to: &impl AsAccountView, + authority: &Signer, + amount: u64, + decimals: u8, +) -> Result<(), ProgramError> { + token_program + .transfer_checked(from, mint, to, authority, amount, decimals) + .invoke() +} + +/// Move tokens out of an event's vault, signed by the Event PDA (the vault's +/// token authority), using the event's seeds. +#[inline(always)] +#[allow(clippy::too_many_arguments)] +pub fn transfer_from_vault( + token_program: &Program, + vault: &impl AsAccountView, + mint: &impl AsAccountView, + to: &impl AsAccountView, + event: &impl AsAccountView, + amount: u64, + decimals: u8, + event_id: u64, + event_bump: u8, +) -> Result<(), ProgramError> { + let event_id_bytes = event_id.to_le_bytes(); + let bump = [event_bump]; + let seeds = [ + Seed::from(EVENT_SEED), + Seed::from(event_id_bytes.as_ref()), + Seed::from(bump.as_ref()), + ]; + token_program + .transfer_checked(vault, mint, to, event, amount, decimals) + .invoke_signed(&seeds) +} diff --git a/finance/betting-market/quasar/src/lib.rs b/finance/betting-market/quasar/src/lib.rs new file mode 100644 index 00000000..e1fa486d --- /dev/null +++ b/finance/betting-market/quasar/src/lib.rs @@ -0,0 +1,111 @@ +#![cfg_attr(not(test), no_std)] + +use quasar_lang::prelude::*; + +pub mod errors; +pub mod instructions; +pub mod state; + +use instructions::*; + +#[cfg(test)] +mod tests; + +declare_id!("7LyqAeLR3mK9dfj9LqxWzfKH61VVHzuNpkgW5Y32De74"); + +/// Parimutuel betting market. An admin opens events, adds outcomes, and settles +/// or cancels them; bettors stake a fixed token on an outcome, and winners +/// share the losing pool (net of a protocol fee) pro-rata to their stake. See +/// README.md for the full walkthrough. +#[program] +mod quasar_betting_market { + use super::*; + + /// One-time setup: the signer becomes the admin and fixes the stake token + /// and the settlement fee (basis points) for every market in this + /// deployment. + #[instruction(discriminator = 0)] + pub fn initialize_config( + ctx: Ctx, + fee_bps: u16, + fee_recipient: Address, + ) -> Result<(), ProgramError> { + instructions::initialize_config::handle_initialize_config( + &mut ctx.accounts, + fee_bps, + fee_recipient, + &ctx.bumps, + ) + } + + /// Admin opens a new market and creates its pool vault. + #[instruction(discriminator = 1)] + pub fn create_event( + ctx: Ctx, + event_id: u64, + description: String<200>, + ) -> Result<(), ProgramError> { + instructions::create_event::handle_create_event( + &mut ctx.accounts, + event_id, + description, + &ctx.bumps, + ) + } + + /// Admin adds a possible result. Only allowed before betting starts. + #[instruction(discriminator = 2)] + pub fn add_outcome( + ctx: Ctx, + label: String<64>, + ) -> Result<(), ProgramError> { + instructions::add_outcome::handle_add_outcome(&mut ctx.accounts, label, &ctx.bumps) + } + + /// A bettor stakes tokens on one outcome. The stake joins the event's pool. + #[instruction(discriminator = 3)] + pub fn place_bet(ctx: Ctx, amount: u64) -> Result<(), ProgramError> { + instructions::place_bet::handle_place_bet(&mut ctx.accounts, amount, &ctx.bumps) + } + + /// Admin resolves the market: takes the fee from the losing pool and records + /// the figures winners need to claim their share. + #[instruction(discriminator = 4)] + pub fn settle_event( + ctx: Ctx, + winning_outcome_index: u8, + ) -> Result<(), ProgramError> { + instructions::settle_event::handle_settle_event(&mut ctx.accounts, winning_outcome_index) + } + + /// 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. + #[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. + #[instruction(discriminator = 6)] + pub fn close_losing_bet( + ctx: Ctx, + ) -> Result<(), ProgramError> { + instructions::close_losing_bet::handle_close_losing_bet(&mut ctx.accounts) + } + + /// Admin voids an unresolved market so bettors can be made whole. + #[instruction(discriminator = 7)] + pub fn cancel_event(ctx: Ctx) -> Result<(), ProgramError> { + instructions::cancel_event::handle_cancel_event(&mut ctx.accounts) + } + + /// After a cancellation, a bettor reclaims their exact stake. The Bet + /// account closes and leaves the bettor's User index. + #[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 new file mode 100644 index 00000000..96abbc2e --- /dev/null +++ b/finance/betting-market/quasar/src/state/bet.rs @@ -0,0 +1,21 @@ +use quasar_lang::prelude::*; + +pub const BET_SEED: &[u8] = b"bet"; + +/// A single bettor's total stake on one outcome. Re-betting the same outcome +/// adds to `amount` rather than creating a second account, so there is exactly +/// 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. +/// +/// PDA: `["bet", outcome, bettor]`. +#[account(discriminator = 4, set_inner)] +#[seeds(b"bet", outcome: Address, bettor: Address)] +pub struct Bet { + pub bettor: Address, + pub event: Address, + pub outcome: Address, + pub outcome_index: u8, + pub amount: u64, + pub bump: u8, +} diff --git a/finance/betting-market/quasar/src/state/config.rs b/finance/betting-market/quasar/src/state/config.rs new file mode 100644 index 00000000..f1688470 --- /dev/null +++ b/finance/betting-market/quasar/src/state/config.rs @@ -0,0 +1,31 @@ +use quasar_lang::prelude::*; + +pub const CONFIG_SEED: &[u8] = b"config"; + +/// 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. +/// +/// PDA: `["config"]`. +#[account(discriminator = 1, set_inner)] +#[seeds(b"config")] +pub struct Config { + pub admin: Address, + pub token_mint: Address, + pub fee_recipient: Address, + /// Protocol fee, in basis points, taken from the losing pool at settlement. + pub fee_bps: u16, + pub event_count: u64, + pub bump: u8, +} + +pub fn snapshot_config(config: &Account) -> ConfigInner { + ConfigInner { + admin: config.admin, + token_mint: config.token_mint, + fee_recipient: config.fee_recipient, + fee_bps: u16::from(config.fee_bps), + event_count: u64::from(config.event_count), + bump: config.bump, + } +} diff --git a/finance/betting-market/quasar/src/state/event.rs b/finance/betting-market/quasar/src/state/event.rs new file mode 100644 index 00000000..5b09a3f6 --- /dev/null +++ b/finance/betting-market/quasar/src/state/event.rs @@ -0,0 +1,67 @@ +use quasar_lang::prelude::*; + +pub const EVENT_SEED: &[u8] = b"event"; + +/// Max stored description length. The Anchor build uses a borsh +/// `String<200>`; this port stores the text in a fixed `[u8; 200]` buffer plus +/// `description_len`, keeping the account fixed-size so every post-creation +/// mutation (place_bet, settle, cancel) is a plain in-place write. +pub const MAX_DESCRIPTION_LEN: usize = 200; + +/// Lifecycle of a market. Stored on-chain as a `u8`. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +#[repr(u8)] +pub enum EventStatus { + Open = 0, + Settled = 1, + Cancelled = 2, +} + +/// One betting market. All stakes across every outcome live in a single vault +/// token account whose authority is this Event PDA, so the program signs +/// payouts with the event's seeds. +/// +/// PDA: `["event", event_id]`. +#[account(discriminator = 2, set_inner)] +#[seeds(b"event", event_id: u64)] +pub struct Event { + pub event_id: u64, + pub outcome_count: u8, + /// Sum of every stake placed across all outcomes. + pub total_pool: u64, + pub status: u8, + /// Fee snapshot taken at creation, so later Config changes can't alter a + /// market bettors have already joined. + pub fee_bps: u16, + /// Written at settlement, read at claim time. + pub winning_outcome_index: u8, + pub winning_pool: u64, + pub distributable_losing_pool: u64, + pub bump: u8, + pub description_len: u8, + pub description: [u8; MAX_DESCRIPTION_LEN], +} + +/// PDA marker for an event's single pool vault: `["vault", event]`. The Anchor +/// build uses an associated token account (ATA) owned by the Event PDA; this +/// port uses a program-derived vault instead, matching how the other Quasar +/// finance examples (lending, perpetual-futures) hold pool funds. +#[derive(Seeds)] +#[seeds(b"vault", event: Address)] +pub struct EventVaultPda; + +pub fn snapshot_event(event: &Account) -> EventInner { + EventInner { + event_id: u64::from(event.event_id), + outcome_count: event.outcome_count, + total_pool: u64::from(event.total_pool), + status: event.status, + fee_bps: u16::from(event.fee_bps), + winning_outcome_index: event.winning_outcome_index, + winning_pool: u64::from(event.winning_pool), + distributable_losing_pool: u64::from(event.distributable_losing_pool), + bump: event.bump, + description_len: event.description_len, + description: event.description, + } +} diff --git a/finance/betting-market/quasar/src/state/mod.rs b/finance/betting-market/quasar/src/state/mod.rs new file mode 100644 index 00000000..7d3f7a61 --- /dev/null +++ b/finance/betting-market/quasar/src/state/mod.rs @@ -0,0 +1,11 @@ +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/outcome.rs b/finance/betting-market/quasar/src/state/outcome.rs new file mode 100644 index 00000000..46127272 --- /dev/null +++ b/finance/betting-market/quasar/src/state/outcome.rs @@ -0,0 +1,36 @@ +use quasar_lang::prelude::*; + +pub const OUTCOME_SEED: &[u8] = b"outcome"; + +/// Max stored label length. Stored as a fixed `[u8; 64]` + `label_len` for the +/// same fixed-size reason as `Event::description`. +pub const MAX_LABEL_LEN: usize = 64; + +/// One possible result of an event (e.g. "Yes", "Team A wins"). `total_amount` +/// is this outcome's share of the pool and the denominator for pro-rata payouts +/// when this outcome wins. +/// +/// PDA: `["outcome", event, index]`. +#[account(discriminator = 3, set_inner)] +#[seeds(b"outcome", event: Address, index: u8)] +pub struct Outcome { + pub event: Address, + pub index: u8, + pub total_amount: u64, + pub bet_count: u64, + pub bump: u8, + pub label_len: u8, + pub label: [u8; MAX_LABEL_LEN], +} + +pub fn snapshot_outcome(outcome: &Account) -> OutcomeInner { + OutcomeInner { + event: outcome.event, + index: outcome.index, + total_amount: u64::from(outcome.total_amount), + bet_count: u64::from(outcome.bet_count), + bump: outcome.bump, + label_len: outcome.label_len, + label: outcome.label, + } +} diff --git a/finance/betting-market/quasar/src/state/user.rs b/finance/betting-market/quasar/src/state/user.rs new file mode 100644 index 00000000..092102c9 --- /dev/null +++ b/finance/betting-market/quasar/src/state/user.rs @@ -0,0 +1,98 @@ +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 new file mode 100644 index 00000000..726b8e4d --- /dev/null +++ b/finance/betting-market/quasar/src/tests.rs @@ -0,0 +1,573 @@ +//! QuasarSVM integration tests. They drive the real program instructions +//! end-to-end: initialize the config, open an event, add outcomes, place bets, +//! settle, and claim, asserting on-chain state and token balances at each step. +//! +//! Multi-step flows use `process_instruction_chain`, which runs several +//! instructions atomically over a shared, evolving account set. + +extern crate std; + +use { + alloc::vec, + alloc::vec::Vec, + quasar_svm::{Account, AccountMeta, Instruction, Pubkey, QuasarSvm}, + solana_program_pack::Pack, + spl_token_interface::state::{Account as TokenAccount, AccountState, Mint}, + std::println, +}; + +use crate::state::{BET_SEED, CONFIG_SEED, EVENT_SEED, OUTCOME_SEED, USER_SEED}; + +const FEE_BPS: u16 = 100; // 1% +const DECIMALS: u8 = 6; +const STARTING_LAMPORTS: u64 = 1_000_000_000; +const STARTING_TOKENS: u64 = 1_000; + +fn program_id() -> Pubkey { + Pubkey::new_from_array(crate::ID.to_bytes()) +} + +fn setup() -> QuasarSvm { + let elf = std::fs::read("target/deploy/quasar_betting_market.so").unwrap(); + QuasarSvm::new() + .with_program(&program_id(), &elf) + .with_token_program() +} + +fn rent_id() -> Pubkey { + quasar_svm::solana_sdk_ids::sysvar::rent::ID +} +fn token_program_id() -> Pubkey { + quasar_svm::SPL_TOKEN_PROGRAM_ID +} +fn system_program_id() -> Pubkey { + quasar_svm::system_program::ID +} + +fn signer_account(address: Pubkey) -> Account { + quasar_svm::token::create_keyed_system_account(&address, STARTING_LAMPORTS) +} + +fn empty_account(address: Pubkey) -> Account { + Account { + address, + lamports: 0, + data: vec![], + owner: system_program_id(), + executable: false, + } +} + +fn mint_account(address: Pubkey, authority: Pubkey) -> Account { + quasar_svm::token::create_keyed_mint_account( + &address, + &Mint { + mint_authority: Some(authority).into(), + supply: 1_000_000_000, + decimals: DECIMALS, + is_initialized: true, + freeze_authority: None.into(), + }, + ) +} + +fn token_account(address: Pubkey, mint: Pubkey, owner: Pubkey, amount: u64) -> Account { + quasar_svm::token::create_keyed_token_account( + &address, + &TokenAccount { + mint, + owner, + amount, + state: AccountState::Initialized, + ..TokenAccount::default() + }, + ) +} + +fn token_amount(account: &Account) -> u64 { + TokenAccount::unpack(&account.data).unwrap().amount +} + +fn config_pda() -> Pubkey { + Pubkey::find_program_address(&[CONFIG_SEED], &program_id()).0 +} +fn event_pda(event_id: u64) -> Pubkey { + Pubkey::find_program_address(&[EVENT_SEED, &event_id.to_le_bytes()], &program_id()).0 +} +fn vault_pda(event: &Pubkey) -> Pubkey { + Pubkey::find_program_address(&[b"vault", event.as_ref()], &program_id()).0 +} +fn outcome_pda(event: &Pubkey, index: u8) -> Pubkey { + Pubkey::find_program_address(&[OUTCOME_SEED, event.as_ref(), &[index]], &program_id()).0 +} +fn bet_pda(outcome: &Pubkey, bettor: &Pubkey) -> Pubkey { + Pubkey::find_program_address(&[BET_SEED, outcome.as_ref(), bettor.as_ref()], &program_id()).0 +} +fn user_pda(bettor: &Pubkey) -> Pubkey { + Pubkey::find_program_address(&[USER_SEED, bettor.as_ref()], &program_id()).0 +} + +// --- Instruction data builders (discriminator byte + args). Strings use +// Quasar's compact wire format: a u8 length prefix then the bytes. --- + +fn initialize_config_data(fee_bps: u16, fee_recipient: &Pubkey) -> Vec { + let mut data = vec![0u8]; + data.extend_from_slice(&fee_bps.to_le_bytes()); + data.extend_from_slice(fee_recipient.as_ref()); + data +} +fn create_event_data(event_id: u64, description: &str) -> Vec { + let mut data = vec![1u8]; + data.extend_from_slice(&event_id.to_le_bytes()); + data.push(description.len() as u8); + data.extend_from_slice(description.as_bytes()); + data +} +fn add_outcome_data(label: &str) -> Vec { + let mut data = vec![2u8]; + data.push(label.len() as u8); + data.extend_from_slice(label.as_bytes()); + data +} +fn place_bet_data(amount: u64) -> Vec { + let mut data = vec![3u8]; + data.extend_from_slice(&amount.to_le_bytes()); + data +} +fn settle_event_data(winning_outcome_index: u8) -> Vec { + vec![4u8, winning_outcome_index] +} +fn claim_winnings_data() -> Vec { + vec![5u8] +} +fn close_losing_bet_data() -> Vec { + vec![6u8] +} +fn cancel_event_data() -> Vec { + vec![7u8] +} +fn claim_refund_data() -> Vec { + vec![8u8] +} + +struct Fixture { + admin: Pubkey, + token_mint: Pubkey, + config: Pubkey, + fee_recipient: Pubkey, + fee_recipient_token: Pubkey, +} + +fn fixture() -> Fixture { + let admin = Pubkey::new_unique(); + let fee_recipient = Pubkey::new_unique(); + Fixture { + admin, + token_mint: Pubkey::new_unique(), + config: config_pda(), + fee_recipient, + fee_recipient_token: Pubkey::new_unique(), + } +} + +fn initialize_config_ix(fx: &Fixture) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(fx.admin, true), + AccountMeta::new_readonly(fx.token_mint, false), + AccountMeta::new(fx.config, false), + AccountMeta::new_readonly(rent_id(), false), + AccountMeta::new_readonly(token_program_id(), false), + AccountMeta::new_readonly(system_program_id(), false), + ], + data: initialize_config_data(FEE_BPS, &fx.fee_recipient), + } +} + +fn create_event_ix(fx: &Fixture, event_id: u64, event: &Pubkey, vault: &Pubkey) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(fx.admin, true), + AccountMeta::new(fx.config, false), + AccountMeta::new_readonly(fx.token_mint, false), + AccountMeta::new(*event, false), + AccountMeta::new(*vault, false), + AccountMeta::new_readonly(rent_id(), false), + AccountMeta::new_readonly(token_program_id(), false), + AccountMeta::new_readonly(system_program_id(), false), + ], + data: create_event_data(event_id, "Team A vs Team B"), + } +} + +fn add_outcome_ix(fx: &Fixture, event: &Pubkey, outcome: &Pubkey, label: &str) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(fx.admin, true), + AccountMeta::new_readonly(fx.config, false), + AccountMeta::new(*event, false), + AccountMeta::new(*outcome, false), + AccountMeta::new_readonly(rent_id(), false), + AccountMeta::new_readonly(system_program_id(), false), + ], + data: add_outcome_data(label), + } +} + +#[allow(clippy::too_many_arguments)] +fn place_bet_ix( + fx: &Fixture, + bettor: &Pubkey, + event: &Pubkey, + outcome: &Pubkey, + bettor_token: &Pubkey, + vault: &Pubkey, + bet: &Pubkey, + user: &Pubkey, + amount: u64, +) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(*bettor, true), + AccountMeta::new_readonly(fx.config, false), + AccountMeta::new_readonly(fx.token_mint, false), + AccountMeta::new(*event, false), + AccountMeta::new(*outcome, false), + AccountMeta::new(*bettor_token, false), + AccountMeta::new(*vault, false), + AccountMeta::new(*bet, false), + AccountMeta::new(*user, false), + AccountMeta::new_readonly(rent_id(), false), + AccountMeta::new_readonly(token_program_id(), false), + AccountMeta::new_readonly(system_program_id(), false), + ], + data: place_bet_data(amount), + } +} + +fn settle_event_ix( + fx: &Fixture, + event: &Pubkey, + winning_outcome: &Pubkey, + vault: &Pubkey, + winning_outcome_index: u8, +) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(fx.admin, true), + AccountMeta::new_readonly(fx.config, false), + AccountMeta::new_readonly(fx.token_mint, false), + AccountMeta::new(*event, false), + AccountMeta::new_readonly(*winning_outcome, false), + AccountMeta::new(*vault, false), + AccountMeta::new(fx.fee_recipient_token, false), + AccountMeta::new_readonly(token_program_id(), false), + ], + data: settle_event_data(winning_outcome_index), + } +} + +fn claim_winnings_ix( + fx: &Fixture, + bettor: &Pubkey, + event: &Pubkey, + bet: &Pubkey, + user: &Pubkey, + bettor_token: &Pubkey, + vault: &Pubkey, +) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(*bettor, true), + AccountMeta::new_readonly(fx.token_mint, false), + AccountMeta::new_readonly(*event, false), + AccountMeta::new(*bet, false), + AccountMeta::new(*user, false), + AccountMeta::new(*bettor_token, false), + AccountMeta::new(*vault, false), + AccountMeta::new_readonly(token_program_id(), false), + ], + data: claim_winnings_data(), + } +} + +fn close_losing_bet_ix( + bettor: &Pubkey, + event: &Pubkey, + bet: &Pubkey, + user: &Pubkey, +) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(*bettor, true), + AccountMeta::new_readonly(*event, false), + AccountMeta::new(*bet, false), + AccountMeta::new(*user, false), + ], + data: close_losing_bet_data(), + } +} + +fn claim_refund_ix( + fx: &Fixture, + bettor: &Pubkey, + event: &Pubkey, + bet: &Pubkey, + user: &Pubkey, + bettor_token: &Pubkey, + vault: &Pubkey, +) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new(*bettor, true), + AccountMeta::new_readonly(fx.token_mint, false), + AccountMeta::new_readonly(*event, false), + AccountMeta::new(*bet, false), + AccountMeta::new(*user, false), + AccountMeta::new(*bettor_token, false), + AccountMeta::new(*vault, false), + AccountMeta::new_readonly(token_program_id(), false), + ], + data: claim_refund_data(), + } +} + +fn cancel_event_ix(fx: &Fixture, event: &Pubkey) -> Instruction { + Instruction { + program_id: program_id(), + accounts: vec![ + AccountMeta::new_readonly(fx.admin, true), + AccountMeta::new_readonly(fx.config, false), + AccountMeta::new(*event, false), + ], + data: cancel_event_data(), + } +} + +// --- Account field offsets (dense 1-byte discriminator + tight packing). --- +const EVENT_STATUS_OFFSET: usize = 1 + 8 + 1 + 8; +const EVENT_WINNING_INDEX_OFFSET: usize = 1 + 8 + 1 + 8 + 1 + 2; +const EVENT_WINNING_POOL_OFFSET: usize = 1 + 8 + 1 + 8 + 1 + 2 + 1; +const EVENT_DISTRIBUTABLE_OFFSET: usize = 1 + 8 + 1 + 8 + 1 + 2 + 1 + 8; +// User: disc(1) authority(32) bump(1) bet_count(1) ... +const USER_BET_COUNT_OFFSET: usize = 1 + 32 + 1; + +fn read_u64(data: &[u8], offset: usize) -> u64 { + let mut bytes = [0u8; 8]; + bytes.copy_from_slice(&data[offset..offset + 8]); + u64::from_le_bytes(bytes) +} + +const STATUS_SETTLED: u8 = 1; +const STATUS_CANCELLED: u8 = 2; + +#[test] +fn test_initialize_config() { + let mut svm = setup(); + let fx = fixture(); + + let accounts = vec![ + signer_account(fx.admin), + mint_account(fx.token_mint, fx.admin), + empty_account(fx.config), + ]; + + let result = svm.process_instruction(&initialize_config_ix(&fx), &accounts); + assert!(result.is_ok(), "initialize_config failed: {:?}", result.raw_result); + + let config = result.account(&fx.config).unwrap(); + assert_eq!(config.data[0], 1, "config discriminator"); + assert_eq!(&config.data[1..33], fx.admin.as_ref(), "admin"); + assert_eq!(&config.data[33..65], fx.token_mint.as_ref(), "token_mint"); + assert_eq!(&config.data[65..97], fx.fee_recipient.as_ref(), "fee_recipient"); + assert_eq!(&config.data[97..99], &FEE_BPS.to_le_bytes(), "fee_bps"); + + println!(" INITIALIZE_CONFIG CU: {}", result.compute_units_consumed); +} + +/// Full parimutuel flow: two bettors stake on opposing outcomes, the admin +/// settles to the larger pool, the winner claims stake + share of the losing +/// pool (net of the 1% fee), and the loser closes their worthless bet. +/// +/// A stakes 100 on outcome 0; B stakes 300 on outcome 1; outcome 1 wins. +/// losing_pool = 100, fee = 1, distributable = 99. B's winnings = 300*99/300 = +/// 99, payout = 399. Fee recipient gets 1. Vault ends empty. +#[test] +fn test_full_lifecycle() { + let mut svm = setup(); + let fx = fixture(); + + let event_id = 1u64; + let event = event_pda(event_id); + let vault = vault_pda(&event); + let outcome0 = outcome_pda(&event, 0); + let outcome1 = outcome_pda(&event, 1); + + let bettor_a = Pubkey::new_unique(); + let bettor_b = Pubkey::new_unique(); + let token_a = Pubkey::new_unique(); + let token_b = Pubkey::new_unique(); + let user_a = user_pda(&bettor_a); + let user_b = user_pda(&bettor_b); + let bet_a = bet_pda(&outcome0, &bettor_a); + let bet_b = bet_pda(&outcome1, &bettor_b); + + const STAKE_A: u64 = 100; + const STAKE_B: u64 = 300; + const FEE: u64 = 1; // ceil? no - floor(100 * 100 / 10000) = 1 + const PAYOUT_B: u64 = STAKE_B + 99; // stake + winnings(99) + + let accounts = vec![ + signer_account(fx.admin), + mint_account(fx.token_mint, fx.admin), + empty_account(fx.config), + empty_account(event), + empty_account(vault), + empty_account(outcome0), + empty_account(outcome1), + signer_account(bettor_a), + token_account(token_a, fx.token_mint, bettor_a, STARTING_TOKENS), + empty_account(user_a), + empty_account(bet_a), + signer_account(bettor_b), + token_account(token_b, fx.token_mint, bettor_b, STARTING_TOKENS), + empty_account(user_b), + empty_account(bet_b), + token_account(fx.fee_recipient_token, fx.token_mint, fx.fee_recipient, 0), + ]; + + let instructions = vec![ + initialize_config_ix(&fx), + create_event_ix(&fx, event_id, &event, &vault), + add_outcome_ix(&fx, &event, &outcome0, "Team A"), + add_outcome_ix(&fx, &event, &outcome1, "Team B"), + place_bet_ix(&fx, &bettor_a, &event, &outcome0, &token_a, &vault, &bet_a, &user_a, STAKE_A), + place_bet_ix(&fx, &bettor_b, &event, &outcome1, &token_b, &vault, &bet_b, &user_b, STAKE_B), + settle_event_ix(&fx, &event, &outcome1, &vault, 1), + claim_winnings_ix(&fx, &bettor_b, &event, &bet_b, &user_b, &token_b, &vault), + close_losing_bet_ix(&bettor_a, &event, &bet_a, &user_a), + ]; + + let result = svm.process_instruction_chain(&instructions, &accounts); + assert!(result.is_ok(), "lifecycle chain failed: {:?}", result.raw_result); + + // Event settled with the recorded figures. + let event_data = &result.account(&event).unwrap().data; + assert_eq!(event_data[EVENT_STATUS_OFFSET], STATUS_SETTLED, "status settled"); + assert_eq!(event_data[EVENT_WINNING_INDEX_OFFSET], 1, "winning index"); + assert_eq!(read_u64(event_data, EVENT_WINNING_POOL_OFFSET), STAKE_B, "winning pool"); + assert_eq!(read_u64(event_data, EVENT_DISTRIBUTABLE_OFFSET), 99, "distributable"); + + // Token movements. + assert_eq!(token_amount(result.account(&fx.fee_recipient_token).unwrap()), FEE); + assert_eq!( + token_amount(result.account(&token_b).unwrap()), + STARTING_TOKENS - STAKE_B + PAYOUT_B + ); + assert_eq!(token_amount(result.account(&token_a).unwrap()), STARTING_TOKENS - STAKE_A); + assert_eq!(token_amount(result.account(&vault).unwrap()), 0, "vault drained"); + + // Both bets closed; user indexes emptied. + assert_eq!(result.account(&bet_a).map(|a| a.lamports).unwrap_or(0), 0, "bet A closed"); + assert_eq!(result.account(&bet_b).map(|a| a.lamports).unwrap_or(0), 0, "bet B closed"); + assert_eq!(result.account(&user_a).unwrap().data[USER_BET_COUNT_OFFSET], 0); + assert_eq!(result.account(&user_b).unwrap().data[USER_BET_COUNT_OFFSET], 0); + + println!(" LIFECYCLE CU: {}", result.compute_units_consumed); +} + +/// A cancelled event refunds each bettor their exact stake. +#[test] +fn test_cancel_and_refund() { + let mut svm = setup(); + let fx = fixture(); + + let event_id = 1u64; + let event = event_pda(event_id); + let vault = vault_pda(&event); + let outcome0 = outcome_pda(&event, 0); + let bettor = Pubkey::new_unique(); + let token = Pubkey::new_unique(); + let user = user_pda(&bettor); + let bet = bet_pda(&outcome0, &bettor); + + const STAKE: u64 = 250; + + let accounts = vec![ + signer_account(fx.admin), + mint_account(fx.token_mint, fx.admin), + empty_account(fx.config), + empty_account(event), + empty_account(vault), + empty_account(outcome0), + signer_account(bettor), + token_account(token, fx.token_mint, bettor, STARTING_TOKENS), + empty_account(user), + empty_account(bet), + ]; + + let instructions = vec![ + initialize_config_ix(&fx), + create_event_ix(&fx, event_id, &event, &vault), + add_outcome_ix(&fx, &event, &outcome0, "Only"), + place_bet_ix(&fx, &bettor, &event, &outcome0, &token, &vault, &bet, &user, STAKE), + cancel_event_ix(&fx, &event), + claim_refund_ix(&fx, &bettor, &event, &bet, &user, &token, &vault), + ]; + + let result = svm.process_instruction_chain(&instructions, &accounts); + assert!(result.is_ok(), "cancel/refund chain failed: {:?}", result.raw_result); + + assert_eq!(result.account(&event).unwrap().data[EVENT_STATUS_OFFSET], STATUS_CANCELLED); + // The bettor got their exact stake back and the bet closed. + assert_eq!(token_amount(result.account(&token).unwrap()), STARTING_TOKENS); + assert_eq!(token_amount(result.account(&vault).unwrap()), 0); + assert_eq!(result.account(&bet).map(|a| a.lamports).unwrap_or(0), 0, "bet closed"); + + println!(" CANCEL/REFUND CU: {}", result.compute_units_consumed); +} + +/// Only the config admin may open an event. +#[test] +fn test_create_event_rejects_non_admin() { + let mut svm = setup(); + let fx = fixture(); + let attacker = Pubkey::new_unique(); + + let event_id = 1u64; + let event = event_pda(event_id); + let vault = vault_pda(&event); + + // Init config with the real admin, then have an attacker try create_event. + let mut attacker_fx = fixture(); + attacker_fx.admin = attacker; + attacker_fx.token_mint = fx.token_mint; + attacker_fx.config = fx.config; + + let accounts = vec![ + signer_account(fx.admin), + signer_account(attacker), + mint_account(fx.token_mint, fx.admin), + empty_account(fx.config), + empty_account(event), + empty_account(vault), + ]; + + let instructions = vec![ + initialize_config_ix(&fx), + create_event_ix(&attacker_fx, event_id, &event, &vault), + ]; + let result = svm.process_instruction_chain(&instructions, &accounts); + assert!( + !result.is_ok(), + "create_event must reject a signer who is not the config admin" + ); +}