Skip to main content

Provably fair flips

Since 27 October 2026, every FFFlip result comes from drand, a public randomness beacon run by a group of independent organisations. The FFFlip program checks drand's signature on chain before it uses it. This page explains how that works and how you can check a flip yourself.

The short version​

  1. When your bet lands, the program locks it to a drand round that hasn't been published yet.
  2. A few seconds later, drand publishes that round with a signature.
  3. Anyone can send that signature to the program. The program checks it against drand's public key and uses it to pick heads or tails.
  4. The result depends only on the drand round and your bet account. Nobody, including the FFF team, can know it before your bet is locked in, and nobody can change it afterwards.

How it works​

drand evmnet​

FFFlip uses drand's evmnet network. It publishes a new round every 3 seconds. Each round has a number and a BLS signature, and each signature can be checked against evmnet's fixed public key.

SettingValue
Networkevmnet
Chain hash04f1e9062b8a81f848fded9c12306733282b2727ecced50032187751166ec8c3
Round period3 seconds
Genesis time1727521075 (Unix time)
Network infoapi.drand.sh/04f1…8c3/info

Round n is published at 1727521075 + (n - 1) × 3 seconds (Unix time).

Step 1: your bet commits to a future round​

When you place a bet, the program reads the Solana clock, works out the current drand round, and adds a small offset. The result is the round your bet is locked to. It is saved in your bet account with your side and stake.

The offset is between 2 and 10 rounds, so your round is published roughly 3 to 30 seconds after your bet lands. It is usually 3 rounds, about 6 to 9 seconds. FFFlip raises the offset when the Solana clock runs behind real time, so the locked round is always one that drand has not published yet.

Step 2: anyone can settle the flip​

Once the round is out, anyone can call the program's flip instruction with that round's signature. FFF runs a service that does this for every bet, then sends your payout if you won.

The program:

  1. Checks that the signature is drand evmnet's valid signature for exactly the round your bet is locked to. Any other round, or a forged signature, is rejected.
  2. Computes the randomness as sha256(signature).
  3. Picks the side from this hash:
digest = sha256("FFFLIP_DRAND_V1" || chain_hash || round || randomness || bet_account)
side = tails if (digest[0] & 1) == 1, otherwise heads

round is the round number as 8 bytes, little-endian. chain_hash is the 32-byte chain hash above. bet_account is the 32-byte address of your bet account.

Your bet account is part of the hash, so two bets locked to the same round don't always get the same side.

  1. Records a Flipped event on chain with your side, the flipped side, the round and the randomness.

Because the result depends only on public data that was fixed before anyone could see it, the timing of the settle transaction, the wallet that sends it and the validator that includes it make no difference.

Why this matters​

Before this change, flips used Solana slot data as randomness. With drand, the result is fixed by a signature that nobody had when your bet was placed, so it doesn't depend on when or how any transaction lands.

Check a flip yourself​

On the site​

After each flip, the site shows a "Settled on chain" or "Paid to your wallet" chip with a link to the flip transaction on Solscan. The site also works out your result from drand as soon as the round is out, and then checks it against the on-chain event. The on-chain Flipped event is the record.

With a script​

This script reads a flip transaction, fetches the drand round, and works out the side again. It needs Node.js 18 or later and no extra packages.

  1. Copy the transaction signature from the Solscan link under your result.
  2. Save the script below as verify-flip.mjs.
  3. Run node verify-flip.mjs <signature>.
verify-flip.mjs
// Usage: node verify-flip.mjs <flip transaction signature> [rpc url]
import { createHash } from "node:crypto";

const [sig, rpc = "https://api.mainnet-beta.solana.com"] = process.argv.slice(2);
const PROGRAM = "72D3En8GQycjtunxf9mgyR8onzYdPqYFsKp4myUzhaRZ";
const CHAIN = "04f1e9062b8a81f848fded9c12306733282b2727ecced50032187751166ec8c3";
const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const sha256 = (...parts) => createHash("sha256").update(Buffer.concat(parts)).digest();
const b58decode = (s, size) => {
let n = 0n;
for (const c of s) n = n * 58n + BigInt(ALPHABET.indexOf(c));
let hex = n === 0n ? "" : n.toString(16);
if (hex.length % 2) hex = "0" + hex;
const zeros = s.match(/^1*/)[0].length;
const out = Buffer.concat([Buffer.alloc(zeros), Buffer.from(hex, "hex")]);
return size ? Buffer.concat([Buffer.alloc(size - out.length), out]) : out;
};
const b58encode = (buf) => {
let n = BigInt("0x" + buf.toString("hex")), s = "";
while (n > 0n) { s = ALPHABET[Number(n % 58n)] + s; n /= 58n; }
for (const b of buf) { if (b !== 0) break; s = "1" + s; }
return s;
};

const res = await fetch(rpc, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getTransaction",
params: [sig, { encoding: "json", maxSupportedTransactionVersion: 1 }] }),
}).then((r) => r.json());
const tx = res.result;
if (!tx) throw new Error("Transaction not found");

// The Flipped event is logged as "Program data: <base64>".
const eventTag = sha256(Buffer.from("event:Flipped")).subarray(0, 8);
const event = tx.meta.logMessages
.filter((l) => l.startsWith("Program data: "))
.map((l) => Buffer.from(l.slice(14), "base64"))
.find((d) => d.subarray(0, 8).equals(eventTag));
if (!event) throw new Error("No Flipped event in this transaction");
const side = (b) => (b === 0 ? "heads" : "tails");
const betSide = side(event[48]);
const flipSide = side(event[49]);
const player = b58encode(event.subarray(50, 82));
const round = event.readBigUInt64LE(82);
const randomness = event.subarray(90, 122);

// The bet account is the first account of the flip instruction.
const keys = [...tx.transaction.message.accountKeys,
...(tx.meta.loadedAddresses?.writable ?? []), ...(tx.meta.loadedAddresses?.readonly ?? [])];
const flipTag = sha256(Buffer.from("global:flip")).subarray(0, 8);
const ix = tx.transaction.message.instructions.find((i) =>
keys[i.programIdIndex] === PROGRAM && b58decode(i.data).subarray(0, 8).equals(flipTag));
const bet = keys[ix.accounts[0]];

// Fetch the drand round and check it against the event.
const beacon = await fetch(`https://api.drand.sh/${CHAIN}/public/${round}`).then((r) => r.json());
const beaconRandomness = sha256(Buffer.from(beacon.signature, "hex"));

const roundLE = Buffer.alloc(8); roundLE.writeBigUInt64LE(round);
const digest = sha256(Buffer.from("FFFLIP_DRAND_V1"), Buffer.from(CHAIN, "hex"), roundLE,
beaconRandomness, b58decode(bet, 32));
const computed = digest[0] & 1 ? "tails" : "heads";

console.log({ player, bet, round: round.toString(), betSide, flipSide,
randomnessMatchesDrand: beaconRandomness.equals(randomness), recomputedSide: computed,
sideMatches: computed === flipSide, result: betSide === flipSide ? "win" : "loss" });

A correct flip prints randomnessMatchesDrand: true and sideMatches: true.

The script trusts drand's HTTP API for the signature. It doesn't check the BLS signature itself, because the program already did that on chain and rejects any signature that isn't valid for the round.

Check that the round was in the future​

To confirm your bet was locked before its round came out:

  1. Open the transaction that placed your bet and note its block time.
  2. Work out when your round was published: 1727521075 + (round - 1) × 3.
  3. The round time should be a few seconds after the block time.

Flips before 27 October 2026​

Flips settled before the drand upgrade used the older slot-based method and can't be checked this way. Bets that were placed before the upgrade but not yet flipped were locked to a drand round when they were settled.