Todo ganhador do Spinn sai de uma conta que qualquer pessoa pode refazer. Antes do sorteio o servidor publica o hash do seu segredo; depois revela o segredo. Se o hash bate, ninguém mexeu no resultado.
Server seed
Texto aleatório gerado pelo servidor. Fica em segredo (só o hash é público) até o sorteio acabar.
Client seed
Texto escolhido pelo streamer na sala do sorteio. Entra na conta junto com o do servidor.
Nonce
O número da rodada no sorteio: 1 no primeiro, e sobe a cada re-sorteio ou prêmio.
// 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 };
}
Pra quem guardou o JSON de uma rodada (botão "Ver JSON" do ganhador). A conta roda no seu navegador, sem falar com o servidor do Spinn.