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.
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.
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.
-
1Commitment 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 thesubmit_commitmentinstruction. Our settler keypair signs and pays the fee; you see nothing. -
2Bet placed
You choose your mode, difficulty, bet size, and predictions, then click Start Game. The settler builds an unsigned
place_bet_solorplace_bet_buxtransaction. 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. -
3Result 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.
-
4Settlement recorded
The settler signs and submits a
settle_bettransaction, passing the raw server seed as an argument. On-chain, the program computesSHA256(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.
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.
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.
| 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).
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.
- 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).
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.
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.
[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.
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:
SHA256(server_seed) == bet_order.commitment_hash— elseInvalidServerSeed.elapsed ≤ game_registry.bet_timeout(default 120s) — elseOrderExpired.payout ≤ bet_order.max_payout— elsePayoutExceedsMax.
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.
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.
- 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.
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.
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
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
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
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
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
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.
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.