Skip to main content

Provably fair

Since 11 November 2026, every Rafffle raffle winner is drawn from drand, a public randomness beacon run by a group of independent organisations. The Rafffle program on Solana checks the beacon and picks the winner itself. Nobody, including the Rafffle team, can choose or change the result, and anyone can check it.

How a winner is picked​

  1. Sales close. Tickets can be bought until the raffle's end time, or until the last ticket sells. From then on the list of tickets can't change.
  2. The draw number is fixed in advance. drand publishes a new random number every 3 seconds, and each one has a round number. The raffle uses the round that comes out about 30 seconds after sales close. Which round that is follows from the end time alone, so it is known in advance, but its number isn't: nobody can know it until drand publishes it, after sales have closed.
  3. The beacon is published. drand signs each round. Anyone can send that signature to the Rafffle program. Rafffle's server normally does it within seconds.
  4. The program checks it. The program verifies the signature against drand's public key. A wrong or made-up number is rejected.
  5. The program picks the ticket. It mixes the drand number with the raffle's address and turns the result into a ticket number between 1 and the number of tickets sold. The wallet that bought that ticket wins.
  6. The result is stored on chain. The program saves the round, the result and the winning ticket on the raffle account, and the draw transaction logs them.

Each ticket is one entry, so a wallet with 10% of the tickets sold has a 10% chance to win.

On a raffle page, a drawn raffle shows Drawn by drand round #.... The link opens that round on drand's public API.

What the Rafffle team can and can't do​

  • Can't pick the winner, re-draw a raffle, or use a different drand round.
  • Can't change a raffle's end time after sales close. While a raffle is still selling, the team can move its end time later, for example during network problems.
  • Can cancel a raffle in rare cases. The prize then goes back to the creator and buyers are refunded. See Entering a raffle.

Technical details​

These are the exact steps the program runs in its draw_winner instruction.

The draw round. drand's evmnet chain started at Unix time 1727521075 and makes one round every 3 seconds, so the round active at time t is floor((t - 1727521075) / 3) + 1. A raffle with on-chain end time end is drawn by:

draw_round = round_at(end) + 10

That round starts 28 to 30 seconds after end. The end time is the one on the raffle account. For a raffle that sold out, it is the time the last ticket sold.

The beacon. For evmnet, a round's randomness is sha256(signature). The program verifies the BLS signature (BN254) against the evmnet public key, then uses that randomness.

The draw hash and the winning ticket.

draw_hash = sha256("RAFFFLE_DRAND_V1" || chain_hash || draw_round (u64, little-endian)
|| randomness || raffle address (32 bytes))
winner_index = (first 16 bytes of draw_hash, read as a little-endian integer) mod tickets_sold

chain_hash is evmnet's chain hash, 04f1e9062b8a81f848fded9c12306733282b2727ecced50032187751166ec8c3, as 32 bytes. Tickets are numbered from 0 in the order they were bought, so winner_index 0 is the first ticket sold. Including the raffle address means two raffles drawn by the same round get unrelated results.

The program stores draw_hash as the raffle's randomness, plus winner_index and drand_round, and emits a DrawRaffle event with the round, the beacon randomness and the winning index.

The draw is safe as long as Solana's clock is less than 27 seconds behind real time when sales close. It is normally 1 to 3 seconds behind.

Multiple winners​

Raffles with more than one winner use the same draw_hash. The extra winners are worked out from it by the site, and shown on the raffle page:

pick(i) = (first 4 bytes of keccak256(draw_hash || i (u32, little-endian)), little-endian) mod n
  • Unique winners (the default). The first winner is the on-chain winning ticket. For the rest, remove all of the first winner's tickets and set n to the tickets left. Take pick(0), pick(1), and so on, skipping any wallet that has already won, until there are enough winners.
  • Winners can repeat. Every winner is pick(i) for i = 0, 1, 2 and so on, with n = all tickets sold. A wallet can appear more than once.

Check a draw yourself​

This script reads a raffle from Solana, fetches its drand round, checks drand's signature, and recomputes the winning ticket. You need Node.js 18 or later.

mkdir check-raffle && cd check-raffle
npm init -y
npm install drand-client bs58
# save the script below as verify-raffle.mjs, then:
node verify-raffle.mjs <raffle address>

The raffle address is the last part of the raffle page URL (rafffle.famousfoxes.com/raffle/<raffle address>).

After the creator collects the proceeds, the raffle account is closed. For those raffles, pass the round and the number of tickets sold from the raffle page:

node verify-raffle.mjs <raffle address> --round <drand round> --sold <tickets sold>
verify-raffle.mjs
// Recompute a Rafffle draw (raffles drawn by drand, program 0.8.0 and later).
// Usage:
// node verify-raffle.mjs <raffle address>
// node verify-raffle.mjs <raffle address> --round <drand round> --sold <tickets sold>
// The second form is for raffles whose account is already closed: take the round
// ("Drawn by drand round #...") and the number of tickets sold from the raffle page.
import { createHash } from "node:crypto";
import bs58 from "bs58";
import { fetchBeacon, HttpCachingChain, HttpChainClient } from "drand-client";

const RPC = process.env.RPC_URL ?? "https://api.mainnet-beta.solana.com";
const CHAIN_HASH = "04f1e9062b8a81f848fded9c12306733282b2727ecced50032187751166ec8c3";
const PUBLIC_KEY =
"07e1d1d335df83fa98462005690372c643340060d205306a9aa8106b6bd0b3820557ec32c2ad488e4d4f6008f89a346f18492092ccc0d594610de2732c8b808f0095685ae3a85ba243747b1b2f426049010f6b73a0cf1d389351d5aaaa1047f6297d3a4f9749b33eb2d904c9d9ebf17224150ddd7abd7567a9bec6c74480ee0b";
const GENESIS = 1727521075; // evmnet genesis (unix seconds)
const PERIOD = 3; // seconds between rounds
const DRAW_ROUND_OFFSET = 10;

const [raffleAddress, ...flags] = process.argv.slice(2);
if (!raffleAddress) throw new Error("usage: node verify-raffle.mjs <raffle> [--round <n> --sold <n>]");
const flag = (name) => {
const i = flags.indexOf(name);
return i >= 0 ? Number(flags[i + 1]) : undefined;
};

// 1. Read the raffle account (if it still exists).
let onChain = null;
const res = await fetch(RPC, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "getAccountInfo",
params: [raffleAddress, { encoding: "base64" }],
}),
});
const value = (await res.json()).result?.value;
if (value) {
const d = Buffer.from(value.data[0], "base64");
// Field offsets for a drawn raffle (its randomness is set, so the layout is fixed).
if (d[144] !== 1) throw new Error("this raffle has not been drawn yet");
onChain = {
randomness: d.subarray(145, 177).toString("hex"),
end: Number(d.readBigInt64LE(177)),
sold: d.readUInt32LE(225),
winnerIndex: d[353] === 1 ? d.readUInt16LE(354) : null,
round: Number(d.readBigUInt64LE(358)),
};
if (onChain.round === 0) throw new Error("this raffle was drawn before drand (see Older draws)");
}
const sold = flag("--sold") ?? onChain?.sold;
// 2. Which drand round draws this raffle: ten rounds after the one active when sales closed.
const round = onChain
? Math.floor((Math.max(onChain.end, GENESIS) - GENESIS) / PERIOD) + 1 + DRAW_ROUND_OFFSET
: flag("--round");
if (round === undefined || sold === undefined) throw new Error("account closed: pass --round and --sold");

// 3. Fetch that round. drand-client checks the BLS signature against the evmnet public key.
const chain = new HttpCachingChain(`https://api.drand.sh/${CHAIN_HASH}`, {
disableBeaconVerification: false,
noCache: false,
chainVerificationParams: { chainHash: CHAIN_HASH, publicKey: PUBLIC_KEY },
});
const beacon = await fetchBeacon(new HttpChainClient(chain), round);

// 4. Same hash and modulo as the program's draw_winner.
const roundLe = Buffer.alloc(8);
roundLe.writeBigUInt64LE(BigInt(round));
const digest = createHash("sha256")
.update("RAFFFLE_DRAND_V1")
.update(Buffer.from(CHAIN_HASH, "hex"))
.update(roundLe)
.update(Buffer.from(beacon.randomness, "hex"))
.update(Buffer.from(bs58.decode(raffleAddress)))
.digest();
const low = BigInt("0x" + Buffer.from(digest.subarray(0, 16)).reverse().toString("hex"));
const winnerIndex = Number(low % BigInt(sold));

console.log(`drand round: ${round} (https://api.drand.sh/${CHAIN_HASH}/public/${round})`);
console.log(`tickets sold: ${sold}`);
console.log(`draw hash: ${digest.toString("hex")}`);
console.log(`winning ticket: #${winnerIndex + 1} (index ${winnerIndex})`);
if (onChain) {
const ok =
onChain.round === round &&
onChain.winnerIndex === winnerIndex &&
onChain.randomness === digest.toString("hex");
console.log(ok ? "Matches the result stored on chain." : "DOES NOT match the result stored on chain!", onChain);
}

Example output:

drand round: 20768627 (https://api.drand.sh/04f1e9.../public/20768627)
tickets sold: 8
draw hash: 44bc09cd4660e3aea6d6a839a428663036447f70fc079d0cfdb972f831e0cb5f
winning ticket: #5 (index 4)

The script uses Solana's public RPC. If it is rate limited, set RPC_URL to another RPC endpoint.

Older draws​

Raffles drawn before 11 November 2026 used a different method. Rafffle's server made a random seed (the address of a newly generated Solana keypair), used it to pick a ticket, and sent the winner to the program. It wrote seed:tickets_sold:winning_index in a memo on the draw transaction, for example:

By8uYnrSXufy4gVaXi1E9qC8XNGq6FtFadxUTDgLZVUn:10:9

You can still find this memo on those draw transactions in any Solana explorer, and check that the index follows from the seed:

// npm install chance
import Chance from "chance";
const [seed, sold, index] = process.argv[2].split(":");
const expected = new Chance(seed).integer({ min: 0, max: Number(sold) - 1 });
console.log(expected === Number(index) ? "Matches" : `Expected ${expected}`);

This shows that the memo is consistent. It can't show how the seed was chosen, because the seed came from Rafffle's server. The drand method removes that step: the random number comes from drand, and the program checks it.

Raffles that had ended but were not yet drawn when the change went live were drawn with drand.