Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
36 changes: 36 additions & 0 deletions finance/betting-market/quasar/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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" }
22 changes: 22 additions & 0 deletions finance/betting-market/quasar/Quasar.toml
Original file line number Diff line number Diff line change
@@ -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"]
118 changes: 118 additions & 0 deletions finance/betting-market/quasar/README.md
Original file line number Diff line number Diff line change
@@ -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<Pubkey>`. 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.
24 changes: 24 additions & 0 deletions finance/betting-market/quasar/src/errors.rs
Original file line number Diff line number Diff line change
@@ -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<BettingError> 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,
}
70 changes: 70 additions & 0 deletions finance/betting-market/quasar/src/instructions/add_outcome.rs
Original file line number Diff line number Diff line change
@@ -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<Config>,

#[account(mut, address = Event::seeds(event.event_id.into()))]
pub event: Account<Event>,

#[account(
init,
payer = admin,
address = Outcome::seeds(event.address(), event.outcome_count),
)]
pub outcome: Account<Outcome>,

pub rent: Sysvar<Rent>,
pub system_program: Program<SystemProgram>,
}

#[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(())
}
31 changes: 31 additions & 0 deletions finance/betting-market/quasar/src/instructions/cancel_event.rs
Original file line number Diff line number Diff line change
@@ -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<Config>,

#[account(mut, address = Event::seeds(event.event_id.into()))]
pub event: Account<Event>,
}

#[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(())
}
77 changes: 77 additions & 0 deletions finance/betting-market/quasar/src/instructions/claim_refund.rs
Original file line number Diff line number Diff line change
@@ -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<Mint>,

#[account(address = Event::seeds(event.event_id.into()))]
pub event: Account<Event>,

// 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<Bet>,

#[account(mut, address = User::seeds(bettor.address()))]
pub user: Account<User>,

#[account(mut)]
pub bettor_token_account: Account<Token>,

#[account(mut, address = EventVaultPda::seeds(event.address()))]
pub vault: InterfaceAccount<Token>,

pub token_program: Program<TokenProgram>,
}

#[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(())
}
Loading
Loading