How flip.bet works, and why it's safe
Two players. One pot. A coin flips. Everything that decides the outcome — the random number, the formula that turns it into heads or tails, the escrow account holding the funds — is on chain. The site operator has no input that affects who wins, and physically cannot.
98%
Winner takes
of the pot
2%
Total fee
1% DAO + 1% maintenance
~1s
Settle time
Switchboard VRF
0
Operator edge
enforced in code
flip.bet is a peer-to-peer, community-owned coin flip on Solana. There's no house. The two players who agreed to play put up matching wagers, a coin flips, and the winner walks away with 98% of the pot. The remaining 2% is split between a community treasury (1%) controlled by $FLIP token holders via on-chain governance and a maintenance multisig (1%) — both separate from the operator and either DAO-voted or multi-sig-gated to spend.
The crucial difference from a centralized site is that nobody — including us — can influence the outcome. The randomness comes from Switchboard's verifiable random function (VRF), the formula that turns it into heads/tails is fixed Rust code, and the money lives in a program-owned escrow account that the operator cannot touch. We explain all of this below.
One sentence summary
Every match goes through a strict on-chain state machine. There are no off-chain "trust us" steps in between.
Funds live in a program-owned PDA the entire time. The site operator is not a party in any transfer.
A player creates a challenge
create_match_sol with their wager, the side they pick (heads/tails), and a 32-byte client seed. Their wager is transferred into a new Match PDA — a program-derived address whose private key literally does not exist; only the program can move funds out of it.Another player joins
join_match_sol deposits a matching wager and contributes their own client seed. The match transitions to Joined.Either player requests randomness
request_randomness binds a Switchboard On-Demand VRF account to the match and records the slot at which the request went on chain. The match is now in AwaitingRandomness.The reveal is consumed and the match settles
settle. The program reads the VRF output, mixes in both client seeds with SHA-256, derives heads or tails, pays out the fees to the two treasuries, and locks the winner's payout in the same Match PDA.Winner claims (or offers double-or-nothing)
claim_winnings to release the payout to their wallet.v1 supports SOL as the wager asset. The Anchor program is structured so that an SPL-token variant of create_match/join_match drops in cleanly, gated by an admin-controlled allowlist. $FLIP (the project's Pump.fun-launched token) is the first SPL token that will be whitelisted, with its own community-treasury PDA per mint.
"Provably fair" is a fingerprint-able promise: given the inputs of a game, anyone can independently re-derive the outcome and confirm it matches what the operator actually paid out. There's no "trust" involved — there's a check.
It's the cryptographic-grade upgrade of the "you can audit our game logs" promise that centralized betting sites make. With provably-fair, the audit:
- takes milliseconds instead of weeks,
- can be done by anyone (including the player who just lost a hand),
- uses open-source codethe operator can't fudge after the fact.
| Property | Centralized site | flip.bet |
|---|---|---|
| Outcome computed by | Their server (private) | On-chain Rust program (open) |
| Random number from | Their RNG (closed) | Switchboard VRF (verifiable) |
| Funds held by | Their wallet (custodial) | Program PDA (non-custodial) |
| Can refuse to pay youOperator literally has no withdrawal authority over a Match PDA | Yes | No |
| Audit available to players | No | Yes |
| Operator wins when you loseflip.bet operator earns the same 1% maintenance fee whether you win or lose | Yes | No |
| Open source | No | Yes |
| Can change the rules silentlyAfter upgrade authority is burned, the program is mathematically immutable | Yes | No |
The right column is enforced by code; the left column is enforced by terms-of-service.
A provably-fair game must satisfy three properties. flip.bet satisfies all three:
01
Commitment
Both players (and the VRF oracle) commit to their inputs before the outcome is known.
02
Reveal
After the round, all secret inputs are revealed on chain — they cannot be retroactively changed.
03
Determinism
The outcome is a pure function of the inputs. Same inputs → same result. No hidden variables.
A Verifiable Random Function is a special kind of digital signature. The signer holds a secret key; for any input, they produce a unique random-looking output plus a proof. The pair has two magical properties:
- Anyone with the public key can verify that the output really did come from that input + that key. No way to fake it.
- Nobody — including the signer — can predict the output before the secret key is applied. It looks uniformly random.
The operator's secret key produces a deterministic random output for any input. Anyone with the public key can verify the output is correct — but cannot predict it before the reveal.
The trust-killer property
On Ethereum, the canonical VRF provider is Chainlink VRF — they popularised the pattern and it's been battle-tested across thousands of contracts. The Solana equivalent is Switchboard, and specifically Switchboard On-Demand, which we use.
| Property | Chainlink VRF v2.5 | Switchboard On-Demand |
|---|---|---|
| Native chain | Ethereum & EVM | Solana |
| Curve | secp256k1 | Curve25519 (ed25519) |
| Reveal latency | ~30s (3 blocks) | ~400ms (1 slot) |
| Cost per request | ~$0.50–$5 in LINK | ~$0.0005 in SOL |
| Open source | Yes | Yes |
| Trust modelBoth are decentralised oracle networks — neither relies on a single operator. | Operator + node DKG | Operator + queue oracles |
When a player calls request_randomness, the program records two things on the Match PDA: the public key of a Switchboard randomness account, and the current slot (the "commit slot"). Switchboard's queue of independent operators races to publish a valid reveal in a near-future slot.
use switchboard_on_demand::RandomnessAccountData; pub fn handler(ctx: Context<Settle>) -> Result<()> { let clock = Clock::get()?; let randomness_data = RandomnessAccountData::parse( ctx.accounts.randomness_account.data.borrow(), )?; // Returns the 32-byte VRF output, OR an error if the reveal slot // hasn't landed yet — guaranteeing we can never settle prematurely. let revealed: [u8; 32] = randomness_data.get_value(&clock)?; // ... mix in client seeds and decide the winner ...}The get_value()call enforces that the randomness account's reveal slot is in the past — so the oracle cannot race the on-chain randomness to favour either side.
A naïve flip program might use the recent block hash as randomness. This is exploitable in three ways:
- Validator MEV: a Solana validator producing the next block can simulate transactions, see what the block hash will be, and choose to include or drop the settle to favour themselves if they happen to be a player.
- Predictability: by the time a settle transaction is in the mempool, the block hash it will land in is often knowable — front-runners can use this.
- Bias: block hashes aren't uniformly random; they're hashes of header data with subtle distributional quirks.
VRF dodges all three because the random output is determined by an external operator's secret key, committed to a future slot, and verifiable independently of who built that slot's block.
VRF on its own is necessary but not sufficient. A naive integration can still be gamed even when the underlying random source is cryptographically perfect. We close every known attack on the commit–reveal pattern with four overlapping defenses:
The pot is sealed BEFORE the random number exists
Both wagers are escrowed and both client seeds are committed in transactions that land before request_randomness. By the time the VRF account even has a committed seed slot, the pot is non-negotiable.
The randomness account must be unrevealed at bind time
The on-chain request_randomness ix parses the Switchboard randomness account and rejects the bind if get_value() succeeds. A revealed account = a known outcome → instant revert with RandomnessAlreadyRevealed.
The reveal is signed by an SGX enclave the operator does not control
Switchboard's SGX oracle network produces the reveal. The cranker can choose WHEN to bind and WHEN to settle but cannot choose WHAT value the oracle publishes — that is gated by the seed slot's hash.
The outcome formula is fixed in immutable Rust
entropy = sha256(vrf || creator_seed || opponent_seed); result = entropy[0] & 1. Once the upgrade authority is burned, a single byte of this formula cannot be changed by anyone.
The most subtle attack on a VRF-backed flip is peek-then-bind: instead of binding a fresh random account, an attacker tries to substitute one whose VRF value they already know. Spelled out:
- Attacker creates and commits their own Switchboard randomness account
R. They are the authority — they pay the rent. - ~400ms later, the SGX oracle publishes the reveal for
R. The 32-byte VRF output is now publicly readable. - Attacker reads
R.value, runs the samesha256(value ‖ creator_seed ‖ opponent_seed)we run on chain, and decides whether the outcome favours them. - If favourable, attacker accepts a real lobby challenge and crafts their TX A to bind
Rviarequest_randomness— sealing a flip whose result they already know.
Defense #2 above blocks step 4. The on-chain code is two lines:
pub fn handler(ctx: Context<RequestRandomness>) -> Result<()> { let m = &mut ctx.accounts.match_account; let clock = Clock::get()?; // SECURITY: reject any randomness account that has already revealed. // get_value(slot).is_ok() ⇒ the VRF output is publicly readable ⇒ // whoever submits this ix could have computed the outcome before // sealing the pot. Pre-reveal binding attack rejected. if let Ok(rand_data) = RandomnessAccountData::parse( ctx.accounts.randomness_account.data.borrow(), ) { require!( rand_data.get_value(clock.slot).is_err(), FlipBetError::RandomnessAlreadyRevealed ); } m.randomness_account = ctx.accounts.randomness_account.key(); m.commit_slot = clock.slot; m.status = MatchStatus::AwaitingRandomness; /* … */}For the legitimate flow, the randomness account is either uncommitted at bind time (server-cranker path: the cranker creates the account during accept, defers randomnessCommit until after this ix lands) or just committed in the same transaction (client-fallback path: TX A bundles randomnessInit + randomnessCommit + request_randomness atomically). In both cases get_value() returns Err — there is nothing to peek at.
| Property | Actor | Theoretical attack | What stops it |
|---|---|---|---|
| The acceptor | Pre-reveal a VRF account, peek the outcome, then bind it | request_randomness reverts with RandomnessAlreadyRevealed | |
| The creator | Cancel the match after seeing the VRF reveal | cancel_open is gated to MatchStatus::Open — once joined, only cancel_stale (which refunds both) is available | |
| Site operator | Replace the deployed program with a biased version | Upgrade authority burned post-audit; reproducible builds let anyone diff the on-chain bytecode against the public source | |
| Switchboard operator | Publish a reveal that picks a side | VRF is signed by an SGX enclave; the value is a deterministic function of a hash committed by the program — any oracle that returns a tampered value fails the on-chain signature check | |
| Solana validator | Reorder transactions to favour an outcome | The outcome is a function of the VRF reveal bytes, not block hash or tx order — reordering the same set of txs does not change which side wins | |
| The cranker | Refuse to settle so the loser's funds stay locked | Anyone can call settle once the reveal lands; cancel_stale refunds both players if the reveal never arrives |
Each attack column requires a working exploit; the right column is enforced in deployed Rust.
The combined guarantee
request_randomness lands on chain) nobody on Earth — not the players, not the operator, not the Switchboard oracle, not even a 51%-of-Solana validator coalition — can know which side will win. The outcome is determined later by an SGX-signed reveal whose value is cryptographically committed before our ix even runs. After the reveal, anyone can verify the math with three lines of code (see the next chapter).Every settled flip stores enough data on chain that anyone can recompute the outcome with one SHA-256 and a parity check. Here's the formula in plain pseudocode:
entropy = sha256( vrf_output ‖ creator_seed ‖ opponent_seed )result = entropy[0] & 1 === 0 ? "heads" : "tails"winner = result === picker_pick ? picker : other_playerThe four 32-byte inputs are all stored on the Match PDA after settlement. The last_entropyfield is the SHA-256 result, so a verifier doesn't even need to recompute the hash — they can compare directly.
Open the dev console on any flip's page and paste this. It uses the WebCrypto API that ships in every modern browser — no dependencies.
const m = await fetch("/api/match/" + matchPda).then(r => r.json()); const buf = new Uint8Array(96);buf.set(hexToBytes(m.vrf_output), 0);buf.set(hexToBytes(m.creator_seed), 32);buf.set(hexToBytes(m.opponent_seed), 64); const entropy = new Uint8Array( await crypto.subtle.digest("SHA-256", buf));const result = (entropy[0] & 1) === 0 ? "heads" : "tails"; console.log({ result, matches: result === m.last_result });// → { result: "tails", matches: true }That's the entire fairness proof.
/verify/<flipId> runs exactly this in your browser using @flipbet/sdk/verify.For batch verification (e.g. auditing your own play history), the SDK exports a one-liner:
import { Connection, PublicKey } from "@solana/web3.js";import { fetchMatch, deriveOutcome } from "@flipbet/sdk"; const connection = new Connection("https://api.mainnet-beta.solana.com");const m = await fetchMatch(connection, new PublicKey(MATCH_PDA));if (!m) throw new Error("no match"); const { side, entropy } = deriveOutcome({ vrfValue: Uint8Array.from(m.lastEntropy), creatorSeed: m.creatorSeed, opponentSeed: m.opponentSeed,}); console.log("computed", side, "vs recorded", m.lastResult);last_entropy directly so you can skip the SHA-256 step and just compare. The SDK exposes deriveOutcome() for the full verification path (recompute and compare).The program manages three kinds of on-chain accounts. Understanding them is the cleanest way to understand why the operator has no leverage over outcomes.
Config
Seeds
[b"config"]Global settings (fee bps, treasuries, pause flag). One per program. Mutable only by admin / DAO.
Match
Seeds
[b"match", creator, nonce]Per-game escrow + state machine. One per active match. Closed when the winner claims.
Community Treasury
Seeds
[b"community_treasury"]System-owned PDA that accumulates 1% of every settled pot. Only spendable by the DAO governance signer.
| Property | Instruction | Caller | Effect |
|---|---|---|---|
| create_match_sol | Player A | Opens a match, escrows wager | |
| join_match_sol | Player B | Matches the wager | |
| request_randomness | Either player | Binds a Switchboard reveal | |
| settle | Anyone | Consumes VRF, pays fees, locks payout | |
| claim_winnings | Winner | Releases payout, closes match | |
| offer_double_or_nothing | Winner | Re-stakes for a second flip | |
| accept_double_or_nothing | Loser | Matches stake, picker rotates | |
| decline_double_or_nothing | Loser / anyone | Pays winner, closes match | |
| cancel_open | Creator | Refunds, before opponent joins | |
| cancel_stale | Anyone | Refunds both, after VRF timeout | |
| community_sweep | DAO governance | Withdraws from treasury, vote-gated |
Three instructions can be called by 'anyone' — those exist so the game never gets stuck if a player goes offline.
Solana programs are upgradeable by default. Once mainnet has had a few weeks of validation and an audit signs off, we run:
solana program set-upgrade-authority <PROGRAM_ID> --finalAfter this, the program's bytecode is mathematically frozen. Nobody — not us, not a malicious admin, not even a 51% Solana validator — can change a single byte of how the game decides outcomes. Combined with verifiable builds (publishing the source + reproducible build hash), users can prove the deployed program matches the open-source repo.
Why we don't freeze on day one
Every settled pot is split exactly three ways, in code, on the same transaction that decides the winner:
The 2% is split exactly 1% / 1% in code. SOL flips route the community 1% through Pump.fun in the same transaction so the treasury holds $FLIP, not SOL — boosting the token directly. Maintenance is paid in SOL to the multisig.
The split is enforced by basis-points fields on the Config account. A constant MAX_TOTAL_FEE_BPS = 500 caps the sum so even a compromised admin can never set fees above 5%.
1% of every pot accumulates in a program-owned PDA derived from seeds = [b"community_treasury"]. The only way to move funds out of it is via community_sweep, which requires the transaction to be signed by the address stored in config.community_governance.
For SOL flips, the program does one extra thing on settle: it atomically swaps the community 1% from SOL into $FLIP via Pump.fun in the same transaction that pays out the winner. The treasury therefore accumulates $FLIP, not SOL. This is enforced by the on-chain config.community_swap_targetfield — when set to the FLIP mint, every settle CPIs into Pump.fun's bonding curve. If the swap would exceed config.community_swap_max_slippage_bps (or Pump.fun is unreachable, or the curve has graduated), the settle soft-fails: SOL stays in the treasury and a SwapFailed event is emitted. The flip itself never reverts.
The signer is a Realms governance PDA — meaning the signer only signs when a $FLIP-holder DAO vote passes. The DAO can use the FLIP treasury to:
- Distribute pro-rata to $FLIP stakers
- Fund grants for builders, audits, or marketing
- Add liquidity to the $FLIP DEX pool — the treasury holds $FLIP, not SOL, so a SOL-paired pool needs the DAO to either (a) swap a slice of treasury $FLIP back to SOL via the same Pump/Pump-AMM route in a sweep tx, or (b) pair $FLIP with a stable like USDC sourced from a separate treasury bond proposal. Single-sided $FLIP pools (e.g. Meteora dynamic AMM) are also a valid path that needs no SOL balance.
The other 1% goes to a Squads multisig stored in config.maintenance_treasury. This pays for things the DAO shouldn't need to vote on every month: RPC bills, the Switchboard subscription, audit retainers, legal counsel for the operating entity.
Why split it at all?
After a flip settles, the winner's 98% payout doesn't immediately leave the Match PDA. There's a 60-second window in which the winner can offer the loser a re-stake at double the previous pot. Both players have full control:
The winner's payout stays escrowed until they either claim or the loser declines. The loser has 60 seconds to accept a re-stake.
Two clocks are involved. First, the winner has config.dor_window_seconds (default 60s) to either claim their winnings or call offer_double_or_nothing. If they do nothing, anyone can call claim_winnings on their behalf — no funds are ever stuck.
If the winner offers, a second 60-second clock starts for the loser to accept or decline. Same fallback: timeout = winner gets paid.
pub fn handler(ctx: Context<DeclineDor>) -> Result<()> { let now = Clock::get()?.unix_timestamp; // Loser can decline immediately. Anyone else must wait for the // window to expire — otherwise they could grief the loser by // declining on their behalf. if ctx.accounts.cranker.key() != m.last_loser { require!(now >= m.dor_deadline, FlipBetError::DorWindowOpen); } // ... pay winner, close match ...}Every accepted DoR doubles the pot:
Round 1
1.96
SOL payout
Round 2
3.84
SOL payout
Round 3
7.52
SOL payout
Round 4
14.74
SOL payout
Round 5
28.90
SOL payout
The 2% fee compounds across rounds — every settle takes its cut again. So a 5-round DoR streak from a 1 SOL initial wager nets the winner ~28.9 SOL but generates ~0.59 SOL in cumulative fees for the treasuries.
Influence the outcome of any flip — the VRF output is signed by an external oracle network.
Refuse to pay a winner — the program transfers funds based purely on the entropy bytes.
Take more than 1% — the maintenance fee is a single field hardcoded in the deployed program.
Move community treasury funds — only the Realms DAO governance signer can.
Inspect player seeds before settle — they're committed first, revealed in the same settle tx.
Front-run a settle — Switchboard reveal happens at a specific committed slot, not when convenient.
Bind a pre-revealed VRF account to a match — request_randomness reverts on any account whose value is already publicly readable on chain (see “Why this is unbreakable”).
Change the rules retroactively — once the upgrade authority is burned, the bytecode is frozen forever.
Honest answer: anything that depends on Solana being live, on Switchboard's queue being live, or on a player not abandoning mid-game. Each failure mode has a deterministic on-chain recovery:
| Property | Failure | Recovery |
|---|---|---|
| Player abandons after creating a challenge | Creator can call cancel_open and pull their wager | |
| Player abandons after joining (before settle) | Anyone can call cancel_stale after randomness_timeout_slots and refund both | |
| Switchboard queue is unhealthy | Match goes stale → cancel_stale refunds both players | |
| Winner doesn't claim | Anyone can call claim_winnings after the DoR window expires | |
| Loser doesn't decline DoR | Anyone can call decline_double_or_nothing after the deadline | |
| Solana goes down | Funds remain in PDAs; recovery happens automatically when the chain comes back |
Every failure mode resolves to either a refund or a payout. Funds are never stuck.
Audits run on a rolling basis. We don't claim completed audits we haven't commissioned, and we don't bury findings — every report we run lands here in the docs and is announced on @flipdotbet the day it closes, including the full scope, all findings (severity + remediation diff), and the firm that ran it. Engagements are paid out of the community treasury, so $FLIP holders see exactly what their share funded.
Until the first audit clears + a 30-day quiet bug-bounty period passes, the upgrade authority remains live so we can ship fixes from disclosures. After that window, the upgrade authority is burned and the program becomes immutable.
Bug bounty
security@flip.bet (PGP key on the GitHub README).