@prizemart/safedraw
v2.0.2
Published
Transparent, verifiable, reproducible draws powered by the drand randomness beacon. The same code that runs a draw verifies a draw.
Downloads
597
Maintainers
Readme
@prizemart/safedraw
Transparent, verifiable, reproducible raffle draws powered by the drand randomness beacon.
Why
Most raffle systems use Math.random() or a seeded PRNG to pick winners. These approaches have two problems:
- Not verifiable — you have to trust the operator ran the code they claim to have run.
- Not reproducible — even if the code is public, without the seed you can never re-derive the same winner.
@prizemart/safedraw solves both by using the drand Quicknet beacon — a distributed randomness service run by the League of Entropy (Cloudflare, Protocol Labs, Ethereum Foundation, and ~12 others). Each beacon round produces a BLS threshold signature that:
- Is unpredictable before it fires
- Is public and permanent once it fires
- Is verifiable against a published public key
This means any draw result can be independently reproduced by anyone, forever — including by your users on their own phones.
How it works
The draw follows a commit-reveal model with three phases:
PUBLISH TIME CLOSE TIME DRAW TIME
─────────────────────────────────────────────────────────
Commit drand round → Lock entry pool → Fetch beacon
(before entries) Hash entries → Select winner
committed → Publish resultCommit the round at publish time — before any entries are accepted, you record which drand round will provide the randomness. This round is determined solely by the close date and is publicly known. Nobody — not even the operator — can change it after entries open.
Hash the entry pool at close time — when the raffle closes, every ticket number is sorted alphabetically and SHA-256 hashed. This fingerprint is published before the draw runs.
Fetch the beacon at draw time — the pre-committed drand round is fetched. The winner is selected:
BigInt(randomness) % entries.length. Anyone with the entry list and round number can reproduce this exact result.
Quick start
npm install @prizemart/safedrawimport { conductDraw } from '@prizemart/safedraw';
const result = await conductDraw({
entries: ['TICKET-001', 'TICKET-042', 'TICKET-107'],
expectedHash: 'a3f8...', // SHA-256 committed when raffle closed
drandRound: 1234567, // committed when raffle was published
});
console.log(result.winner); // 'TICKET-042'
console.log(result.selectionFormula); // 'BigInt("0xfe29...") % BigInt(3) = 1'
console.log(result.drand.randomness); // 'fe29...'
console.log(result.drand.signature); // BLS signature hex — verifiable against drand public keyCommitting the drand round at publish time
When you publish a raffle, compute the round that will fire at or just after the close date and store it:
import { computeDrandRound, QUICKNET_CHAIN_HASH } from '@prizemart/safedraw';
const closeTimestamp = Math.floor(new Date(raffle.close_date).getTime() / 1000);
const drand_committed_round = computeDrandRound(closeTimestamp);
// Store on your raffle record:
// { drand_committed_round, drand_chain_hash: QUICKNET_CHAIN_HASH }Hashing the entry pool at close time
When the raffle closes, hash the entry pool and store it before running the draw:
import { hashEntryPool, verifyEntryHash } from '@prizemart/safedraw';
const entries = tickets.map(t => t.ticket_number);
const entry_hash = await hashEntryPool(entries);
// Store entry_hash on the raffle record — this is the commitment.
// The draw uses this hash to verify the pool hasn't changed.Verifying a past draw
Given any published draw result, anyone can verify the winner independently:
Step 1 — Confirm your ticket was in the pool:
# entry list is published at GET /draws/{id}/entries
curl https://api.yourapp.com/draws/abc123/entries | jq '.[] | .ticket_number' | sortStep 2 — Verify the entry pool hash:
import { hashEntryPool } from '@prizemart/safedraw';
const entries = ['TICKET-001', 'TICKET-042', ...]; // from the published entry list
const hash = await hashEntryPool(entries);
console.log(hash === publishedHash); // trueStep 3 — Fetch the drand round:
curl https://api.drand.sh/52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971/public/1234567
# { "round": 1234567, "randomness": "fe29...", "signature": "b44..." }Step 4 — Reproduce the winner:
import { selectWinner } from '@prizemart/safedraw';
const sorted = [...entries].sort((a, b) => a.localeCompare(b));
const { winner, winnerIndex, formula } = selectWinner(sorted, beacon.randomness);
console.log(winner); // reproduces the published winner
console.log(formula); // 'BigInt("0xfe29...") % BigInt(1500) = 742'Reproducing a winner from scratch (copy-paste script)
// reproduce-winner.mjs — run with Node.js 18+
import { conductDraw } from '@prizemart/safedraw';
const result = await conductDraw({
entries: [
// paste the full published entry list here
'TICKET-001',
'TICKET-042',
// ...
],
expectedHash: 'a3f8...', // from the draw result page
drandRound: 1234567, // from the draw result page
});
console.log('Winner:', result.winner);
console.log('Formula:', result.selectionFormula);
console.log('Verified hash:', result.entryHash);node reproduce-winner.mjs
# Winner: TICKET-042
# Formula: BigInt("0xfe29...") % BigInt(3) = 1
# Verified hash: a3f8...API reference
conductDraw(input: DrawInput): Promise<DrawOutput>
Main entrypoint. Verifies the entry hash, fetches the pre-committed drand round, and returns the winner with full proof data.
Throws if the entry hash doesn't match, or if the drand round cannot be fetched.
hashEntryPool(ticketNumbers: string[]): Promise<string>
Returns SHA-256(sorted_ticket_numbers.join(',')) as a lowercase hex string. Sorts the input internally.
verifyEntryHash(ticketNumbers: string[], expectedHash: string): Promise<boolean>
Returns true if hashEntryPool(ticketNumbers) === expectedHash.
selectWinner(sortedEntries: string[], randomnessHex: string): WinnerSelection
Synchronous. Computes BigInt('0x' + randomnessHex) % BigInt(sortedEntries.length) and returns the winner, its index, and a human-readable formula string.
sortedEntries must already be sorted — sorting is not applied here.
computeDrandRound(closeTimestampSeconds: number): number
Returns the first drand Quicknet round that fires at or after closeTimestampSeconds. Call this at raffle publish time and store the result.
fetchDrandRound(round: number, options?: DrandOptions): Promise<DrandBeacon>
Fetches a specific round from the drand Quicknet beacon, failing over across the official endpoints.
QUICKNET_CHAIN_HASH
'52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971' — the Quicknet chain hash. Store this alongside drand_committed_round so the chain is explicit in your records.
Types
interface DrawInput {
entries: string[]; // ticket numbers (sorted internally)
expectedHash: string; // SHA-256 committed when raffle closed
drandRound: number; // committed at raffle publish time
options?: DrandOptions;
}
interface DrawOutput {
winner: string;
winnerIndex: number;
totalEntries: number;
entryHash: string;
drand: {
round: number;
randomness: string; // hex — sha256(signature)
signature: string; // BLS threshold signature hex
chainHash: string;
};
selectionFormula: string; // e.g. 'BigInt("0xfe29...") % BigInt(1500) = 742'
}
interface DrandOptions {
chainHash?: string; // defaults to QUICKNET_CHAIN_HASH
apiUrls?: string[]; // defaults to 3 official drand endpoints
}Security model
Why drand?
crypto.randomInt() is cryptographically secure but not reproducible — you can't re-derive the same random number from public information. Users have to take the operator's word for it.
drand Quicknet solves this: each round produces a BLS threshold signature over the round number, signed by a distributed network of 15+ independent organisations (the League of Entropy). To produce a valid signature, a threshold majority must participate. No single party — including the raffle operator — can influence the output.
The commit-reveal property
The critical trust property is that the drand round is committed before any entries are accepted, not at draw time. This means:
- The operator cannot see the randomness and decide to open/close entry based on it
- The operator cannot cherry-pick a "better" round after seeing the entries
- The draw is fully determined at publish time — the only unknowns are who buys tickets
What you can independently verify
After any draw:
| Claim | How to verify |
|---|---|
| My ticket was in the pool | Hash the published entry list; compare to published hash |
| The hash was committed before the draw | Check timestamps on the raffle record |
| The winner was selected correctly | Fetch the drand round; run selectWinner locally |
| The randomness is authentic | Verify BLS signature against the drand public key |
Platform support
@prizemart/safedraw has zero runtime dependencies. It uses only:
fetch(Node.js 18+, browsers, React Native)globalThis.crypto.subtle— Web Crypto API (Node.js 18+, browsers, React Native/Hermes)BigInt(Node.js 10.4+, all modern browsers, React Native/Hermes)
This means the full conductDraw function runs on a user's phone — no server required. Users can verify any past draw result entirely locally.
Contributing
This library is intentionally minimal — zero dependencies, three pure functions, one async HTTP call. Contributions that add dependencies or server-side state will not be accepted.
Bug reports and PRs welcome.
License
MIT — see LICENSE.
