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.
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.
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 | 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).
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.
- 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)
- 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)
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.
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
amountfield 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
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.
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.
// 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
-
1Enter an amount
The
/pool/solor/pool/buxpage 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). -
2Click deposit, sign the transaction
The settler builds an unsigned
deposit_solordeposit_buxtransaction. 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. -
3The 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. -
4Cost basis is recorded locally
When the transaction confirms, the LiveView records your deposit (amount + LP price at deposit time) into Mnesia's
user_pool_positionstable 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
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 }
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.
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
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 }
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.
- 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)
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)
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
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.
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 poll —
LpPriceTrackerpulls/pool-statsfrom the settler once per minute. Writes are throttled: if the last write was <60s ago we skip. - Event-driven — Whenever a
settle_betis confirmed, the tracker gets a{:bet_settled, vault_type}message and writes a new price point immediately withforce: 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.
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.
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.