After a round settles, you can check how its numbers were drawn. Your browser recalculates the seed from the round’s recorded inputs, rebuilds every reel number from it and checks each stop. For a sealed reel it also checks the seed against the seal the round published and, if this browser played the round, the seal it saw before the last stop.
You can find the round ID on the game’s result receipt.
The verifier sorts each player’s commitment and reveal by commitment, then combines those values with the round’s drand beacon data and the server’s random input. It hashes the encoded inputs with SHAKE256 to reproduce the 64-byte seed.
A sealed reel publishes a seal, a SHA3-256 hash of the seed, before anyone stops, and the seed itself only after the last stop. A seed that matches the seal cannot have been changed once the stops were known. Each stop on a sealed reel is also shifted by an amount that comes from the seed, so stopping when someone else did does not copy their number.
/**
* HNW randomizer — frontend port.
*
* This is a 4th port of the canonical Go reference, sitting alongside
* `verification/reference.{go,py,ts}`. It exists because the frontend's
* `@noble/hashes` major version differs from the verification package's
* and the `./sha3` subpath moved between the two; sharing one TS port
* across both consumers wasn't clean. Output must stay bit-identical
* with the other three ports — run `npm run test` in verification/ if
* you touch canonicalize/deriveSeed/sequence and re-check vectors.
*
* Spec: docs/RANDOMIZER.md
*/
import { shake256, sha3_256 } from '@noble/hashes/sha3.js';
export const DOMAIN_TAG = new TextEncoder().encode('HNW/v1/round-seed');
export const SEED_COMMITMENT_TAG = new TextEncoder().encode('HNW/v1/seed-commitment');
export const SEALED_FRAME_TAG = new TextEncoder().encode('HNW/v1/sealed-frame');
export const DRAND_RANDOMNESS_SIZE = 32;
export const SERVER_ENTROPY_SIZE = 32;
export const COMMIT_SIZE = 32;
export const REVEAL_SIZE = 32;
export const SEED_SIZE = 64;
export interface Inputs {
drandRound: bigint;
drandRandomness: Uint8Array;
serverEntropy: Uint8Array;
commits: Uint8Array[];
reveals: Uint8Array[];
degraded: boolean;
}
/** Hex JSON bundle shape as served by `GET /api/rounds/:id/verification`. */
export interface BundleHex {
drand_round: number;
drand_randomness_hex: string;
server_entropy_hex: string;
commits_hex: string[];
reveals_hex: string[];
degraded: boolean;
}
/** Rehydrates Inputs from the hex-encoded backend response. */
export function inputsFromBundle(b: BundleHex): Inputs {
if (b.commits_hex.length !== b.reveals_hex.length) {
throw new Error('commits/reveals length mismatch');
}
return {
drandRound: BigInt(b.drand_round),
drandRandomness: hexToBytes(b.drand_randomness_hex),
serverEntropy: hexToBytes(b.server_entropy_hex),
commits: b.commits_hex.map(hexToBytes),
reveals: b.reveals_hex.map(hexToBytes),
degraded: b.degraded
};
}
/**
* Returns the exact canonical byte string. Sorts (commit, reveal) pairs
* by commit ascending so callers can pass them in any order.
*/
export function canonicalize(i: Inputs): Uint8Array {
if (i.commits.length !== i.reveals.length) {
throw new Error('commits and reveals must be the same length');
}
for (const b of [i.drandRandomness, i.serverEntropy]) {
if (b.length !== 32) throw new Error('byte field must be 32 bytes');
}
for (const c of i.commits) if (c.length !== 32) throw new Error('commit must be 32 bytes');
for (const r of i.reveals) if (r.length !== 32) throw new Error('reveal must be 32 bytes');
const n = i.commits.length;
if (n > 0xffffffff) throw new Error('too many players');
const idx = Array.from({ length: n }, (_, k) => k);
idx.sort((a, b) => compareBytes(i.commits[a], i.commits[b]));
const chunks: Uint8Array[] = [];
chunks.push(DOMAIN_TAG);
chunks.push(u64BE(i.drandRound));
chunks.push(i.drandRandomness);
chunks.push(i.serverEntropy);
chunks.push(u32BE(n));
for (const k of idx) chunks.push(i.commits[k]);
for (const k of idx) chunks.push(i.reveals[k]);
chunks.push(new Uint8Array([i.degraded ? 0x01 : 0x00]));
return concatBytes(chunks);
}
/** Returns SHAKE256(canonicalize(i), 64). */
export function deriveSeed(i: Inputs): Uint8Array {
return shake256(canonicalize(i), { dkLen: SEED_SIZE });
}
/**
* Returns `count` numbers, each `numDigits` digits wide, from a SHAKE256
* stream keyed by `seed` (one byte per digit, bytes >= 250 rejected).
* Matches `Sequence` in verification/reference.go. Capped at 15 digits so
* every number stays exact in a JS number.
*/
export function sequence(seed: Uint8Array, numDigits: number, count: number): number[] {
if (seed.length !== SEED_SIZE) throw new Error(`seed must be ${SEED_SIZE} bytes`);
if (!(numDigits >= 1 && numDigits <= 15)) throw new Error('numDigits must be in [1, 15]');
if (count < 0) throw new Error('count must be >= 0');
// shake256 has no streaming reader here; a longer output always starts
// with the shorter one, so the buffer can simply be regrown.
let stream = shake256(seed, { dkLen: Math.max(64, numDigits * count * 2) });
let idx = 0;
const out: number[] = [];
for (let n = 0; n < count; n++) {
let acc = 0;
for (let d = 0; d < numDigits; d++) {
for (;;) {
if (idx >= stream.length) stream = shake256(seed, { dkLen: stream.length * 2 });
const b = stream[idx++];
if (b < 250) {
acc = acc * 10 + (b % 10);
break;
}
}
}
out.push(acc);
}
return out;
}
/**
* SHA3-256("HNW/v1/seed-commitment" || seed): the seal a sealed round shows
* before anyone stops. Matches `SeedCommitment` in verification/reference.go.
*/
export function seedCommitment(seed: Uint8Array): Uint8Array {
if (seed.length !== SEED_SIZE) throw new Error(`seed must be ${SEED_SIZE} bytes`);
const msg = new Uint8Array(SEED_COMMITMENT_TAG.length + seed.length);
msg.set(SEED_COMMITMENT_TAG, 0);
msg.set(seed, SEED_COMMITMENT_TAG.length);
return sha3_256(msg);
}
/**
* The shift a sealed reel adds to a tapped or timed-out stop:
* uint64_BE(SHA3-256("HNW/v1/sealed-frame" || seed || player_id || candidate_id)[0:8]) mod length.
* Matches `SealedFrameOffset` in verification/reference.go.
*/
export function sealedFrameOffset(seed: Uint8Array, playerId: Uint8Array, candidateId: Uint8Array, length: number): number {
if (seed.length !== SEED_SIZE) throw new Error(`seed must be ${SEED_SIZE} bytes`);
if (playerId.length !== 16 || candidateId.length !== 16) throw new Error('ids must be 16 bytes');
if (!(length > 0)) throw new Error('length must be positive');
const msg = new Uint8Array(SEALED_FRAME_TAG.length + SEED_SIZE + 32);
msg.set(SEALED_FRAME_TAG, 0);
msg.set(seed, SEALED_FRAME_TAG.length);
msg.set(playerId, SEALED_FRAME_TAG.length + SEED_SIZE);
msg.set(candidateId, SEALED_FRAME_TAG.length + SEED_SIZE + 16);
const digest = sha3_256(msg);
let head = 0n;
for (let i = 0; i < 8; i++) head = (head << 8n) | BigInt(digest[i]);
return Number(head % BigInt(length));
}
/** 16 bytes of a canonical UUID; an absent id is all zeros. */
export function uuidBytes(id: string | undefined): Uint8Array {
return id ? hexToBytes(id.replace(/-/g, '')) : new Uint8Array(16);
}
/** Public proof fields the browser checks after a round. */
export interface ProofHex extends BundleHex {
num_digits: number;
seed_hex: string;
sequence: number[];
proof_version?: number;
sealed?: boolean;
seed_commitment_hex?: string;
flash_ms?: number;
locks: {
player_id?: string;
candidate_id?: string;
sequence_index: number;
locked_number: number;
tap_ms: number;
lock_kind?: string;
}[];
}
/**
* Each check is true (passed), false (failed) or null (does not apply to
* this round, e.g. a round played before seals existed).
*/
export interface ProofChecks {
/** The seed recomputed from the round's inputs equals the published seed. */
seed: boolean;
/** The published seed matches the seal published with the round. */
seal: boolean | null;
/** The published seed matches the first seal this browser saw before the last stop. */
sealSeen: boolean | null;
/** Every reel number follows from the seed. */
numbers: boolean | null;
/** Every locked number is the reel number at its stop. */
locks: boolean | null;
recomputedSeedHex: string;
}
/** Runs every check the proof allows. `seen` is the seal this browser saw. */
export function checkProof(p: ProofHex, seen: string | null = null): ProofChecks {
const seed = deriveSeed(inputsFromBundle(p));
const recomputedSeedHex = bytesToHex(seed);
const seedOk = recomputedSeedHex === p.seed_hex.toLowerCase();
const sealHex = bytesToHex(seedCommitment(seed));
const v2 = (p.proof_version ?? 1) >= 2 && !!p.seed_commitment_hex;
let numbers: boolean | null = null;
let locks: boolean | null = null;
if (p.num_digits <= 15 && p.sequence.length > 0) {
const expected = sequence(seed, p.num_digits, p.sequence.length);
numbers = expected.every((n, i) => n === p.sequence[i]);
locks = p.locks.every((l) => {
if (l.sequence_index < 0 || l.sequence_index >= expected.length) return false;
if (expected[l.sequence_index] !== l.locked_number) return false;
// v2 proofs also fix which frame each stop time lands on. A sealed
// reel shifts tapped and timed-out stops by a per-player, per-option
// offset; assigned frames are not shifted.
if (v2 && p.flash_ms && p.flash_ms > 0) {
let frame = Math.floor(l.tap_ms / p.flash_ms);
if (p.sealed && l.lock_kind !== 'assigned') {
frame += sealedFrameOffset(seed, uuidBytes(l.player_id), uuidBytes(l.candidate_id), expected.length);
}
return frame % expected.length === l.sequence_index;
}
return true;
});
}
return {
seed: seedOk,
seal: v2 ? sealHex === p.seed_commitment_hex!.toLowerCase() : null,
sealSeen: seen ? sealHex === seen.toLowerCase() : null,
numbers,
locks,
recomputedSeedHex
};
}
/** SHA3-256 of a reveal. */
export function commitFor(reveal: Uint8Array): Uint8Array {
if (reveal.length !== REVEAL_SIZE) throw new Error(`reveal must be ${REVEAL_SIZE} bytes`);
return sha3_256(reveal);
}
// ---------- byte helpers ----------
export function hexToBytes(s: string): Uint8Array {
const clean = s.startsWith('0x') ? s.slice(2) : s;
if (clean.length % 2 !== 0) throw new Error('odd hex length');
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) {
const byte = parseInt(clean.substring(i * 2, i * 2 + 2), 16);
if (Number.isNaN(byte)) throw new Error(`invalid hex: ${s.substring(i * 2, i * 2 + 2)}`);
out[i] = byte;
}
return out;
}
export function bytesToHex(b: Uint8Array): string {
const chars = '0123456789abcdef';
let out = '';
for (const v of b) out += chars[v >> 4] + chars[v & 0x0f];
return out;
}
function u64BE(n: bigint): Uint8Array {
const out = new Uint8Array(8);
const view = new DataView(out.buffer);
view.setBigUint64(0, n, false);
return out;
}
function u32BE(n: number): Uint8Array {
const out = new Uint8Array(4);
const view = new DataView(out.buffer);
view.setUint32(0, n, false);
return out;
}
function concatBytes(chunks: Uint8Array[]): Uint8Array {
let total = 0;
for (const c of chunks) total += c.length;
const out = new Uint8Array(total);
let off = 0;
for (const c of chunks) {
out.set(c, off);
off += c.length;
}
return out;
}
function compareBytes(a: Uint8Array, b: Uint8Array): number {
const n = Math.min(a.length, b.length);
for (let i = 0; i < n; i++) if (a[i] !== b[i]) return a[i] - b[i];
return a.length - b.length;
}
For a shared-link draw, open the verification link on the results page to see the record of each draw.