JSPM

@prizemart/safedraw

2.0.2
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 17
  • Score
    100M100P100Q88158F
  • License MIT

Transparent, verifiable, reproducible draws powered by the drand randomness beacon. The same code that runs a draw verifies a draw.

Package Exports

  • @prizemart/safedraw

Readme

@prizemart/safedraw

Transparent, verifiable, reproducible raffle draws powered by the drand randomness beacon.

League of Entropy npm License: MIT


Why

Most raffle systems use Math.random() or a seeded PRNG to pick winners. These approaches have two problems:

  1. Not verifiable — you have to trust the operator ran the code they claim to have run.
  2. 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 result
  1. Commit 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.

  2. 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.

  3. 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/safedraw
import { 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 key

Committing 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' | sort

Step 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); // true

Step 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.