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" + ); +}