DEV Community

Yuval Abu
Yuval Abu

Posted on

We built a prize draw engine where you never have to trust us. Here is how

The problem with "trust me, I picked randomly"

Every online prize draw has the same weak point. Someone announces a winner, and the only evidence is their word. A spinning wheel, a screen recording, a spreadsheet with a random formula: none of it can be checked after the fact. You can re-run a wheel as many times as you like until it lands on the name you want, and nobody watching ever sees the attempts that did not make the cut.

We spent a while building DrawSeal, a platform for running prize draws where the winner is not just announced, it is provably the outcome of a process fixed in advance. Not "fixed and then revealed", which is what most tools with a random seed already do. Fixed before the randomness that decides it exists, in a way any third party can check without asking us anything.

This post is about the actual mechanism, not the product pitch. The whole engine that computes winners is open source (Apache-2.0), published as @drawseal-com/verify on npm and mirrored at github.com/drawseal/verify. If you want to skip the explanation and just read the code, that is the repo.

Three commitments, in a specific order

A fair draw needs three things fixed before the result exists, and the order between them matters as much as the values themselves.

1. The server seed hash. Before a draw is even scheduled, DrawSeal generates a 32 byte random seed and publishes sha256(serverSeed). The seed itself stays secret until after the draw.

2. The drand round. DrawSeal commits to a specific round number of drand, a public randomness beacon run by an independent consortium (Cloudflare, EPFL, Protocol Labs and others). The round is scheduled far enough in the future that its randomness genuinely does not exist yet at commitment time. Drand publishes a new, unpredictable, publicly verifiable random value every round, signed with BLS threshold signatures.

3. The participant pool seal. At registration close, the participant list is reduced to a Merkle root and published, along with the pool size. This is the one people forget. Without it, the first two commitments guarantee nothing: someone could still add or remove entries after seeing which drand round will decide the draw, without contradicting either published hash.

The strict rule enforced in code, sealRound < drandRound, is a plain integer comparison anyone can redo with the published values alone:

export function sealPrecedesRandomness(sealRound: number, drandRound: number): boolean {
  if (!Number.isInteger(sealRound) || !Number.isInteger(drandRound)) {
    return false;
  }
  return sealRound < drandRound;
}
Enter fullscreen mode Exit fullscreen mode

Put together: by the time the participant list is frozen, the randomness that will decide the winner is not yet knowable by anyone, including us.

Picking the winner, deterministically

Once the drand round fires and the server seed is revealed, computing the winner is pure arithmetic, no server call, no hidden state:

export function computeRankFinalHash(
  serverSeed: string,
  drandValue: string,
  rank: number,
  participantsFingerprint: string,
): string {
  return sha256Hex(`${serverSeed}|${drandValue}|${rank}|${participantsFingerprint}`);
}
Enter fullscreen mode Exit fullscreen mode

That hash then selects a participant from the sealed pool, weighted by entry count, using a cumulative interval walk over BigInt (a Number sum would lose precision past 2^53 and could shift the selected interval by a hair, which is not acceptable when the winner has to be exact, not merely probable):

const total = canonical.reduce((sum, p) => sum + BigInt(p.weight), 0n);
const target = BigInt('0x' + finalHash) % total;

let cumulative = 0n;
for (const participant of canonical) {
  cumulative += BigInt(participant.weight);
  if (target < cumulative) return participant;
}
Enter fullscreen mode Exit fullscreen mode

The modulo bias here is negligible: with a 256 bit hash, it is bounded by total / 2^256. For multi-winner draws, the same rank formula runs in a cascade: derive the hash for rank 1, pick, remove that participant from the pool, derive the hash for rank 2 on the reduced pool, and so on. Fully deterministic, fully replayable.

The subtle attack this design has to defeat

Here is the part that took the most thought, and the reason "the drand signature is valid" is not, by itself, a sufficient check.

A valid BLS signature on a drand beacon only proves the beacon genuinely came from the drand network. It does not prove it is the specific round the platform committed to. Imagine an operator with write access to the database: they could leave serverSeed and its published hash untouched (so that commitment still checks out), and simply substitute the randomness for that of a different, already-past drand round, chosen after the fact so the computation lands on whoever they want. Every other check would still pass.

What closes that hole is checking round number and randomness value together against the original commitment, not signature validity alone. That is what the verifier's beaconOk check does, and it is the one control that makes the other six meaningless without it.

Verifying it yourself, not just reading about it

The point of all this falls apart if verification requires trusting the same company that ran the draw. So the checker is a separate, standalone package, not an endpoint on our servers:

import { verifyDraw } from '@drawseal-com/verify';

const result = verifyDraw({
  commitment, // published BEFORE the draw: seed hash + drand round
  seal,       // published at registration close: Merkle root + round
  beacon,     // the drand beacon, revealed after
  reveal,     // seed, randomness, participants, winner count
  claimedWinners,
});

console.log(result.ok ? 'valid' : 'MISMATCH');
Enter fullscreen mode Exit fullscreen mode

It runs seven checks (seed hash, pool seal, seal ordering, beacon authenticity and identity, recomputed winners, recomputed redraws, and the raw recomputed values to compare against external witnesses yourself), and ok is only true if all seven are. It never throws on a mismatch either, a mismatch is a result to display, not an exception to catch.

It is isomorphic (Node and browser), and deliberately has only two dependencies, both from the same author: @noble/hashes and @noble/curves.

One more design choice worth mentioning: the verifier refuses to conclude anything when it is asked to check a document produced by a newer algorithm version than the one it supports. A verifier that silently applies stale rules to a document it does not actually understand would compute a different winner and report "fraud" on a perfectly honest draw, which is worse than having no verifier at all.

Witnesses outside our own control

Open sourcing the checker still leaves one gap: what stops someone from publishing a different npm package under a similar name that always says "valid"? Two answers. First, only the @drawseal-com npm scope and github.com/drawseal/verify are ours (there is an unrelated, unscoped drawseal package on npm since 2018, a Chinese seal-drawing library with nothing to do with any of this, which is exactly why we scoped ours). Second, and more importantly, every draw is also anchored outside anything we control: an OpenTimestamps proof in the Bitcoin chain for pure antecedence, and a mirror in a public repo (one file per draw, full history) so the published document itself cannot be quietly edited after the fact without it showing.

Where to look

Happy to answer questions on the design in the comments, including the parts I glossed over here (redraws on unclaimed prizes, the anti-fraud filtering that runs before the pool is sealed, or the Merkle proof each participant gets so they can check their own entry was counted without downloading the whole list).

Top comments (0)