flip.betflip.bet
Open source · provably fair · 2% fee

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

01

The Game

What is flip.bet?

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

Two players each commit a secret seed and a wager into an escrow controlled by code; a verifiable random function decides the outcome; the program pays out the winner; anyone can re-derive the result and confirm it wasn't tampered with.

Lifecycle of a flip

Every match goes through a strict on-chain state machine. There are no off-chain "trust us" steps in between.

DiagramMatch lifecycle
Player APlayer BMATCH PDAEscrow vaultowned by programseeds = [b"match", creator, nonce]create_matchjoin_matchSWITCHBOARD VRFrequest_randomness → settle

Funds live in a program-owned PDA the entire time. The site operator is not a party in any transfer.

1

A player creates a challenge

They call 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.
2

Another player joins

Calling join_match_sol deposits a matching wager and contributes their own client seed. The match transitions to Joined.
3

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.
4

The reveal is consumed and the match settles

When the Switchboard oracle publishes the reveal (typically within one slot, ~400ms), anyone can crank 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.
5

Winner claims (or offers double-or-nothing)

Within 60 seconds, the winner can offer the loser a re-stake (Double or Nothing — see its own section). Otherwise they call claim_winnings to release the payout to their wallet.

Wager currencies

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.

Even though SOL is the only wager currency at launch, 1% of every fee is intended to flow to a $FLIP buy & burn (or staker distribution) once the DAO votes to enable that Realms instruction template.

02

Provably Fair

What "provably fair" actually means

"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.

vs. centralized sites

PropertyCentralized siteflip.bet
Outcome computed byTheir server (private)On-chain Rust program (open)
Random number fromTheir RNG (closed)Switchboard VRF (verifiable)
Funds held byTheir wallet (custodial)Program PDA (non-custodial)
Can refuse to pay youOperator literally has no withdrawal authority over a Match PDAYesNo
Audit available to playersNoYes
Operator wins when you loseflip.bet operator earns the same 1% maintenance fee whether you win or loseYesNo
Open sourceNoYes
Can change the rules silentlyAfter upgrade authority is burned, the program is mathematically immutableYesNo

The right column is enforced by code; the left column is enforced by terms-of-service.

The three cryptographic properties

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.

03

Randomness (VRF)

What is a VRF?

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:

  1. Anyone with the public key can verify that the output really did come from that input + that key. No way to fake it.
  2. Nobody — including the signer — can predict the output before the secret key is applied. It looks uniformly random.
DiagramHow a VRF reveal works
Match commit slotOperator secret keyVRF FUNCTIONECVRF (Curve25519)cryptographiccommitmentRandom output (32 B)Proof of validityANYONE CAN VERIFYverify(public_key, commit_slot, output, proof) = true

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

Because the operator can't predict the VRF output before committing to the input slot, they can't sandwich the player or choose to publish only favourable randomness. They either publish a valid VRF reveal — which produces whatever output it produces — or the match times out and both players get refunded.

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.

PropertyChainlink VRF v2.5Switchboard On-Demand
Native chainEthereum & EVMSolana
Curvesecp256k1Curve25519 (ed25519)
Reveal latency~30s (3 blocks)~400ms (1 slot)
Cost per request~$0.50–$5 in LINK~$0.0005 in SOL
Open sourceYesYes
Trust modelBoth are decentralised oracle networks — neither relies on a single operator.Operator + node DKGOperator + queue oracles

How we use Switchboard On-Demand

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.

program/src/instructions/settle.rs
rust
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.

Why we don't just use the block hash

A naïve flip program might use the recent block hash as randomness. This is exploitable in three ways:

  1. 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.
  2. 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.
  3. 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.

Why this is unbreakable

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:

01

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.

02

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.

03

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.

04

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 “peek-then-bind” attack — and why it cannot work

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:

  1. Attacker creates and commits their own Switchboard randomness account R. They are the authority — they pay the rent.
  2. ~400ms later, the SGX oracle publishes the reveal for R. The 32-byte VRF output is now publicly readable.
  3. Attacker reads R.value, runs the same sha256(value ‖ creator_seed ‖ opponent_seed) we run on chain, and decides whether the outcome favours them.
  4. If favourable, attacker accepts a real lobby challenge and crafts their TX A to bind R via request_randomness — sealing a flip whose result they already know.

Defense #2 above blocks step 4. The on-chain code is two lines:

program/src/instructions/request_randomness.rs
rust
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.

Who could cheat (and what stops them)

PropertyActorTheoretical attackWhat stops it
The acceptorPre-reveal a VRF account, peek the outcome, then bind itrequest_randomness reverts with RandomnessAlreadyRevealed
The creatorCancel the match after seeing the VRF revealcancel_open is gated to MatchStatus::Open — once joined, only cancel_stale (which refunds both) is available
Site operatorReplace the deployed program with a biased versionUpgrade authority burned post-audit; reproducible builds let anyone diff the on-chain bytecode against the public source
Switchboard operatorPublish a reveal that picks a sideVRF 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 validatorReorder transactions to favour an outcomeThe 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 crankerRefuse to settle so the loser's funds stay lockedAnyone 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

For any flip on flip.bet, at the moment the pot is sealed (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).

04

Verify a flip yourself

The derivation

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:

javascript
entropy = sha256( vrf_output ‖ creator_seed ‖ opponent_seed )result  = entropy[0] & 1 === 0 ? "heads" : "tails"winner  = result === picker_pick ? picker : other_player

The 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.

Verify in your browser console

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.

browser console
javascript
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.

Three lines of crypto, no trusted intermediary. The verify page at /verify/<flipId> runs exactly this in your browser using @flipbet/sdk/verify.

Verify from a script

For batch verification (e.g. auditing your own play history), the SDK exports a one-liner:

verify.ts
typescript
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);
The Match PDA stores the 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).

05

Smart Contracts

Account model

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.

Instruction set

PropertyInstructionCallerEffect
create_match_solPlayer AOpens a match, escrows wager
join_match_solPlayer BMatches the wager
request_randomnessEither playerBinds a Switchboard reveal
settleAnyoneConsumes VRF, pays fees, locks payout
claim_winningsWinnerReleases payout, closes match
offer_double_or_nothingWinnerRe-stakes for a second flip
accept_double_or_nothingLoserMatches stake, picker rotates
decline_double_or_nothingLoser / anyonePays winner, closes match
cancel_openCreatorRefunds, before opponent joins
cancel_staleAnyoneRefunds both, after VRF timeout
community_sweepDAO governanceWithdraws from treasury, vote-gated

Three instructions can be called by 'anyone' — those exist so the game never gets stuck if a player goes offline.

Immutability

Solana programs are upgradeable by default. Once mainnet has had a few weeks of validation and an audit signs off, we run:

bash
solana program set-upgrade-authority <PROGRAM_ID> --final

After 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

Freezing a buggy program is permanent. We deploy upgradeable for the first weeks of mainnet so that critical bugs caught post-launch can be patched. The deployment plan (DEPLOY.md) requires a passed audit + a quiet bug bounty period before the burn-authority transaction.

06

Fees & Treasury

The 2% split

Every settled pot is split exactly three ways, in code, on the same transaction that decides the winner:

DiagramFee split on every settle
POT100%98%Winner payoutreleased on claim or after DoR window1%Community DAO treasurySOL flip → swapped to $FLIP via Pump.fun in the same txFLIP flip → SPL transfer · spent via Realms votesoft-fail: SOL stays in treasury if Pump routing failsPUMP.FUN1%Maintenance multisigpaid in SOL · audits, hosting, RPCs

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%.

Community DAO treasury

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.

Maintenance multisig

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?

Pure DAOs are slow. Operations need a fast-twitch payment lane (you can't miss a Helius bill because of a 3-day vote). Pure operator pockets are extractive. Splitting fees gives the DAO a controlling-stake voice in long-term direction (1%) while keeping the lights on (1%).

07

Double or Nothing

How it works

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:

DiagramDouble-or-Nothing flow
Settledwinner pickedWinner offers DoR?DoR Offeredloser's 60s windowyesnoAccept?Claimwinner gets 98% payoutNew roundpot doubles · re-flipDecline / timeoutwinner claimsyesno/timeout

The winner's payout stays escrowed until they either claim or the loser declines. The loser has 60 seconds to accept a re-stake.

The 60-second window

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.

program/src/instructions/decline_double_or_nothing.rs
rust
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 ...}

The pot math (and why it's exponential)

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.

08

Security

What the operator CAN'T do

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.

What can go wrong (and the recovery path)

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:

PropertyFailureRecovery
Player abandons after creating a challengeCreator 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 unhealthyMatch goes stale → cancel_stale refunds both players
Winner doesn't claimAnyone can call claim_winnings after the DoR window expires
Loser doesn't decline DoRAnyone can call decline_double_or_nothing after the deadline
Solana goes downFunds 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.

Audit & disclosure

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

We run a permanent bug bounty: critical issues that move funds get up to 10% of the community treasury at the time of disclosure. Coordinate disclosure to security@flip.bet (PGP key on the GitHub README).

09

FAQ

12