5 August 2026 · Burnsie · 30 min read
How we built a giveaway bot you don't have to trust
Every online giveaway asks you to take someone's word for it. The organiser says they picked a winner at random, and you either believe them or you don't. There's usually no way to tell the difference between a fair draw and a rigged one, and that's a bad position to put your own community in.
So when we built the bot that runs giveaways in our Discord, we set ourselves a harder goal than "be fair". We wanted the draw to be fair in a way anyone can check for themselves, without trusting us, and without taking our word for anything. This is how it works and what we learned building it.
The problem in one sentence
If the organiser controls the randomness, the organiser controls the winner.
Everything else follows from that. A Math.random() call on our server is
worthless as evidence, because nobody can see it happen. Even a genuinely
honest draw looks identical to a dishonest one from the outside. And it
isn't only outright fraud you have to design against: it's the softer
version, where you run the draw, dislike the result, find a reason to
discount it, and run it again.
Randomness nobody can control
Instead of generating randomness ourselves, we use a public randomness beacon called drand, run by the League of Entropy. It's a network of independent organisations that jointly produce a new random value every three seconds, and publishes it for anyone to fetch.
Two properties make it useful here.
The first is that the values are unpredictable. Nobody knows what round number 40 million will be until it happens.
The second, and the one that really matters, is that they are unbiasable. The beacon uses BLS threshold signatures, which are deterministic: for a given round number and a given network key, exactly one valid signature exists. There is nothing to choose between. Even the organisations producing the randomness cannot steer it, because there's no alternative value for them to prefer. They can only produce the one correct answer or fail to produce anything.
That's a much stronger guarantee than "we used a good random number generator".
Verifying a round is a BLS pairing check against the network's public key. The key and the chain identifier are hardcoded constants in our source, obtained from drand's documentation rather than fetched at runtime — a check that asks a relay for the key it should be verified against proves nothing, because a malicious relay would simply serve a self-consistent fake chain.
export function verifyBeaconSignature(
round: number,
signatureHex: string,
publicKeyHex: string = QUICKNET.publicKey,
): boolean {
try {
const message = sha256(roundBuffer(round));
const hashedMessage = bls.G1.ProjectivePoint.fromAffine(
bls.G1.hashToCurve(message, { DST }).toAffine(),
);
const publicKey = bls.G2.ProjectivePoint.fromHex(publicKeyHex);
const signature = bls.G1.ProjectivePoint.fromHex(signatureHex);
// e(S, G2) * e(H(m), -P) == 1
const left = bls.pairing(signature, bls.G2.ProjectivePoint.BASE, true);
const right = bls.pairing(hashedMessage, publicKey.negate(), true);
return bls.fields.Fp12.eql(bls.fields.Fp12.mul(left, right), bls.fields.Fp12.ONE);
} catch {
return false;
}
}The try/catch returning false is not laziness. A tampered signature
is often rejected while it is being decoded — "bad point: not in
prime-order subgroup" — rather than by the pairing check itself. Both
outcomes mean the same thing to a caller, and letting one of them throw
while the other returns a boolean is how you end up with a code path that
crashes instead of refusing.
Note also that the round number is hashed into the signed message. That's what stops a relay replaying a different round's genuine signature: the signature is only valid for the round it was produced for.
Committing before the dice are thrown
Public randomness alone isn't enough. If we waited to see the random value and then decided which entrants to include, we'd still control the outcome.
So the bot commits to both inputs before either can be influenced:
- When a giveaway is created, it calculates which future drand round will decide it, and publishes that round number in the channel. That round has not been generated yet. Nobody on earth knows its value, including us.
- When entries close, it freezes the eligible entrant list, publishes the full list, and publishes a SHA-256 fingerprint of it. This happens while the committed round is still in the future.
- After the round is published, the bot fetches it from several independent relays, verifies its signature against a public key hardcoded in our source, and uses it to shuffle the frozen list.
Picking the round is simple arithmetic on the beacon's genesis time and period, with one detail that matters: it has to be the first round emitted strictly after the deadline, never the round in progress at that moment.
/** The most recent round emitted at or before `timeMs`. */
export function roundAt(timeMs: number): number {
const elapsed = timeMs - QUICKNET.genesisTimeSeconds * 1000;
if (elapsed < 0) return 1;
return Math.floor(elapsed / (QUICKNET.periodSeconds * 1000)) + 1;
}
/**
* The first round emitted strictly after `timeMs`. This is what a giveaway
* commits to: because the round has not been produced yet, its value cannot be
* known by anyone, including us, at the moment we publish the commitment.
*/
export function firstRoundAfter(timeMs: number): number {
return roundAt(timeMs) + 1;
}Get that off by one wrong and you commit to a round that already exists, which quietly destroys the whole guarantee while looking completely fine. There's a test asserting the committed round's emission time is always greater than the deadline, for exactly that reason.
The ordering is the entire argument. The entrant list is fixed and public before the randomness that selects from it exists. The randomness cannot be influenced by anyone. Therefore the result cannot be steered toward or away from anybody.
Making the draw itself reproducible
Verifiable randomness is no use if the step from "random value" to "winner" is a black box. So that step is a pure function of published inputs:
randomness = sha256(signature)
seedMessage = "<version>|<giveawayId>|<entrantListFingerprint>|<winnerCount>"
seed = HMAC-SHA256(key = randomness, message = seedMessage)
Binding the giveaway ID, the entrant fingerprint and the winner count into the seed means one drand round cannot be quietly reused to produce a second, differently framed draw.
What's actually in the published list
The fingerprint is a hash of the entrant list in a canonical form. Before getting to that form, it's worth saying what the list contains, because it isn't Discord IDs.
Verification needs the committed pool to be public — you cannot check a fingerprint against a list you can't see. It does not need the pool to be readable. Publishing a plain list of Discord IDs for every giveaway would accumulate into a searchable record of who enters what, which is not something anyone signed up for by reacting to a message. So each entrant appears as a keyed hash of their ID instead:
export function hashEntrant(salt: string, entrantId: string): string {
assertEntrantSalt(salt);
if (!ENTRANT_ID.test(entrantId)) {
throw new Error(`entrant id ${JSON.stringify(entrantId)} is not a Discord snowflake`);
}
return createHmac('sha256', Buffer.from(salt, 'hex')).update(entrantId, 'utf8').digest('hex');
}The salt is 32 random bytes, minted once when entries close and frozen alongside everything else in the commitment — the database rejects any later change to it, because reissuing the salt would reorder the pool and so amount to a dial on the outcome. It is published in the proof, because otherwise you could not compute your own hash to check you were included, and being able to do that is most of the point.
Publishing the salt is also the limit of what this buys. Anyone already holding a list of candidate IDs can hash them and test for membership, and there is no way around that while entrants can still self-check. What it prevents is the pool being read off directly, and since the salt differs per giveaway, the same person's hash never repeats across draws.
Everything downstream runs on those hashes: the fingerprint, the seed, the shuffle. Discord IDs stay in our database and appear in nothing we publish, with one deliberate exception. The winners' IDs go out in the clear, since they're announced by name anyway, and including them lets you check something you otherwise couldn't:
const winnersHashCorrectly =
proof.result.winnerIds.length === proof.result.winnerHashes.length &&
proof.result.winnerIds.every(
(id, index) => hashEntrant(proof.pool.salt, id) === proof.result.winnerHashes[index],
);Without that step the ranked order could be entirely honest while the announcement named somebody the draw never selected.
The canonical form matters for the same reason it always did — restricting values to a fixed alphabet is what keeps the newline-joined encoding unambiguous:
/**
* Restricting the pool to hex is a correctness requirement, not decoration: the
* canonical form joins values with newlines, so a value containing a newline or
* a pipe would make the serialisation ambiguous and two different pools could
* share one hash.
*/
export function canonicaliseEntrantHashes(hashes: readonly string[]): string[] {
for (const hash of hashes) {
if (!SHA256_HEX.test(hash)) {
throw new Error(`entrant hash ${JSON.stringify(hash)} is not 64 lowercase hex characters`);
}
}
return [...new Set(hashes)].sort(compareCodePoints);
}
/** The exact bytes that get hashed: canonical hashes joined by \n, no trailing newline. */
export function serialiseEntrants(canonicalHashes: readonly string[]): string {
return canonicalHashes.join('\n');
}Sorting is by Unicode code point rather than anything clever, because
that's the default string ordering in every mainstream language — a third
party re-running the draw with sorted(hashes) in Python or hashes.sort()
in JavaScript gets the same list without reading our source. Deduplication
matters too: it means a duplicated row could never become a second entry
for the same person.
From that seed we generate a deterministic byte stream and shuffle the entrant list with Fisher-Yates. Two details in there are worth calling out, because both are easy to get subtly wrong.
Modulo bias. The obvious way to pick a number below n is value % n.
That's biased whenever n doesn't divide the sample space evenly, and it
hands low-indexed entrants a very slightly better chance. It's a small
effect, and in a prize promotion it's still a defect. We use rejection
sampling instead: discard any sample at or above the largest exact multiple
of n and draw again.
export function randomBelow(stream: HmacStream, bound: number): number {
if (!Number.isInteger(bound) || bound <= 0) {
throw new Error(`bound must be a positive integer, received ${bound}`);
}
if (bound === 1) return 0;
const bitsNeeded = BigInt(bound - 1).toString(2).length;
const byteLength = Math.ceil(bitsNeeded / 8);
const space = 1n << BigInt(byteLength * 8);
const bigBound = BigInt(bound);
const limit = space - (space % bigBound);
// Each iteration rejects with probability < 1/2, so this cap is unreachable
// in practice; it exists so a bug can never become an infinite loop.
for (let attempt = 0; attempt < 10_000; attempt += 1) {
const sample = BigInt(`0x${stream.bytes(byteLength).toString('hex')}`);
if (sample < limit) return Number(sample % bigBound);
}
throw new Error('rejection sampling failed to terminate');
}limit is always at least half of space, so each attempt succeeds with
probability greater than one half and termination is immediate in practice.
The iteration cap exists purely so that a future bug can't turn this into
an infinite loop inside a draw.
One shuffle, not several draws. A single pass produces a complete ranked ordering of every entrant, not just the winners.
export function seededShuffle<T>(items: readonly T[], stream: HmacStream): T[] {
const result = [...items];
for (let i = result.length - 1; i > 0; i -= 1) {
const j = randomBelow(stream, i + 1);
const atI = result[i] as T;
const atJ = result[j] as T;
result[i] = atJ;
result[j] = atI;
}
return result;
}The direction and the bound are part of the published algorithm, not
incidental: iterating upward, or drawing j from [0, n) instead of
[0, i], would produce a different — and in the second case biased —
permutation from the same seed. Anyone reimplementing the verification has
to match this exactly.
The first few names are the winners and everyone after them is a reserve, in order. That means if a winner can't be contacted or is later disqualified, the replacement was already decided by the same draw. There is never a reason to run a second one, which removes the most tempting avenue for interference.
Letting the database refuse
We could have written "don't allow a second draw" as a rule in the application code. Application code changes, though, and a rule that lives only in code is a rule someone can quietly work around.
Instead, the constraints live in Postgres:
- The draw results table has one row per giveaway, enforced by a primary key. A second draw is rejected by the database, not by a conditional.
- Triggers reject any
UPDATE,DELETEorTRUNCATEon the draw results, the audit log, and the record of staff eligibility decisions. They are append-only in the strict sense: correcting a decision means adding a new row, so the full history of who decided what and why survives. - Once a giveaway's committed round or entrant list is written, triggers reject any attempt to change them. A drawn giveaway cannot be reopened. A closed one has its entry window and winner count frozen.
The append-only part is about as blunt as SQL gets:
create or replace function forbid_mutation() returns trigger
language plpgsql set search_path = '' as $$
begin
raise exception 'relation %.% is append-only; % is not permitted',
tg_table_schema, tg_table_name, tg_op
using errcode = 'restrict_violation';
end;
$$;
create trigger draw_results_no_update
before update on draw_results
for each row execute function forbid_mutation();
create trigger draw_results_no_delete
before delete on draw_results
for each row execute function forbid_mutation();
create trigger draw_results_no_truncate
before truncate on draw_results
for each statement execute function forbid_mutation();The truncate trigger is easy to forget and has to be statement-level rather
than row-level, because TRUNCATE doesn't fire row triggers at all.
Without it, the table you carefully made immutable can still be emptied in
one statement.
The immutability guard is a little more interesting, because it has to allow a value to be written once and never again:
create or replace function giveaways_guard_commitments() returns trigger
language plpgsql set search_path = '' as $$
begin
if new.drand_round <> old.drand_round or new.drand_chain_hash <> old.drand_chain_hash then
raise exception 'the committed drand round for giveaway % is immutable', old.id
using errcode = 'restrict_violation';
end if;
if old.entrant_set_hash is not null
and new.entrant_set_hash is distinct from old.entrant_set_hash then
raise exception 'the entrant set hash for giveaway % is immutable once committed', old.id
using errcode = 'restrict_violation';
end if;
if old.status = 'drawn' and new.status <> 'drawn' then
raise exception 'giveaway % has already been drawn and cannot be reopened', old.id
using errcode = 'restrict_violation';
end if;
return new;
end;
$$;is distinct from rather than <> is deliberate: NULL <> 'anything'
evaluates to NULL, not true, so a comparison written the obvious way
would let the check pass silently in exactly the case you most want it to
fire.
There's a script in the repository that proves each of these against a live database by attempting all of them and checking every one is refused. It runs inside a transaction and rolls back, so it can be pointed at production. Two dozen guarantees, and every one has to report a pass:
PASS a draw result cannot be modified
PASS a draw result cannot be deleted
PASS a draw result cannot be truncated away
PASS a second draw cannot be recorded for the same giveaway
PASS the committed drand round is immutable
PASS the entrant set hash is immutable once committed
PASS the committed entrant pool is immutable
PASS an entrant cannot be appended to the committed pool
PASS a drawn giveaway cannot be reopened
PASS one entry per person is enforced
PASS audit log entries cannot be modified
PASS eligibility decisions cannot be rewritten
...
Here's the shape of it:
| Attack | What refuses it |
|---|---|
| Re-roll until a favoured name appears | Primary key on the results table; the draw command returns the stored result instead of drawing again |
| Edit the entrant list after seeing the result | List and fingerprint are published before the randomness exists, and the database rejects changes to them |
| Pick a more favourable round | The round is committed at creation and is immutable |
| Fake the random value | The signature is verified against a pinned public key, and the round number is part of the signed message, so another round's signature can't be replayed |
| Weight the draw toward someone | The draw function receives nothing but a set of IDs. There is no weighting input to abuse |
| Delete the evidence | Append-only triggers |
Deciding who's eligible without building a surveillance system
Our giveaways ask entrants to be genuine participants in the community, not drive-by accounts that react to a post and leave. Checking that means looking at message activity, which raises an obvious question: how much of people's conversation are we hoovering up?
The answer is none of it. The bot never stores message content.
For each message it keeps the message ID, the channel, the author, the timestamp, the length, the word count, and a hash of the normalised text. That's enough to count activity and to detect duplicates and copy-paste, and it's not enough to reconstruct what anyone said. When staff need to review someone's messages, the bot generates Discord jump links, so the text stays in Discord where it already lives and where the author can still delete it.
The same instinct decided what the proof publishes. Verifiability forced the entrant list into the open; it didn't force the names into the open, which is why the pool goes out as salted hashes and only the winners — who are announced anyway — appear as IDs.
The hash is the interesting part. It's computed over an aggressively normalised form of the text, because the cheap ways to defeat a duplicate check are all cosmetic: changing capitalisation, adding punctuation, stretching letters. All of those collapse to the same value.
We won't publish the exact tuning of the anti-abuse signals, and that's a deliberate line. The fairness machinery above is published in full, because publishing it is what makes it verifiable — its security doesn't depend on secrecy at all. Fraud heuristics are the opposite: their value drops the moment someone knows precisely what to sit underneath. The rules that determine whether a message counts are published to entrants in the giveaway terms, where they belong. The signals we use to spot manipulation are not.
One principle there is worth stating publicly, though: none of those signals ever disqualify anyone automatically. They raise a flag for a human to look at. Wrongly excluding a real member of the community is a worse outcome than making a staff member read a few messages, so a suspicious pattern blocks a giveaway from closing until someone has actually looked, and whatever they decide is recorded with a mandatory reason.
The promise that shaped the whole design
Our giveaway terms say that once you meet the activity requirement, sending more messages does not improve your chances of winning.
That single sentence rules out a lot of designs. It means activity can only ever be a gate on entry, never a weighting on the draw. You cannot earn extra tickets.
The cleanest way to guarantee that isn't a policy document, it's the type signature:
export interface DrawInput {
giveawayId: string;
winnerCount: number;
salt: string;
/** Raw ids, supplied by the bot. */
entrantIds?: readonly string[];
/** Published hashes, supplied by anyone verifying from the proof alone. */
entrantHashes?: readonly string[];
beacon: BeaconInput;
/** Only ever disabled by tests that exercise the shuffle with synthetic seeds. */
verifySignature?: boolean;
}That's the entire input to the draw. There is no weight, no score, no multiplier and nowhere to put one. Activity is computed elsewhere, decides membership of the pool, and then is thrown away. A future change can't quietly introduce weighting without adding a field here, which is not the kind of edit that slips through unnoticed.
The two pool fields are mutually exclusive, and the distinction is load bearing. We pass IDs, since we hold them. A stranger checking the proof passes hashes, since that's all they have. Both paths run the same function and reach the same ranked order — that equivalence is one of the things the test suite asserts, because it's the property the whole publishing scheme rests on.
The one escape hatch is verifySignature, which exists because some tests
need to drive the shuffle with a synthetic seed rather than a real beacon.
It's worth noticing what it doesn't do: it can only skip a check, never
change the mapping from randomness to winners, and the code path that
actually runs a draw never sets it. If a flag like that could alter the
outcome, it would belong nowhere near this function.
There's a test for it too, which asserts that padding the input with forty copies of one entrant produces byte-identical output to the clean list — because the guarantee is worth checking, not just documenting.
It also means we can't quietly reward the most active members, which some people find counterintuitive for a community giveaway. We think it's the right trade. "Everyone who qualifies has exactly the same chance" is a promise people can actually understand and verify, and it doesn't turn the run-up to a giveaway into a spam competition.
Things that tried to break it
The interesting part of any build is what went wrong.
We nearly ran two bots at once. The bot runs as a single always-on process that holds one websocket connection to Discord. Two copies would process every message and every command twice, including a draw. Our hosting platform, by default, creates a paired standby machine for exactly this kind of service and starts it if it thinks the primary is unavailable, and some deployment strategies boot a second machine alongside the first during an update. None of that is wrong — it's sensible for a web service — but it's fatal here. We disabled it, and then didn't trust ourselves: the process now takes a session-scoped Postgres advisory lock before it connects to Discord, and refuses to start if it can't get one.
const conn = await sql.reserve();
const deadline = Date.now() + timeoutMs;
for (;;) {
const [row] = await conn`select pg_try_advisory_lock(${key}::bigint) as locked`;
if (row?.locked) return { release: /* ... */ };
if (Date.now() + intervalMs > deadline) {
throw new Error(
`another instance already holds the singleton lock for "${scope}". ` +
'Refusing to start a second bot.',
);
}
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}Advisory locks are the right primitive here because Postgres releases them automatically when the session dies. A hard crash doesn't leave the bot permanently locked out — which we verified by killing a connection mid-hold and watching a replacement take over about five seconds later. The retry loop matters for the same reason: a redeploy races the old process's teardown, and exiting immediately would turn every deployment into a crash loop under a restart policy that always restarts.
That's also the only mechanism that survives a network partition or a developer accidentally running the bot on their laptop against production. We tested it by starting a second copy against the live database and watching it refuse:
warn singleton lock held elsewhere, retrying attempt=1
warn singleton lock held elsewhere, retrying attempt=2
warn singleton lock held elsewhere, retrying attempt=3
The connection pooler lies about session state. We wanted the bot's tables in their own Postgres schema. The pooler rejected the standard way of pinning that, which was annoying but fine. Then we noticed something worse: it reuses server connections between clients without reliably resetting session state, so a setting changed by one client can be inherited by the next. We caught it because a diagnostic script reported a setting we had never applied on that connection — it had leaked in from an earlier one.
In practice that meant a migration could have built the entire schema in the wrong place, silently. The fix was to run migrations inside a single transaction, which pins them to one connection, normalise the setting there, and then assert where it's actually pointing before creating anything:
await db().begin(async (tx) => {
await tx`reset search_path`;
const [where] = await tx`select current_schema() as current`;
assertSchemaName(where?.current ?? null);
// ...create tables only after that has passed
});current_schema() is precisely the answer to "where would CREATE TABLE
put this", which makes it the right thing to assert on. Ordinary reads and
writes would have failed loudly on their own if the setting were wrong —
it's specifically DDL that fails quietly, by succeeding somewhere
unintended.
The real lesson is that "I set it, so it's set" is not a safe assumption behind a connection pooler, and that the failure mode worth defending against is the one that succeeds.
A test caught a real hole in our duplicate detection. Invisible Unicode characters are a classic way to defeat text matching: drop a zero-width space between every word and the string looks identical to a human but hashes differently. We were stripping those characters out, which seemed obviously correct and wasn't:
// What we wrote. Removes the character, joins the words together.
'free\u200Bnitro'.replace(INVISIBLE, ''); // "freenitro" !== "free nitro"
// What we needed. Substituting a space collapses both forms to the same text.
'free\u200Bnitro'.replace(INVISIBLE, ' '); // "free nitro" === "free nitro"Deleting the character defeats the evasion only if the words were already joined. Where the invisible character sits between words — which is exactly where someone would put it — deletion produces a third distinct string, and the evasion works anyway.
We only found it because the test asserted the behaviour we wanted ("these two should be detected as the same message") rather than the behaviour we'd implemented ("invisible characters are removed"). A test written the second way would have passed forever.
Rate limits punish the obvious design. We mirror every recorded action into a staff channel so there's a live view of what the bot is doing. The naive version posts one message per event, which works beautifully until a member sends messages faster than Discord will accept posts. High-frequency events are now aggregated into periodic summaries, sends are serialised behind a minimum interval, and the queue is bounded so a channel the bot loses access to can never grow memory without limit.
The part we can't make trustless, and won't pretend otherwise
The draw is beyond anyone's influence, including ours. Eligibility isn't.
Staff decide who is in the pool before it's locked. That decision is made by deterministic rules applied identically to every entrant, and every manual override is written to an append-only log with the actor, the timestamp, and a mandatory reason. But it isn't cryptographic, and it would be dishonest to imply otherwise. Anyone evaluating whether to trust a giveaway should know exactly where the mathematical guarantee ends and the human one begins.
What we can do is stop that decision being silent. The message that closes entries now publishes how many entrants were ruled out and which requirement each of them missed, counted by requirement:
14 entrant(s) did not meet the requirements and are not in the list above.
- Sent genuine, relevant messages during the giveaway period: 11
- Confirmed you are 18 or over and where you live: 4
- Still a member of the server: 2
That's checkable in the weak sense rather than the strong one — you can't recompute it from a beacon, and someone determined to lie could still lie. But it's posted before the deciding round exists, next to the entrant count it has to be consistent with, and it gives anyone who thinks they were wrongly excluded a number to point at and a window to argue in. Silence would have been easier and worse.
We'd rather say all of that plainly than oversell it.
Check it yourself
There is a page for this: /verify. Paste or upload the proof file from any draw and it recomputes the winners in front of you. If you have no proof file to hand, it will load a worked example.
Everything on that page runs in your browser. It fetches the winning random value straight from drand's public relays, checks the signature against a public key written into the page, and reruns the shuffle using the browser's own Web Crypto. None of it touches a Burnsies Hideout server. We could take this whole site down and you could still verify a draw from the proof file. What we cannot do is make a bad draw look valid.
The page is deliberately a second implementation of the algorithm rather than shared code with the bot. Two implementations that agree is much stronger evidence than one agreeing with itself, so it carries a frozen test vector from the bot's own test suite and runs it before it will verify anything. If the two ever drift apart it reports a failed self-test instead of quietly giving you a wrong answer.
Before the draw there is still something to check
A proof only exists after a draw, which leaves an awkward gap: while a giveaway is open, the whole fairness claim is about what will happen. So the page handles that case too.
Every giveaway message links to the verifier with the committed round in the query string. Before the draw, the page asks the relays directly whether that round exists yet:
PASS The deciding round falls after entries close
PASS The deciding round does not exist yet
4 relay(s) confirm it has not been generated, so its value cannot be
known by anyone
That is the fairness argument reduced to something you can confirm from four independent sources without asking us anything. The entrant list is already published, and the value that will select from it demonstrably does not exist yet.
Two honest caveats, both of which the page states itself. Query parameters are not self-authenticating, so anyone can craft a link with a different round number in it — what actually pins a commitment to a point in time is the announcement posted in the channel before entries closed, and the page tells you to compare the two. And it distinguishes "the round has not been generated" from "we could not reach the relays", because reporting the first when the truth is the second would be exactly the sort of false reassurance this page exists to avoid.
The command line version
After every draw we publish a proof file containing the drand round and its signature, the full entrant list, the fingerprint, the seed derivation, and the complete ranked result. The repository includes a standalone verifier that needs no database, no credentials and no trust in us:
npm run verify-draw -- proof.jsonIt re-fetches the round from the public relays, checks the signature against the pinned network key, confirms the entrant list matches the published fingerprint, and recomputes the winners. If any check fails, it says so and exits non-zero.
Verifying draw for: Example Giveaway (example-2026)
Entries closed: 2026-08-05T00:57:45.000Z
Entrants: 37
Prizes: 3
Checks
PASS Proof uses the expected drand chain
PASS Proof carries the same public key this verifier has pinned
PASS Round 31029667 fetched live and matches the proof (3 relay(s) agreed)
PASS Signature verifies against the pinned quicknet public key
PASS Published randomness equals sha256 of the signature
PASS Entrant list hashes to the published fingerprint
PASS Entrant count matches the list
PASS The randomness was published after entries closed
PASS The pool was frozen before the deciding value existed (900s before the round)
PASS Seed message matches
PASS Seed matches
PASS Recomputing the draw reproduces the identical ranked order
PASS Announced winners are the first entries of the ranked order
PASS Announced winner ids hash to the selected entries (3 winner(s))
VERIFIED. The published winners are exactly what the committed randomness
produces from the committed entrant list. No other outcome was possible,
and the organiser could not have influenced it.
Pass --me with your own Discord ID and it hashes it locally and tells you
where you placed, without sending anything anywhere.
The proof file itself is plain JSON, with every input needed to reach that result:
{
"format": "burnsies-giveaway-proof/2",
"randomness": {
"beacon": "quicknet",
"round": 31029667,
"roundEmittedAt": "2026-08-05T01:12:45.000Z",
"signature": "989014341bda3e21b132e98447cf1686d287e73a...",
"randomness": "60d95d26adb5ed6ce52fb88542938c11da7d65ac...",
"relaysAgreeing": ["https://api.drand.sh", "https://api2.drand.sh"]
},
"pool": {
"salt": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b...",
"entrantSetHash": "85e17a3a75cf4694d305964b9d84efb622c5dab8...",
"entrantCount": 37,
"entrantHashes": ["00d10fa2e667c871...", "1af9e73c126205a8...", "..."]
},
"derivation": {
"seedMessage": "burnsies-giveaway-v2|<giveawayId>|<entrantSetHash>|3",
"seedHex": "3ce8289982cae70e0c203bff615d31f4b8c20d6a..."
},
"result": {
"winnerHashes": ["3eb0cfb2b7cb9983...", "1af9e73c126205a8...", "..."],
"winnerIds": ["100000000000000032", "100000000000000018", "..."],
"rankedEntrantHashes": ["..."]
}
}We tested it the way you'd hope: by tampering. Altering an entrant, flipping a single bit of the signature, swapping an announced winner, and backdating the round so it predated the entry deadline. Each was caught by exactly the check you'd want to catch it.
The proof also spells out the algorithm in words, so you can reimplement it in whatever language you like rather than running our code.
The two verifiers take deliberately opposite approaches, and both are worth having. The command line one shares the bot's own draw function, so what it checks is precisely the code that ran. The web page reimplements the algorithm from scratch, so when the two agree it rules out a bug that a verifier sharing the same mistake would happily confirm. Sharing code proves faithfulness; reimplementing proves correctness. The frozen test vector is what keeps the two honest.
The stack, briefly
TypeScript on Node, discord.js for the gateway, Postgres for storage, and BLS verification via a well-audited pure-JavaScript elliptic curve library. It runs as a single small always-on container, costing a few dollars a month.
Around ninety automated tests cover it, and the ones we care most about are in the draw: a frozen golden vector so a published proof can never silently stop verifying, chi-square tests confirming the shuffle and the sampler are actually uniform across tens of thousands of trials, and negative tests that reject flipped signature bytes, wrong round numbers and mismatched keys. The round arithmetic and signature verification are cross-checked against the reference drand client, so two independent implementations have to agree before anything passes.
Verification runs against a real drand round captured from the network, not a mock. Mocks are excellent at confirming you've faithfully reproduced your own misunderstanding.
Why bother
This is a lot of engineering for giving away some game keys.
But the alternative is asking a community to trust us because we said so, and "trust us" is exactly what every scam giveaway also says. Being able to point at a page and say here, check it yourself, you don't need my permission and you don't need to believe me is worth the effort. It's also worth it for us: there is no way for a future staff member to rig a draw, no way for one to be accused of it without evidence either way, and no awkward conversation about whether a result was real.
If you run giveaways for a community, we'd encourage you to consider something similar. The randomness is free, the verification is a few dozen lines, and the hard part isn't the cryptography — it's deciding to give up the ability to choose the winner.