Every Spinn winner comes from math anyone can redo. Before the giveaway the server publishes the hash of its secret; afterwards it reveals the secret. If the hash matches, nobody touched the result.
Server seed
Random text generated by the server. It stays secret (only its hash is public) until the giveaway ends.
Client seed
Text chosen by the streamer in the giveaway room. It goes into the math together with the server’s.
Nonce
The round number in the giveaway: 1 for the first, going up with each redraw or prize.
// Verificador Provably Fair do Spinn. Roda inteiro no navegador (Web Crypto), sem falar com o
// servidor: recebe o JSON de uma rodada e refaz a conta. Este arquivo é mostrado na página
// /provablyfair como o código-fonte do verificador.
//
// hash = HMAC_SHA256(chave = serverSeed, mensagem = clientSeed + ":" + nonce)
// ticket = (16 primeiros dígitos hex do hash, como inteiro) mod (soma dos pesos do pool)
// ganhador = andando o pool em ordem e somando os pesos, o primeiro cuja soma passa do ticket
//
// O pool é a lista [["plataforma:usuario", peso], ...] em ordem alfabética da chave; o peso é 1,
// ou o número de entradas de sub quando o streamer dá peso pra sub.
import { tr } from '@/i18n';
export type PoolEntry = [key: string, weight: number];
export interface RollJson {
version: number;
raffleId?: string;
nonce: number;
kind?: 'seed' | 'physics';
clientSeed: string;
serverSeedHash: string;
serverSeed: string | null;
poolHash: string;
winner: string;
ticket: number | null;
pool: PoolEntry[];
}
export type CheckStatus = 'ok' | 'fail' | 'pending';
export interface Check { id: string; label: string; status: CheckStatus; detail: string }
export interface VerifyResult { checks: Check[]; ok: boolean; computedWinner: string | null; ticket: number | null; hash: string | null }
const enc = new TextEncoder();
const hex = (buf: ArrayBuffer) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
export async function sha256(text: string): Promise<string> {
return hex(await crypto.subtle.digest('SHA-256', enc.encode(text)));
}
export async function hmacSha256(key: string, message: string): Promise<string> {
const k = await crypto.subtle.importKey('raw', enc.encode(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
return hex(await crypto.subtle.sign('HMAC', k, enc.encode(message)));
}
export function pickFromHash(hash: string, pool: PoolEntry[]): { index: number; ticket: number; totalWeight: number } {
const totalWeight = pool.reduce((s, [, w]) => s + w, 0);
if (totalWeight <= 0) throw new Error('pool vazio');
const ticket = Number(BigInt('0x' + hash.slice(0, 16)) % BigInt(totalWeight));
let acc = 0;
for (let i = 0; i < pool.length; i++) {
acc += pool[i][1];
if (ticket < acc) return { index: i, ticket, totalWeight };
}
throw new Error('ticket fora do pool');
}
const isSorted = (pool: PoolEntry[]) => pool.every(([k], i) => i === 0 || pool[i - 1][0] < k);
// Lê o JSON colado e confere o formato antes de qualquer conta.
export function parseRoll(text: string): RollJson {
let data: unknown;
try { data = JSON.parse(text); } catch { throw new Error(tr('Isso não é um JSON válido.', 'That isn’t valid JSON.')); }
const r = data as Partial<RollJson>;
if (!r || typeof r !== 'object') throw new Error(tr('JSON sem os dados da rodada.', 'The JSON doesn’t have the round data.'));
if (typeof r.clientSeed !== 'string' || typeof r.serverSeedHash !== 'string' || typeof r.poolHash !== 'string'
|| typeof r.winner !== 'string' || !Number.isInteger(r.nonce) || !Array.isArray(r.pool)) {
throw new Error(tr('Faltam campos: clientSeed, serverSeedHash, nonce, poolHash, winner e pool são obrigatórios.', 'Missing fields: clientSeed, serverSeedHash, nonce, poolHash, winner and pool are required.'));
}
if (!r.pool.every((e) => Array.isArray(e) && typeof e[0] === 'string' && Number.isInteger(e[1]) && e[1] > 0)) {
throw new Error(tr('O pool tem de ser uma lista de ["plataforma:usuario", peso].', 'The pool must be a list of ["platform:username", weight].'));
}
return { version: 1, ticket: null, serverSeed: null, ...r } as RollJson;
}
export async function verifyRoll(roll: RollJson): Promise<VerifyResult> {
const checks: Check[] = [];
const push = (id: string, label: string, status: CheckStatus, detail: string) => checks.push({ id, label, status, detail });
// 1. A lista de participantes é a mesma que o servidor registrou na hora da rodada.
const poolHash = await sha256(JSON.stringify(roll.pool));
push('pool', tr('Lista de participantes', 'Entrant list'), poolHash === roll.poolHash ? 'ok' : 'fail',
poolHash === roll.poolHash
? tr(`SHA-256 da lista confere (${roll.pool.length} participantes).`, `The list's SHA-256 matches (${roll.pool.length} entrants).`)
: tr(`SHA-256 da lista deu ${poolHash}, o registrado é ${roll.poolHash}.`, `The list's SHA-256 is ${poolHash}, the recorded one is ${roll.poolHash}.`));
push('order', tr('Ordem da lista', 'List order'), isSorted(roll.pool) ? 'ok' : 'fail',
isSorted(roll.pool) ? tr('Em ordem alfabética, sem repetição.', 'Alphabetical, no repeats.') : tr('A lista tem de estar em ordem alfabética e sem repetição.', 'The list must be alphabetical with no repeats.'));
const inPool = roll.pool.some(([k]) => k === roll.winner);
push('member', tr('Ganhador estava no sorteio', 'Winner was in the giveaway'), inPool ? 'ok' : 'fail',
inPool ? tr(`${roll.winner} está na lista.`, `${roll.winner} is on the list.`) : tr(`${roll.winner} não está na lista.`, `${roll.winner} isn't on the list.`));
// Bolinhas: quem decide é a corrida no navegador, não os seeds. Não há conta pra refazer.
if (roll.kind === 'physics') {
push('physics', tr('Ganhador', 'Winner'), 'ok', tr('Rodada das Bolinhas: o ganhador é a 1ª bolinha a chegar na corrida (física), não sai dos seeds.', 'Marbles round: the winner is the first marble to finish the race (physics), not the seeds.'));
return { checks, ok: checks.every((c) => c.status === 'ok'), computedWinner: null, ticket: null, hash: null };
}
// 2. O serverSeed revelado é o mesmo que foi prometido (pelo hash) antes da rodada.
if (!roll.serverSeed) {
push('seed', 'Server seed', 'pending', tr('Ainda em segredo: o servidor revela quando o sorteio acaba. Até lá dá pra conferir só a lista.', 'Still secret: the server reveals it when the giveaway ends. Until then only the list can be checked.'));
return { checks, ok: false, computedWinner: null, ticket: null, hash: null };
}
const seedHash = await sha256(roll.serverSeed);
push('seed', 'Server seed', seedHash === roll.serverSeedHash ? 'ok' : 'fail',
seedHash === roll.serverSeedHash
? tr('SHA-256 do seed revelado é igual ao hash publicado antes do sorteio.', 'The SHA-256 of the revealed seed equals the hash published before the giveaway.')
: tr(`SHA-256 do seed revelado deu ${seedHash}, diferente do publicado.`, `The SHA-256 of the revealed seed is ${seedHash}, different from the published one.`));
// 3. Refaz o sorteio: HMAC → ticket → ganhador.
const hash = await hmacSha256(roll.serverSeed, `${roll.clientSeed}:${roll.nonce}`);
const { index, ticket, totalWeight } = pickFromHash(hash, roll.pool);
const computed = roll.pool[index][0];
push('winner', tr('Ganhador recalculado', 'Recomputed winner'), computed === roll.winner ? 'ok' : 'fail',
computed === roll.winner
? tr(`Ticket ${ticket} de ${totalWeight} caiu em ${computed}, o mesmo ganhador do sorteio.`, `Ticket ${ticket} of ${totalWeight} landed on ${computed}, the same winner as the giveaway.`)
: tr(`Ticket ${ticket} de ${totalWeight} caiu em ${computed}, mas o sorteio anunciou ${roll.winner}.`, `Ticket ${ticket} of ${totalWeight} landed on ${computed}, but the giveaway announced ${roll.winner}.`));
if (roll.ticket != null && roll.ticket !== ticket) {
push('ticket', 'Ticket', 'fail', tr(`O ticket registrado (${roll.ticket}) é diferente do recalculado (${ticket}).`, `The recorded ticket (${roll.ticket}) differs from the recomputed one (${ticket}).`));
}
return { checks, ok: checks.every((c) => c.status === 'ok'), computedWinner: computed, ticket, hash };
}
For anyone who saved a round’s JSON (the winner’s "View JSON" button). The math runs in your browser, without talking to Spinn’s server.