Double your BUX! Play Now →
Games · Coin Flip

Coin Flip

A Solana-native, provably-fair coin-flip game. Heads or tails, single flip or chains of up to five. Every bet is placed, committed, revealed, and settled on-chain.

01

Overview

Coin Flip lives at /play. It is the flagship game on Blockster and the first to use the on-chain bankroll. A round consists of one to five independent, 50/50 flips. You predict each flip before it happens. We pay you according to the rules of the mode you picked.

The parts to know:

  • Stake. Any amount of SOL or BUX above the minimum, up to a per-difficulty cap of roughly 0.5% of the vault.
  • Mode. Win All requires every prediction to match. Win One requires at least one.
  • Multipliers. Fixed payouts. From 1.02× (Win One, five flips, 96.9% hit rate) up to 31.68× (Win All, five flips, 3.125% hit rate). All variants are calibrated to a flat ~1% house edge.
  • Randomness. Committed on-chain as a SHA-256 hash before you bet. Revealed at settlement. Anyone can verify.
  • Custody. None. Your wager moves from your wallet to the on-chain vault when you sign; your payout moves back when we settle. We never hold it.
02

The round lifecycle

A Coin Flip round has four distinct on-chain moments. The UI stitches them together into a single experience, but it's useful to see the seams because they explain every piece of state and every signature you're asked for.

  1. 1
    Commitment submitted

    When you first land on /play, the server generates a random 32-byte server seed, computes its SHA-256 hash, and writes that hash to the Bankroll program as a commitment bound to your wallet and a nonce. This is the submit_commitment instruction. Our settler keypair signs and pays the fee; you see nothing.

  2. 2
    Bet placed

    You choose your mode, difficulty, bet size, and predictions, then click Start Game. The settler builds an unsigned place_bet_sol or place_bet_bux transaction. Your wallet signs it. When it confirms, your wager has left your wallet and is sitting in the vault; a BetOrder PDA has been created with status Pending.

  3. 3
    Result revealed

    The server now combines the server seed, a deterministic client seed built from your bet parameters, and the bet's nonce. It hashes that triple with SHA-256. The first N bytes of the hash become your N flips (0-127 = heads, 128-255 = tails). The UI animates the reveal while the settler prepares the settlement transaction.

  4. 4
    Settlement recorded

    The settler signs and submits a settle_bet transaction, passing the raw server seed as an argument. On-chain, the program computes SHA256(server_seed) and rejects the settlement unless it matches the earlier commitment. If you won, the vault sends your payout via a signed CPI. Either way the BetOrder account closes and its rent returns to the settler.

03

Win All vs Win One

The mode determines what "winning" means when you chain multiple flips. The number of flips in the chain is determined by the difficulty, which doubles as an index into the multiplier table.

Win All

The classic variant. You predict a sequence of N flips and win only if every prediction matches. Payouts roughly double each extra flip (minus the house edge), so chains of five pay 31.68× but are correct only 1/32 = 3.125% of the time.

Difficulty indices +1 through +5 correspond to Win All of 1, 2, 3, 4, and 5 flips respectively. Difficulty +1 is just a single heads/tails call at about 2×.

Win One

The "safe" variant. You predict N flips and win if any prediction matches. At least one match is very likely — the odds of zero matches across N flips are just 0.5ⁿ — so the multipliers sit close to 1× but your hit rate is close to 100%.

Difficulty indices −1 through −4 correspond to Win One of 2, 3, 4, and 5 flips respectively. Harder indices (more flips) mean a higher hit probability and a smaller multiplier.

04

Difficulty & multiplier table

Difficulty is a signed integer in the range −4 to +5. The on-chain registry stores it as an index 0–8 into a [u16; 9] array; the mapping is −4→0, −3→1, …, −1→3, +1→4, +2→5, …, +5→8. There is no zero difficulty.

Coin flip multipliers
Difficulty Mode Flips Multiplier Stored (bps) Hit prob House edge
−4 Win One 5 1.02× 10,200 96.875% ~1.2%
−3 Win One 4 1.05× 10,500 93.75% ~1.6%
−2 Win One 3 1.13× 11,300 87.5% ~1.1%
−1 Win One 2 1.32× 13,200 75.0% ~1.0%
+1 Win All 1 1.98× 19,800 50.0% ~1.0%
+2 Win All 2 3.96× 39,600 25.0% ~1.0%
+3 Win All 3 7.92× 79,200 12.5% ~1.0%
+4 Win All 4 15.84× 158,400 6.25% ~1.0%
+5 Win All 5 31.68× 316,800 3.125% ~1.0%

How the on-chain number is packed

The multipliers field of a GameEntry is a [u16; 9] and cannot hold a number above 65,535. Because 31.68× in bps is 316,800, the stored values are scaled down by 100. The program multiplies them back by 100 at read time (see MULTIPLIER_SCALE in state/game_registry.rs).

05

Bet limits

Every bet is bounded by three guards enforced on-chain. If any one fails, placement reverts and no funds move. All three are computed dynamically from the live vault state, so the limits you saw a minute ago are not necessarily the limits you see now.

Placement guards
amount ≥ min_bet
The game's configured minimum, in lamports (SOL) or base units (BUX). Stored per-game in the registry.
amount ≤ max_bet_for_difficulty
Formula: (net_vault_balance × max_bet_bps / 10000) × 20000 / multiplier_bps. Harder difficulties → lower max. At net 100 SOL, max_bet_bps 10, 1.98× multiplier, the cap is ~0.101 SOL.
potential_profit ≤ net_vault_balance
Even if your bet passes the per-difficulty cap, the program rejects it if paying you out at the max multiplier would drain more than the vault's net balance (after rent and existing liability).
math.rs :: calculate_max_bet_for_difficulty
rust
pub fn calculate_max_bet_for_difficulty(
    net_balance: u64,
    max_bet_bps: u16,
    multiplier_bps: u64,
) -> Result<u64> {
    if net_balance == 0 || multiplier_bps == 0 { return Ok(0); }
    // base_max_bet = net_balance × max_bet_bps / 10_000
    let base = (net_balance as u128)
        .checked_mul(max_bet_bps as u128)?
        .checked_div(BPS_DENOMINATOR as u128)?;
    // max_bet = base × 20_000 / multiplier_bps
    let result = base.checked_mul(20_000u128)?.checked_div(multiplier_bps as u128)?;
    Ok(result as u64)
}

The 20000 constant is intentional. It makes the maximum potential payout a constant percentage of the vault regardless of which difficulty you chose, which means the vault's solvency guarantee is independent of player behaviour.

06

Placing a bet

From your perspective there is one click and (usually) one signature. From the system's perspective it is a dance between the browser, the Elixir backend, the settler service, and the on-chain program.

Bet placement sequence
txt
[Browser]                [Phoenix LiveView]            [Settler]              [Solana]
    |                           |                          |                      |
    | start_game (click)        |                          |                      |
    |------------------------->|                          |                      |
    |                           | build_place_bet_tx        |                      |
    |                           |------------------------->|                      |
    |                           |                          | getLatestBlockhash   |
    |                           |                          |-------------------->|
    |                           |<---- unsigned tx --------|                      |
    | sign_place_bet {tx}       |                          |                      |
    |<--------------------------|                          |                      |
    | (wallet signs + sends)    |                          |                      |
    |----------------------------------------------------------> place_bet_*     |
    |                           |                          |                      |
    |                           |                          |  sig + confirmation  |
    | bet_confirmed {sig}       |                          |                      |
    |-------------------------->|                          |                      |
    |                           | reveal flips (animation) |                      |
    |                           | spawn settle_game(...)   |                      |
    |                           |------------------------->|                      |
    |                           |                          | settle_bet submitted |
    |                           |                          |-------------------->|
    |                           |                          |  sig + payout        |
    |                           |<---- sig ----------------|                      |
    | settlement_complete {sig} |                          |                      |
    |<--------------------------|                          |                      |

Who signs what

Two signatures are involved in a place_bet_* transaction: yours as the player (authorising the wager transfer) and the settler's as the rent_payer (paying the lamports to allocate the BetOrder PDA). The settler partial-signs the transaction before it's handed to your wallet.

The rent_payer identity is checked on-chain against game_registry.settler. This prevents any third party from spending our rent lamports, and also binds the BetOrder to the current settler keypair so that rent rebates on settlement only go where we expect.

Zero-SOL bettors (social login)

For users who sign in with social login (Web3Auth) and have no SOL in their wallet, the settler can be switched to full fee-payer mode. In that case the settler signs the whole transaction, pays all the fees, and you just authorise the wager transfer — never blocking new users on "go buy SOL first." This is controlled by the feePayerMode flag on the /build-place-bet endpoint.

07

Settlement

Once your bet is confirmed, the UI kicks off settlement as a fire-and-forget background task. This is important because the Coin Flip UX is optimistic: you see the flip animate and your payout credited before the settlement transaction has actually confirmed.

Happy path: fire-and-forget

Right after bet_confirmed, the LiveView spawns an unlinked Elixir process that calls the settler's /settle-bet endpoint. The settler signs a settle_bet transaction (passing server_seed, won, payout) and submits it. The LiveView gets back a settlement_complete message and writes the signature to Mnesia.

Nothing in the UI waits for this. You can start your next bet as soon as the flip animation finishes. If settlement crashes mid-flight, the banner described below catches it within 30 seconds.

Safety net: the 1-minute settler

A cluster-wide GenServer called CoinFlipBetSettler (registered as a global singleton) runs every minute. It queries Mnesia for bets with status :placed that are at least two minutes old and attempts to settle them. If the settler returns AccountNotInitialized it means the BetOrder never actually landed on-chain, and the record is marked failed so the loop stops retrying.

What settlement actually does on-chain

The settle_bet handler does three critical validations before touching any money:

  1. SHA256(server_seed) == bet_order.commitment_hash — else InvalidServerSeed.
  2. elapsed ≤ game_registry.bet_timeout (default 120s) — else OrderExpired.
  3. payout ≤ bet_order.max_payout — else PayoutExceedsMax.

If all three pass, the program transfers the payout from the vault to the player (only when won and payout is positive), updates total_liability, house_profit, total_bets, total_volume, the player's lifetime stats, and emits a BetSettled event. The BetOrder PDA is closed and its rent (~0.002 SOL) is returned to the original rent_payer — always the settler.

08

Stuck bets & reclaim

If the settler is down, or the settlement transaction fails to confirm, your wager will sit in the vault with your BetOrder account still in Pending status. After the configured timeout you — and only you — can reclaim your original wager directly from the vault.

Timing
On-chain timeout
120 seconds (game_registry.bet_timeout; settlement rejected past this)
UI banner threshold
5 minutes (300s) — gives the 1-minute settler a few retries first
Banner poll interval
30 seconds
Cluster settler interval
60 seconds, only touches bets ≥120s old

How reclaim works

When the UI detects a Pending bet older than 5 minutes, it shows a yellow banner on /play with a Reclaim button. Clicking it calls the settler's /build-reclaim-expired endpoint, which returns an unsigned transaction calling the reclaim_expired instruction. You sign it with your wallet.

On-chain, the program checks that you own the bet (has_one = player), that the bet's status is still Pending, and that the timeout has actually passed (elapsed > bet_timeout, strictly greater). If all three pass, it refunds your original wager (not the potential payout, not a fee, just the wager) and closes the BetOrder. Rent goes back to the settler.

Reclaimed bets don't count

A reclaimed bet does not increment total_bets or total_volume and doesn't touch house_profit. It only decrements total_liability and unsettled_count. An expired bet is treated as if it never happened.

09

Nonce management

The nonce is what binds a commitment to a specific bet and prevents any form of replay. Every time you bet, the program increments your player_state.nonce. The next commitment must target a nonce at least as large. A BetOrder's PDA seed includes the nonce, so two bets with the same nonce would collide on account creation.

Mnesia vs on-chain

We keep a local copy of the nonce in Mnesia so that opening /play is instant — we don't wait for an RPC round-trip. But on-chain is the source of truth. On init, we compute next = max(mnesia_next, onchain_next). If there's drift we log a warning.

If the on-chain program rejects a commitment with NonceMismatch, the UI re-initialises, which re-reads the on-chain nonce and tries again with a higher value.

Pre-committed futures

The program allows the settler to commit for any nonce greater than or equal to player_state.nonce, not just equal. That leaves room for batching or pre-committing several future seeds in advance. The current backend does not use this — each bet gets its own fresh commitment — but the capability exists on-chain.

10

On-chain instructions used by Coin Flip

Four of the Bankroll program's 22 instructions are part of the Coin Flip lifecycle. See the Smart Contracts page for the full reference; here we summarise just what Coin Flip touches.

Instruction Signers Purpose
submit_commitment Settler Write SHA256(server_seed) for a given (player, nonce) pair. Runs before every bet.
place_bet_sol / place_bet_bux Player + Settler Transfer the wager into the vault, create the BetOrder PDA, bind it to the prior commitment, clear the commitment.
settle_bet Settler Reveal server_seed, verify the hash matches, pay the player if won, close the BetOrder.
reclaim_expired Player After 120 seconds, lets the player claw their wager back if the settler never settled. Does not count the bet in stats.

submit_commitment — full signature

instructions/submit_commitment.rs
rust
pub fn submit_commitment(
    ctx: Context<SubmitCommitment>,
    player_key: Pubkey,
    nonce: u64,
    commitment_hash: [u8; 32],
) -> Result<()>

Accounts:
  settler           — Signer, mut         (must equal game_registry.settler)
  game_registry     — PDA ["game_registry"]
  player_state      — PDA ["player", player_key], init_if_needed, payer=settler
  system_program

Errors: UnauthorizedSettler, Paused, NonceMismatch (if nonce < existing nonce)
Emits:  CommitmentSubmitted { player, nonce, commitment_hash }

place_bet_sol — full signature

instructions/place_bet_sol.rs
rust
pub fn place_bet_sol(
    ctx: Context<PlaceBetSol>,
    game_id: u64,
    nonce: u64,
    amount: u64,        // lamports
    difficulty: u8,     // 0..9 index
) -> Result<()>

Accounts:
  player            — Signer, mut
  rent_payer        — Signer, mut         (must equal game_registry.settler)
  game_registry     — PDA ["game_registry"]   (paused check)
  sol_vault         — PDA ["sol_vault"],  mut  (receives wager)
  sol_vault_state   — PDA ["sol_vault_state"], mut
  player_state      — PDA ["player", player.key()], mut, has_one=player
  bet_order         — PDA ["bet", player.key(), nonce_le], init, payer=rent_payer
  system_program

Pre-checks:
  rent_payer == game_registry.settler               → InvalidRentPayer
  game_id is registered and game.active             → GameNotFound
  nonce == player_state.pending_nonce               → NonceMismatch
  player_state.pending_commitment != [0u8; 32]      → NoCommitment
  amount >= game.min_bet                            → BetTooSmall
  amount <= calculate_max_bet_for_difficulty(...)   → BetTooLarge
  max_payout - amount <= net_balance                → MaxPayoutExceedsCapacity

Side effects:
  · transfer SOL from player to sol_vault (amount)
  · create BetOrder with status Pending and commitment_hash copied from player_state
  · clear player_state.pending_commitment
  · increment player_state.nonce
  · update sol_vault_state (total_liability, unsettled_count, unsettled_bets)
  · emit BetPlaced

settle_bet — full signature

instructions/settle_bet.rs
rust
pub fn settle_bet(
    ctx: Context<SettleBet>,
    nonce: u64,
    server_seed: [u8; 32],
    won: bool,
    payout: u64,
) -> Result<()>

Signer:  settler (constraint == game_registry.settler)
Closes:  bet_order → rent_payer

Pre-checks:
  SHA256(server_seed) == bet_order.commitment_hash  → InvalidServerSeed
  unix_now - bet_order.created_at <= bet_timeout    → OrderExpired
  payout <= bet_order.max_payout                    → PayoutExceedsMax
  bet_order.status == Pending                       → OrderNotPending

Side effects:
  if won and payout > 0:
    CPI transfer payout from vault to player (signed by vault PDA)
  else:
    process_*_referral_rewards (best-effort, emits RewardPaid/Failed)

  update vault stats (total_liability, house_profit, total_bets, ...)
  update player_state (total_orders, total_wagered_*, net_pnl_*)
  close bet_order → rent_payer
  emit BetSettled { player, nonce, won, amount, payout, vault_type, server_seed }

reclaim_expired — full signature

instructions/reclaim_expired.rs
rust
pub fn reclaim_expired(ctx: Context<ReclaimExpired>, nonce: u64) -> Result<()>

Signer:  player
Closes:  bet_order → rent_payer (the original settler)

Pre-checks:
  bet_order.player == player.key()                  → InvalidServerSeed (misleading name)
  bet_order.rent_payer matches stored pubkey        → InvalidRentPayer
  bet_order.status == Pending                       → OrderNotPending
  unix_now - bet_order.created_at > bet_timeout     → OrderNotExpired

Side effects:
  refund bet_order.amount from vault to player (SOL or BUX)
  decrement vault_state.total_liability by max_payout
  decrement vault_state.unsettled_count, unsettled_bets
  close bet_order → rent_payer
  emit BetReclaimed { player, nonce, amount, vault_type }
  · total_bets and house_profit are NOT touched — expired bets don't count
11

Edge cases

The UI deducted my balance but the bet failed

The LiveView deducts your local balance the instant the wallet returns bet_confirmed, before settlement lands. If the settlement transaction reverts, the local balance is refreshed from chain during the next balance sync (usually within 30 seconds). Your on-chain balance is always correct.

Settlement kicks off before the reveal animation ends

It does, intentionally. The settlement tx typically confirms within one slot (~400ms). Letting the animation run concurrently gives us a smoother feel without extending the wait. If settlement fails mid-animation, the UI silently swallows it and the 1-minute settler takes over.

Floating-point precision

The Elixir backend uses trunc/1 (not Float.round/1) to compute the payout it submits to settle_bet. On-chain, the program uses u64 integer arithmetic. If those two diverged, the on-chain check payout ≤ max_payout would fail. We match truncation behaviour deliberately to keep them aligned.

What if you double-click Start Game?

The first click consumes the pending commitment (the program clears pending_commitment on placement). The second click would hit NoCommitment on-chain. The UI disables the button while the first tx is in-flight as a UX belt; the program provides the suspenders.

12

FAQ

Why is there a house edge?

Without one, the pool could not pay its costs and would drift to zero in expectation. Our edge is small (around 1% on Win All variants) because we care more about volume and fairness than about margin per bet.

Can the settler see the server seed before I bet?

It generated the seed. The point of the commit-reveal scheme is not that you trust the settler didn't know the seed — it's that the settler committed to one specific seed on-chain before you bet, and the program will not let it reveal a different seed later. The seed is fixed the moment submit_commitment lands.

What happens during a program pause?

If the authority toggles the global paused flag, submit_commitment and place_bet_* start reverting with Paused. But settle_bet and reclaim_expired still work — so any bet you have in flight can still be resolved. This is intentional.

Is there a maximum I can win in a day?

There is no per-day cap, but the program's per-bet cap (potential_profit ≤ net_balance) means you can never drain the vault in a single bet. The max-bet formula is recomputed on every placement from the live vault balance.