Double your BUX! Play Now →
Bankroll · Pools & LP

The pools that back every bet.

Blockster runs two independent liquidity pools — one in SOL, one in BUX — that collectively act as the house. Depositors receive LP tokens (SOL-LP, BUX-LP) whose price appreciates when bettors lose and falls when they win.

01

Overview

The pool pages live at /pool (index) and /pool/sol or /pool/bux (detail). Each detail page lets you deposit, withdraw, and watch the LP price move in real time as bets settle.

Mechanically, each pool is an AMM-style share pool without a pair. You put in SOL (or BUX), the program mints you LP tokens at the current share price, and you get back the same token — plus your slice of whatever the pool earned (or minus your slice of whatever the pool lost) — whenever you withdraw. There is nothing to swap, no impermanent loss, no external oracle.

When bettors lose, their wagers stay in the vault and the LP price rises. When bettors win, the vault pays them and the LP price falls. Over a large enough sample, the house edge (~1% on Win-All variants) ensures LPs are paid for taking the other side.

02

Dual-vault architecture

The Bankroll program maintains two fully separate vaults. They share the same program, the same pause toggle, the same registry of games — but their state, their liquidity, their LP tokens, and their accounting are entirely independent.

Vault PDAs
Vault Holds State PDA LP mint Decimals
SOL Native SOL (system-owned PDA) ["sol_vault_state"] SOL-LP 9
BUX BUX SPL tokens (token account PDA) ["bux_vault_state"] BUX-LP 9

Why separate vaults?

A player placing a SOL bet wants their win (or refund) in SOL. A player placing a BUX bet wants it in BUX. Mixing the two would require an oracle and swap logic and introduce slippage risk that has nothing to do with the game. Keeping them separate also lets LPs take explicit exposure to whichever token they care about — you can be long SOL without touching BUX, or vice versa.

The SOL vault is a SystemAccount, not a token account

The SOL vault holds native lamports. Its PDA is a SystemAccount seeded as [b"sol_vault"]. Transfers out of it happen via anchor_lang::system_program::transfer with the vault's PDA signer seeds. There is no ATA, no token program, no SPL wrapper.

The BUX vault, by contrast, is a proper SPL token account at [b"bux_token_account"]. Its authority is itself (the program signs CPIs with the same PDA that holds the tokens).

03

SOL-LP & BUX-LP LP tokens

When you deposit into a vault, the program mints you LP tokens. These are plain SPL tokens — you can keep them in your wallet, transfer them, import them into any Solana-compatible wallet. They are also the only way to withdraw from the vault.

SOL-LP — SOL pool LP
Mint PDA seeds
["bsol_mint"]
Mint authority seeds
["bsol_mint_authority"]
Decimals
9 (same as SOL)
Metadata
Created via Metaplex Token Metadata CPI (create_lp_metadata instruction)
Supply
Grows on deposit (mint), shrinks on withdrawal (burn)
BUX-LP — BUX pool LP
Mint PDA seeds
["bbux_mint"]
Mint authority seeds
["bbux_mint_authority"]
Decimals
9
Metadata
Created via Metaplex Token Metadata CPI
Supply
Grows on deposit (mint), shrinks on withdrawal (burn)
04

How the LP price is computed

LP price is not oracular, not voted on, not set by anyone. It's a pure function of two numbers: the effective balance of the vault and the current supply of the LP token. Both are read directly from on-chain state.

LP price formula
math
effective_balance = vault_balance - rent_exempt - total_liability
lp_price          = effective_balance / lp_supply
  • vault_balance — for SOL, the lamports sitting in the vault PDA. For BUX, the amount field of the vault's token account.
  • rent_exempt — the minimum lamports the vault account needs to stay rent-exempt. Deducted so we don't accidentally promise liquidity we don't actually have. Only applies to the SOL vault.
  • total_liability — the sum of max payouts for every pending bet. If bettors have bets in flight, those future payouts are already booked against the vault. Effective balance is what's left after they're all (hypothetically) paid out at their maximum.
  • lp_supply — the current supply of SOL-LP or BUX-LP, read from the mint account on-chain.

On-chain implementation

math.rs :: calculate_lp_price
rust
pub const LP_PRICE_PRECISION: u64 = 1_000_000_000; // 1e9

pub fn calculate_lp_price(effective_balance: u64, lp_supply: u64) -> Result<u64> {
    if lp_supply == 0 {
        return Ok(LP_PRICE_PRECISION); // 1.0 when pool is empty
    }
    let numerator = (effective_balance as u128)
        .checked_mul(LP_PRICE_PRECISION as u128)?;
    let price = numerator.checked_div(lp_supply as u128)?;
    Ok(price as u64) // price × 1e9, so 1_200_000_000 means 1.2
}

Why subtract liability?

Without the subtraction, the LP price would spike the instant a bet is placed (the vault balance just grew by the wager) and crash the instant it's settled as a win. Subtracting total_liability smooths that out by pre-booking the worst-case payout. The price only moves when the bet is actually resolved — up on losses, down on wins.

A worked example

Start with a SOL vault holding 100 SOL and 100 SOL-LP outstanding. LP price = 1.0. A bettor wagers 1 SOL at 1.98× on Win All 2.

  • Immediately after placement: vault holds 101 SOL, total_liability += 1.98 SOL. Effective balance = 101 − 1.98 = 99.02 SOL. LP price = 0.9902. A small dip because we've booked the worst case.
  • If the bettor loses: total_liability −= 1.98, vault still holds 101 SOL. Effective = 101. LP price = 1.01. You made 1% on the house edge.
  • If the bettor wins: vault pays 1.98 SOL (now holds 99.02), total_liability −= 1.98. Effective = 99.02. LP price = 0.9902. You're down 0.98%.

That symmetric movement — small up on losses, small down on wins — is the LP's return profile. Over thousands of bets, the house edge wins.

05

Depositing

Depositing swaps your underlying token (SOL or BUX) for LP tokens at the current price. The math is deliberately simple and matches every major AMM you've used: you get a proportional share of the pool's current value.

LP tokens minted for a deposit
math
// First-ever deposit (lp_supply == 0):
lp_to_mint = amount - MINIMUM_LIQUIDITY   // 10_000 burned forever

// Subsequent deposits:
lp_to_mint = amount × lp_supply / effective_balance

Sequence

  1. 1
    Enter an amount

    The /pool/sol or /pool/bux page previews how many LP tokens you'll receive based on the current on-chain price. That preview is computed from the exact same formula the program uses, so it's not a quote — it's the answer (subject to tiny slippage if a bet settles between your click and landing).

  2. 2
    Click deposit, sign the transaction

    The settler builds an unsigned deposit_sol or deposit_bux transaction. Your wallet signs it. If you're using Web3Auth with no SOL, the settler partial-signs as fee payer so you don't need a balance for fees.

  3. 3
    The program moves tokens and mints LP

    On-chain, the program validates amount > 0, computes the LP tokens to mint, transfers your tokens into the vault, and mints LP tokens to your ATA — creating that ATA if it doesn't exist yet.

  4. 4
    Cost basis is recorded locally

    When the transaction confirms, the LiveView records your deposit (amount + LP price at deposit time) into Mnesia's user_pool_positions table so we can show you accurate P/L later. This is local bookkeeping — the on-chain LP tokens are the source of truth for your share.

Deposit instruction accounts

instructions/deposit_sol.rs
rust
pub fn deposit_sol(ctx: Context<DepositSol>, amount: u64) -> Result<()>

Accounts:
  depositor              — Signer, mut
  game_registry          — PDA ["game_registry"]                  (paused check)
  sol_vault              — PDA ["sol_vault"], mut                 (receives SOL)
  sol_vault_state        — PDA ["sol_vault_state"], mut
  bsol_mint              — PDA ["bsol_mint"], mut
  bsol_mint_authority    — PDA ["bsol_mint_authority"]             (signer seeds)
  depositor_bsol_account — ATA (depositor, bsol_mint), init_if_needed
  system_program
  token_program
  associated_token_program
  rent

Errors: Paused, ZeroAmount, DepositTooSmall (first deposit only), MathOverflow
Emits:  SolDeposited { depositor, sol_amount, lp_minted, vault_balance, lp_supply }
06

Withdrawing

Withdrawing burns some of your LP tokens and returns a proportional slice of the underlying token. Because LP price can have moved since you deposited, you may get more or less than you put in — that's the whole point.

Underlying returned for an LP burn
math
available_balance = vault_balance - rent_exempt - total_liability
underlying_out    = lp_burned × available_balance / lp_supply

// Both must be > 0; program errors WithdrawTooLarge if underlying_out rounds to 0
// Program errors InsufficientLiquidity if underlying_out > available_balance

No withdrawal fee

There is no deposit or withdrawal fee. The house takes its edge from bet outcomes, not from LP flows. Gas (roughly 5,000 lamports) is the only cost, and in Web3Auth mode the settler can sponsor it for you.

Withdraw instruction accounts

instructions/withdraw_sol.rs
rust
pub fn withdraw_sol(ctx: Context<WithdrawSol>, lp_amount: u64) -> Result<()>

Accounts:
  withdrawer              — Signer, mut
  game_registry           — PDA ["game_registry"]                  (paused check)
  sol_vault               — PDA ["sol_vault"], mut                 (pays out SOL)
  sol_vault_state         — PDA ["sol_vault_state"], mut
  bsol_mint               — PDA ["bsol_mint"], mut
  withdrawer_bsol_account — ATA, mut                               (burned from)
  system_program
  token_program

Errors: Paused, ZeroAmount, InsufficientLiquidity, WithdrawTooLarge, MathOverflow
Emits:  SolWithdrawn { withdrawer, sol_amount, lp_burned, vault_balance, lp_supply }
07

Cost basis & unrealised P/L

The pool detail page shows your cost basis, unrealised P/L, and realised gains. These are client-side conveniences — the protocol itself doesn't track them — stored in a Mnesia table called user_pool_positions.

user_pool_positions schema
id
{user_id, vault_type} — composite primary key
total_cost
Running sum of underlying tokens you deposited (minus the cost you've realised via withdrawals)
total_lp
Running LP token balance (local copy; on-chain is canonical)
realized_gain
Cumulative gain/loss from past withdrawals
updated_at
Unix timestamp of the last deposit/withdraw

Deposit accounting (average cost basis)

pool_positions.ex :: record_deposit
elixir
def record_deposit(user_id, vault_type, amount, lp_price) do
  lp_received = amount / lp_price
  case get(user_id, vault_type) do
    nil ->
      write(user_id, vault_type, amount, lp_received, 0.0)
    %{total_cost: tc, total_lp: tl, realized_gain: rg} ->
      write(user_id, vault_type, tc + amount, tl + lp_received, rg)
  end
end

Every deposit grows your cost basis by the amount deposited and your LP balance by amount / lp_price. Your average cost per LP is always total_cost / total_lp.

Withdraw accounting (proportional removal)

pool_positions.ex :: record_withdraw
elixir
def record_withdraw(user_id, vault_type, lp_burned, lp_price) do
  %{total_cost: tc, total_lp: tl, realized_gain: rg} = get(user_id, vault_type)
  avg_cost_per_lp  = tc / tl
  cost_removed     = lp_burned * avg_cost_per_lp
  proceeds         = lp_burned * lp_price
  realized_delta   = proceeds - cost_removed      # can be negative

  new_cost = tc - cost_removed
  new_lp   = tl - lp_burned
  write(user_id, vault_type, new_cost, new_lp, rg + realized_delta)
end

When you burn LP, your cost basis drops proportionally and the difference between proceeds and the retired cost is added to realized_gain. A full withdrawal zeros out both total_cost and total_lp (floating-point residuals below 10⁻⁹ are clamped to zero).

Unrealised P/L

pool_positions.ex :: summary
math
current_value  = current_lp × current_lp_price
unrealized_pnl = current_value - total_cost
total_pnl      = unrealized_pnl + realized_gain

Pre-existing holders are seeded on first render

If you acquired SOL-LP or BUX-LP before the position tracking existed (or transferred from another wallet), you won't have a row in user_pool_positions. On your first load of the detail page we call seed_if_missing/4, which writes cost_basis = current_lp × current_lp_price and realized_gain = 0.

Translation: your P/L will read as zero on that first visit, and accrue from there. If you want true historical cost, you'd need to read your on-chain transaction history and reconstruct it manually.

08

Price history & real-time chart

Both pool detail pages display a sparkline of LP price over time, with selectable 1H / 24H / 7D / 30D / All timeframes. The data is collected by a cluster-wide GenServer and broadcast to every connected client via PubSub.

Collection

  • Periodic pollLpPriceTracker pulls /pool-stats from the settler once per minute. Writes are throttled: if the last write was <60s ago we skip.
  • Event-driven — Whenever a settle_bet is confirmed, the tracker gets a {:bet_settled, vault_type} message and writes a new price point immediately with force: true, bypassing the throttle. This is why the chart jiggles the moment a bet settles.
  • Pruning — Once per day we delete everything older than 30 days.

Downsampling

Reading "the last 30 days" at 1-minute resolution would be ~43,000 points, too many to render smoothly. Each timeframe has a bucket size; points are grouped into buckets and the last point of each bucket is returned.

Timeframe Window Bucket size
1H 3,600 s 60 s
24H 86,400 s 300 s
7D 604,800 s 1,800 s
30D 2,592,000 s 7,200 s
All unbounded 86,400 s

Below 500 points we skip downsampling entirely, so short histories look identical to raw data.

Live updates

The LiveView subscribes to pool_chart:vault_type on mount. When a new point is recorded, PubSub fires a chart_point message. The LiveView pushes a chart_update JS event, the chart hook appends it without re-fetching history.

09

Risk profile

Being an LP here is not a deposit account. It is an investment with specific, large, quantifiable risks.

1. Short-run drawdowns

A single lucky bettor can push LP price down by several percent in a few seconds. Five wins in a row at the 31.68× difficulty — each at the maximum bet — could dent the pool by a material percentage even though the expected value is strongly positive. Drawdowns of 5–10% over an hour are plausible. You have no insurance against them.

2. Smart-contract risk

Your deposit is locked in an on-chain program. If that program has a bug that lets someone drain the vault, your LP tokens go to zero with it. Our program has been reviewed (see the Security Audit), but no review eliminates this risk entirely.

3. Authority risk

The program has an authority keypair that can pause operations, register new games, and change per-game config (including multipliers). A compromised authority key could in principle push multipliers above 2×, turning every bet into a negative-EV trade for the LP. We document this fully in the audit and are actively planning to migrate authority to a multisig before mainnet. For now, LPs are trusting the authority keypair is secure.

4. Liquidity risk

Withdrawals are capped at available balance — the vault balance minus the rent buffer minus the outstanding liability. If many bets are pending simultaneously, a withdrawal might fail until those bets settle. In practice bets settle in seconds, so this is a latency issue, not a solvency one.

10

On-chain instructions used by pools

Four of the program's 22 instructions are pool-facing. They are mirror pairs across SOL and BUX — same logic, different accounts.

Instruction Signer Purpose
deposit_sol Depositor Transfer SOL into vault, mint SOL-LP to depositor's ATA
withdraw_sol Withdrawer Burn SOL-LP, transfer SOL out of vault
deposit_bux Depositor Transfer BUX into vault, mint BUX-LP to depositor's ATA
withdraw_bux Withdrawer Burn BUX-LP, transfer BUX out of vault
create_lp_metadata Authority One-time CPI to Metaplex to attach name/symbol/URI metadata to SOL-LP or BUX-LP mint

All four deposit/withdraw variants reject when game_registry.paused == true, and they don't take any game-level arguments — deposits are not associated with a specific game.