One thing to trust. The rest you can check.
A draw you cannot check is just a promise. This one is pinned by two commitments published up front and a public random number from drand that we cannot produce, predict or swap — so you can recompute any winner yourself with one dependency-free Node script. What is left over, you should not have to guess at: it is named in full further down.
Two things go up before anyone enters
The moment a cycle opens — before the first entry is accepted — we publish two values. First, the SHA-256 hash of a random 32-byte seed we generate and then keep secret. Second, the number of the drand round that will decide this draw. drand is a public randomness beacon run by the League of Entropy; it publishes a new random number every 30 seconds on a fixed schedule. The round we name has not happened yet, so its value does not exist and nobody — us very much included — can predict it. Read “What you still have to trust us for” below before you decide how much this step is worth: the two values are real and binding, but the timestamp on them is our word until you record them yourself.
Members enter, then the list is frozen and published
Entries stop at the closing instant — enrolment is gated on the clock, not on a status flag, so there is no grey minute after the deadline in which one more ticket can slip in. Every entry is then numbered, the whole list is frozen, and its hash is written once and never rewritten. Entry ids are derived from the cycle, the member and their ticket number, so re-freezing the same members always produces byte-identical tickets. From this moment nobody can add an entry, drop one or reorder the list without the published hash changing.
The freeze has to beat the beacon, or there is no draw
The deciding drand round lands one full hour after the cycle closes. That hour is load-bearing, not padding: the list must be frozen and its hash visible while the randomness is still unknowable to everybody, us included, which leaves a real window in which you can see the committed hash before anyone can see the result. If the freeze did not happen before that round became public, the draw is refused outright — no winner is published, the cycle stays undrawn, and a human has to decide what to do. We would rather be late than publish a draw where the entry list could have been assembled around a random number we had already seen. Every cycle page prints both timestamps and states which way round they fell, so you are not taking that on faith either.
Only then does the randomness exist
The drand round we named in step 1 is published on schedule, after the entry list is already frozen and public. We do not produce this number, we cannot influence it, and we could not have seen it while the entry list was still ours to change. Anyone can fetch the same round from drand, today or in ten years, and get the same value.
We reveal the seed and the winner is computed, not chosen
The seed is published with the result — hash it yourself and check it against the commitment from step 1. The winning position is HMAC-SHA256, keyed with that seed, over the entry-list hash, the drand value and a counter. That 256-bit number is reduced to the entry count by rejection sampling, so every ticket is exactly equally likely; a plain remainder would quietly favour low ticket numbers.
Anyone recomputes it
Every input is public after the draw. Run the script below, or read src/lib/draw.ts, and you will land on the same winner — and the script checks the entry list against the frozen hash and the beacon against drand, so it is not just taking our word for either. Every cycle page also re-runs the check on our own server and shows you the result, pass or fail.
Why two commitments, not one.
A commitment to our seed alone used to be the whole story here. It was not enough, and it is worth spelling out what each piece actually does.
The seed commitment stops us swapping the randomness we contribute
Publish SHA-256 of the seed first and we are locked in against anyone holding that hash. We cannot later reach for a different seed that happens to pick a friend, because the revealed seed would stop hashing to the published commitment — and you could prove it in one line of code.
The frozen entry-list hash stops us swapping who is in
Publish the hash of the entry list the moment entries close and the list is fixed in public. An entry quietly added, dropped or reordered afterwards changes the hash, and the draw refuses to run against a list that no longer matches.
The public beacon stops us knowing the answer while we could still change either
This is the one that was missing. We hold the seed from the day the cycle opens, and until the freeze we can influence the entry list — so we could have signed up one more free account, recomputed the winner, thrown it away, and repeated until someone we liked came up. It costs nothing and leaves no trace, and a verifier that recomputes from whatever list it is handed would still have called the result valid. Folding in a number that did not exist at freeze time closes it from both ends.
The ordering is the security
None of the three values matters as much as the order they land in. Read it as a timeline:
- Cycle opens.Seed hash published. drand round number published. That round's value does not exist.
- Week runs. Members enter. We could still influence who is in the list — and the randomness is still unknowable, so shaping the list tells us nothing.
- Cycle closes. Entry stops on the clock. The list is frozen and its hash is published. The randomness still does not exist — and it will not for another hour.
- The round is published, an hour later. Now the randomness is knowable — and the entry list has already been fixed in public for everyone to see.
- The draw runs — or refuses. The freeze timestamp is compared against the moment the round went public. If the freeze did not come first, nothing is drawn and no winner is published; it goes to a human instead. If it did, the seed is revealed and the winner falls out of values that were all committed before anyone could see the answer.
While we could still change the inputs, we could not see the result. By the time we could see the result, the inputs were already published and frozen. That ordering is what the scheme buys, and it is checked on every draw rather than assumed — each cycle page prints both timestamps and tells you which way round they actually fell. What the ordering does not buy is proof of when we first published the commitments. That one is next.
What you still have to trust us for.
This design does not remove trust. It shrinks it to one thing and shows you where that thing is, so you can go and close it yourself in about a minute.
The beacon is the part we genuinely cannot touch
drand is run by the League of Entropy — a set of independent organisations, none of them us — and it emits a random value every 30 seconds on a fixed public schedule. Anyone can fetch the round that decided any of our draws, today or in ten years, straight from drand, and get the same value we published. We do not generate it, we cannot predict it, and we cannot substitute it without the fetched value disagreeing with ours in public. That is the one number here that carries its own proof.
Everything else rests on our word about what we published, and when
Be clear about where the seed hash and the round number actually live: in our own database, rendered by our own server, on this site. That is the whole of it. There is no signature over those values, no timestamping authority, no notarised log, no external mirror, nothing published to a chain. So nothing here cryptographically proves that the seed existed before entries opened rather than being picked later and written in as though it had. An operator with database access and bad intentions could produce a page that looks exactly like this one.
A member can verify what they saw themselves, and only that. If you did not look at a cycle before it was drawn, this page can show you a perfectly consistent set of numbers and you will still be taking our account of the timeline at face value. We would rather say that plainly than let the word "provable" do work it has not earned.
What to do about it: witness the commitments yourself
Open /verify/<period> while the cycle is still open — any time before its published Monday close. Copy down two values: the published seed hash and the drand round number. Put them somewhere we cannot reach. A note on your phone, a screenshot, an email to yourself; the timestamp on your own copy is what matters. After the draw, come back and check those same two values are still the ones on the page, then run the script above.
That is the whole ritual, and it is what turns our claim into your evidence. Everything downstream of those two values is mathematics you can re-run without us.
What is guaranteed once you have witnessed them
- The seed cannot be swapped. The revealed seed has to hash to the value you wrote down. One different byte and it does not.
- The entry list cannot be changed. The published list has to hash to the frozen entry-list hash, and the draw refuses to run against a list that no longer matches. An entry added, dropped or reordered shows up immediately.
- The randomness cannot be chosen by us. The round number you wrote down resolves to exactly one value at drand, and that value only came into existence after the entry list was frozen.
Those three hold against us even if we wanted them not to. The one thing outside them is whether the commitments really went up when we say they did — and if you recorded them at the time, you have settled that one for yourself too.
One file of Node, no dependencies.
Save this as verify.mjs, paste in the published values from any drawn cycle, and run it. It reproduces our draw engine step for step, checks the entry list against the frozen hash, and fetches the drand round from drand rather than from us.
// verify.mjs — recompute a 99 Losers draw yourself.
// Node 18 or newer. No dependencies. Run it with: node verify.mjs
import { createHash, createHmac } from "node:crypto";
// ---- 1. Paste the published values for the cycle, from /verify/<period> -----
const publishedSeedHash = "PASTE_SEED_HASH"; // published when the cycle OPENED
const revealedSeed = "PASTE_SEED"; // published after the draw ran
const frozenEntriesHash = "PASTE_FROZEN_HASH"; // published when entries CLOSED
const beaconRound = 0; // named when the cycle OPENED
const publishedBeacon = "PASTE_BEACON"; // that round's value, after close
const beaconSource = "PASTE_SOURCE"; // "drand" on a real draw
const claimedWinner = "PASTE_ENTRY_ID"; // the entry we say won
// ---- 2. Paste the frozen entry list from the same page ---------------------
const entries = [
{ ticket: 1, entryId: "ent_...", userId: "usr_..." },
// ...one row per ticket, exactly as published
];
// ---- 3. The seed must match the commitment made before anyone entered ------
const seedHash = createHash("sha256").update(revealedSeed).digest("hex");
console.log("seed commitment:", seedHash === publishedSeedHash ? "OK" : "MISMATCH");
// ---- 4. The entry list must match the hash published when entries closed ---
// Note what is deliberately absent from these bytes: whether an entry was paid
// for or free. Payment cannot touch the winner, by construction. This check is
// what stops an entry being added, dropped or reordered after the freeze.
const ordered = [...entries].sort((a, b) => a.ticket - b.ticket);
const canonical = ordered
.map((e) => e.ticket + "|" + e.entryId + "|" + e.userId)
.join("\n");
const entriesHash = createHash("sha256").update(canonical).digest("hex");
console.log("entry list: ", entriesHash === frozenEntriesHash ? "OK" : "MISMATCH");
console.log("entries hash: ", entriesHash);
// ---- 5. The randomness has to come from drand, not from us -----------------
// drand publishes a fresh value every 30 seconds on a fixed public schedule.
// The round above is the one published for this cycle; its value did not exist
// until an hour after entries closed. Fetch it from drand and compare — and if
// you recorded the round number while the cycle was open, compare that too.
const CHAIN = "8990e7a9aaed2ffed73dbd7092123d6f289930540d7651336225dc172e51b2ce";
let beacon = publishedBeacon;
try {
const url = "https://api.drand.sh/" + CHAIN + "/public/" + beaconRound;
const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
if (res.ok) {
const fetched = (await res.json()).randomness;
console.log("drand round: ", fetched === publishedBeacon ? "OK" : "MISMATCH");
if (beaconSource === "drand") beacon = fetched; // trust drand, never us
} else {
console.log("drand round: unavailable (HTTP " + res.status + ")");
}
} catch (error) {
console.log("drand round: could not fetch (" + error.message + ")");
}
if (beaconSource !== "drand") {
console.log("WARNING: beacon source is " + beaconSource + ", not drand — a local");
console.log(" development stand-in. This draw is NOT independently");
console.log(" verifiable: no third party holds that value.");
}
// ---- 6. Derive the winning index ------------------------------------------
// HMAC-SHA256 keyed with the seed, over entriesHash:beacon:counter. Rejection
// sampling: any digest at or above the largest exact multiple of n is thrown
// away and the counter bumped, so no ticket is favoured (a plain "digest % n"
// would quietly skew toward low ticket numbers).
const n = BigInt(ordered.length);
const SPACE = 2n ** 256n;
const limit = SPACE - (SPACE % n);
const key = Buffer.from(revealedSeed, "hex");
let index = -1;
for (let counter = 0; counter < 10000; counter += 1) {
const digest = createHmac("sha256", key)
.update(entriesHash + ":" + beacon + ":" + counter)
.digest("hex");
const value = BigInt("0x" + digest);
if (value < limit) { index = Number(value % n); break; } // else re-roll
}
// ---- 7. Compare ------------------------------------------------------------
const winner = ordered[index];
console.log("winning index: ", index);
console.log("winning entry: ", winner.entryId);
console.log(
winner.entryId === claimedWinner
? "PASS - the published winner is the one the maths picks"
: "FAIL - the published winner is not what recomputes",
);
The entry list, the seed, the seed hash, the frozen entry-list hash, the drand round and its value, and the winning entry id for each drawn cycle are all published on that cycle's page below.
week of 27 July 2026 — committed, not drawn
Published seed hash (commitment one)
dd96e5e1d5d1a6f88a165d6416281aeaf7d9a99863b7cad3be82f7c4de93dcc5
drand round that will decide this draw (commitment two)
6342927
Seed
— not published yet —
Entries are open. Both values above went up when the cycle opened, before the first entry was accepted. The seed stays secret until the draw, and the drand round has not happened yet — so right now nobody, us included, can work out who this cycle picks. This is the moment to copy both values down: recording them now is what lets you prove later that neither one moved.
Cycles you can verify
Each one publishes its seed, both of its commitments, the drand round that decided it, its frozen entry list and its winner.
- Verify →
week of 20 July 2026
27 entries · €23 paid out
- Verify →
week of 13 July 2026
24 entries · €19 paid out
- Verify →
week of 6 July 2026
21 entries · €17 paid out
- Verify →
week of 29 June 2026
18 entries · €14 paid out
- Verify →
week of 22 June 2026
15 entries · €11 paid out
- Verify →
week of 15 June 2026
12 entries · €10 paid out
The selection rules themselves are in the Official Rules, section 9. Payment status is not an input to the draw — it is not even in the bytes that get hashed.