Sistema Provably Fair
O sistema Provably Fair garante que todos os resultados de jogos (abertura de caixas, upgrades, batalhas, sorteios) sao justos, verificaveis e nao podem ser manipulados nem pelo site nem pelo jogador.
Visao Geral
O sistema usa HMAC-SHA256 como funcao de hash criptografica para gerar resultados determinísticos a partir de tres inputs:
- Server Seed: Gerado pelo servidor, mantido em segredo ate ser revelado
- Client Seed: Fornecido pelo usuario (ou gerado automaticamente)
- Nonce: Contador sequencial que incrementa a cada aposta
O resultado e um numero inteiro no range 1 a 10.000.000 (dez milhoes).
Constantes
const HASH_MAX = 10000000; // Range maximo do roll
const HASH_MIN = 1; // Range minimo do roll
const HASH_PREFIX_LENGTH = 13; // Caracteres do hash usados para gerar o numeroArquivos do Codigo
| Arquivo | Responsabilidade |
|---|---|
src/application/services/provably-fair.service.ts | Servico principal com todas as funcoes |
src/application/use-cases/provably-fair/verify-seed.use-case.ts | Use case de verificacao de seed |
src/application/use-cases/provably-fair/calculate-result.use-case.ts | Use case de calculo de resultado |
src/presentation/controllers/provably-fair.controller.ts | Controller com 4 endpoints publicos |
src/application/dto/provably-fair.dto.ts | DTOs de validacao |
Servico: ProvablyFairService
generateServerSeed()
Gera um server seed aleatorio de 32 bytes (64 caracteres hex).
generateServerSeed(): string {
return randomBytes(32).toString('hex');
}Saida: String hexadecimal de 64 caracteres (ex: "a1b2c3d4e5f6...")
hashServerSeed(seed)
Gera o hash HMAC-SHA256 do server seed. Este hash e revelado ao usuario ANTES da aposta como commitment (compromisso). O usuario pode verificar depois que o server seed revelado gera este mesmo hash.
hashServerSeed(seed: string): string {
return createHmac('sha256', seed).digest('hex');
}Input: Server seed em texto Saida: Hash HMAC-SHA256 em hexadecimal (64 caracteres)
generateClientSeed(userInput?)
Gera ou aceita um client seed do usuario.
generateClientSeed(userInput?: string): string {
if (userInput && userInput.length > 0) {
return userInput;
}
return randomBytes(32).toString('hex');
}Comportamento:
- Se o usuario fornecer input: usa o input diretamente
- Se nao fornecer: gera
32 bytes aleatorios(64 caracteres hex, ex:"a1b2c3d4e5f6...")
calculateRoll(serverSeed, clientSeed, nonce, max?)
Funcao principal - Calcula o roll provably fair.
calculateRoll(
serverSeed: string,
clientSeed: string,
nonce: number,
max: number = 10000000,
): number {
// 1. Criar HMAC-SHA256 usando serverSeed como chave
const hmac = createHmac('sha256', serverSeed);
hmac.update(`${clientSeed}:${nonce}`);
const hash = hmac.digest('hex');
// 2. Extrair primeiros 13 caracteres do hash
const hashPrefix = hash.substring(0, 13);
// 3. Converter para numero decimal
const decimal = parseInt(hashPrefix, 16);
// 4. Verificar overflow (proteção contra bias)
const maxSafeValue = Math.floor(Number.MAX_SAFE_INTEGER / max) * max;
let result = decimal;
if (result >= maxSafeValue) {
// Retry com string diferente para evitar bias
const hmac2 = createHmac('sha256', serverSeed);
hmac2.update(`${clientSeed}:${nonce}:retry`);
const hash2 = hmac2.digest('hex');
result = parseInt(hash2.substring(0, 13), 16);
}
// 5. Calcular roll no range [1, max]
const roll = (result % max) + 1;
return roll;
}Passo a passo detalhado:
- HMAC-SHA256: Usa
serverSeedcomo chave secreta e"${clientSeed}:${nonce}"como mensagem - Hash prefix: Pega os primeiros 13 caracteres hexadecimais do hash resultante
- Conversao: Converte o prefix hex para um numero decimal (
parseInt(prefix, 16))- 13 caracteres hex = ate 2^52 = 4.503.599.627.370.496 (dentro de
Number.MAX_SAFE_INTEGER)
- 13 caracteres hex = ate 2^52 = 4.503.599.627.370.496 (dentro de
- Overflow check: Se o numero >=
maxSafeValue, faz retry com"${clientSeed}:${nonce}:retry"para evitar modulo biasmaxSafeValue = floor(MAX_SAFE_INTEGER / max) * max- Para max=10000000:
maxSafeValue = floor(9007199254740991 / 10000000) * 10000000 = 9007199250000000 - Probabilidade de retry: ~0.00005% (extremamente rara)
- Roll final:
(result % max) + 1= numero de 1 a max (padrao: 1 a 10.000.000)
verifyResult(serverSeed, clientSeed, nonce, expectedRoll, max?)
Verifica se um roll e valido recalculando-o.
verifyResult(
serverSeed: string,
clientSeed: string,
nonce: number,
expectedRoll: number,
max: number = 10000000,
): boolean {
const calculatedRoll = this.calculateRoll(serverSeed, clientSeed, nonce, max);
return calculatedRoll === expectedRoll;
}verifyServerSeedHash(serverSeed, serverSeedHash)
Verifica se o server seed corresponde ao hash revelado antes da aposta.
verifyServerSeedHash(serverSeed: string, serverSeedHash: string): boolean {
const calculatedHash = this.hashServerSeed(serverSeed);
return calculatedHash === serverSeedHash;
}Commitment Scheme (Esquema de Compromisso)
O sistema usa um esquema de compromisso de 3 etapas para garantir justica:
Etapa 1: Pre-Aposta
- Servidor gera
serverSeedaleatorio - Servidor calcula
serverSeedHash = HMAC-SHA256(serverSeed) - Servidor revela APENAS o
serverSeedHashao usuario - Usuario pode definir seu
clientSeed(opcional)
Etapa 2: Aposta
- Servidor usa
serverSeed,clientSeeddo usuario enoncepara calcular o roll - O roll determina o resultado do jogo
serverSeedainda nao e revelado
Etapa 3: Verificacao
- Apos a aposta, o
serverSeede revelado ao usuario - Usuario pode verificar:
HMAC-SHA256(serverSeed) === serverSeedHash(prova que o seed nao foi alterado)calculateRoll(serverSeed, clientSeed, nonce) === roll registrado(prova que o resultado e correto)
Por que e justo:
- O servidor nao pode alterar o
serverSeedapos mostrar o hash (qualquer alteracao muda o hash) - O usuario pode influenciar o resultado via
clientSeed(nao depende so do servidor) - O
noncegarante resultados diferentes para cada aposta mesmo com mesmos seeds
Uso em Cada Sistema
Abertura de Caixas
// open-case.use-case.ts
const roll = this.provablyFairService.calculateRoll(serverSeed, clientSeed, nonce);
// roll: 1 a 10.000.000
// Item selecionado quando roll esta dentro do range
for (const caseItem of items) {
if (roll >= caseItem.hashRangeStart && roll <= caseItem.hashRangeEnd) {
return caseItem.item; // Este e o item ganho
}
}Hash ranges: Cada item tem hashRangeStart e hashRangeEnd que cobrem o range 1-10.000.000 proporcionalmente a sua probabilidade. Items baratos = ranges grandes (alta probabilidade). Items raros = ranges pequenos (baixa probabilidade).
Sistema FLIP (Segunda Roleta)
// flipThreshold e um valor configurado por caixa (Case.flipThreshold, nullable)
const isFlip = this.flipService.shouldTriggerFlip(roll, flipThreshold); // roll >= flipThreshold
if (isFlip) {
// NAO ha segundo roll nem seeds derivados.
// O MESMO roll seleciona o item no tier raro (hashRangeStart >= flipThreshold).
const flipItems = allItems.filter((ci) => ci.hashRangeStart >= flipThreshold);
const wonItem = this.flipService.selectFlipWinner(flipItems, roll);
// flipServerSeed/flipClientSeed/flipNonce/flipRoll sao gravados como null no CaseOpening
}Upgrades
// perform-upgrade.use-case.ts
const roll = this.provablyFairService.calculateRoll(serverSeed, clientSeed, nonce);
// Calculo do threshold de sucesso
// Se chance e 20%, o threshold e 80% do range (8.000.000)
const successThreshold = 10000000 - (chancePercent / 100) * 10000000;
// Sucesso quando roll >= threshold (ultimos X% do range)
const success = roll >= successThreshold;
// Exemplo: chance 20%, threshold = 8000000
// roll = 9500000 -> 9500000 >= 8000000 -> SUCESSO
// roll = 3000000 -> 3000000 >= 8000000 -> FALHABatalhas
// execute-battle.use-case.ts
// Cada player em cada round gera seus PROPRIOS seeds e nonce (nao ha seed compartilhado)
for (const round of rounds) {
for (const player of players) {
const serverSeed = this.provablyFairService.generateServerSeed();
const clientSeed = this.provablyFairService.generateClientSeed();
const nonce = await this.nonceService.getNextNonce(player.userId);
const roll = this.provablyFairService.calculateRoll(
serverSeed, clientSeed, nonce
);
// roll determina o item dropado para este player neste round
}
}Sorteios (Raffles)
// draw-raffle.use-case.ts
// Um roll por premio (prizes ordenados por displayOrder)
for (const prize of prizes) {
const serverSeed = this.provablyFairService.generateServerSeed(); // proprio por premio
const nonce = prize.displayOrder + 1; // clientSeed compartilhado
const roll = this.provablyFairService.calculateRoll(
serverSeed, clientSeed, nonce, eligibleTickets.length, // max = nº de tickets elegiveis
);
const winningIndex = roll % eligibleTickets.length;
const winningTicket = eligibleTickets[winningIndex];
// vencedores anteriores sao excluidos de eligibleTickets
}Endpoints da API
Todos os 4 endpoints sao publicos (decorator @Public()) - qualquer pessoa pode verificar resultados sem autenticacao.
POST /provably-fair/generate-seeds
Gera um par de seeds para uso externo/testes.
Body (GenerateSeedsDto):
{
"clientSeed": "minha-seed-opcional" // opcional
}Validacao:
clientSeed:@IsOptional(),@IsString()
Response (ProvablyFairResponseDto):
{
"serverSeedHash": "a1b2c3d4e5f6...",
"clientSeed": "minha-seed-opcional"
}Nota: O serverSeed NAO e retornado (apenas o hash). O seed real seria revelado apos uso.
POST /provably-fair/verify-seed
Verifica se um server seed corresponde ao hash informado.
Body (VerifySeedDto):
{
"serverSeed": "a1b2c3d4e5f6...",
"expectedHash": "hash-que-foi-mostrado-antes"
}Validacao:
serverSeed:@IsString(), obrigatorioexpectedHash:@IsString(), obrigatorio
Response:
{
"serverSeed": "a1b2c3d4e5f6...",
"serverSeedHash": "hash-que-foi-mostrado-antes",
"clientSeed": "",
"valid": true
}POST /provably-fair/calculate-result
Calcula o roll a partir dos inputs.
Body (CalculateResultDto):
{
"serverSeed": "a1b2c3d4e5f6...",
"clientSeed": "client-seed-do-usuario",
"nonce": 42,
"max": 10000000 // opcional, default 10000000
}Validacao:
serverSeed:@IsString(), obrigatorioclientSeed:@IsString(), obrigatoriononce:@IsNumber(), obrigatorio, transformado via@Type(() => Number)max:@IsOptional(),@IsNumber(),@Min(1),@Max(10000000)
Response:
{
"serverSeed": "a1b2c3d4e5f6...",
"serverSeedHash": "hash-calculado",
"clientSeed": "client-seed-do-usuario",
"nonce": 42,
"roll": 7385294,
"max": 10000000
}Nota: A response inclui o serverSeedHash recalculado e o roll resultante. O max no response mostra 100000 como default no controller (divergencia com o service que usa 10000000 - documentar como inconsistencia).
POST /provably-fair/verify-result
Verifica se um resultado e valido.
Body (VerifyResultDto):
{
"serverSeed": "a1b2c3d4e5f6...",
"clientSeed": "client-seed-do-usuario",
"nonce": 42,
"expectedRoll": 7385294,
"max": 10000000 // opcional
}Validacao:
serverSeed:@IsString(), obrigatorioclientSeed:@IsString(), obrigatoriononce:@IsNumber(), obrigatorioexpectedRoll:@IsNumber(), obrigatoriomax:@IsOptional(),@IsNumber(),@Min(1),@Max(10000000)
Response:
{
"serverSeed": "a1b2c3d4e5f6...",
"serverSeedHash": "hash-calculado",
"clientSeed": "client-seed-do-usuario",
"nonce": 42,
"roll": 7385294,
"max": 10000000,
"valid": true
}Campos:
roll: O roll recalculado pelo servidorvalid:trueseroll === expectedRoll
Nonce - Contador Sequencial
O nonce e um numero que incrementa a cada aposta, garantindo resultados diferentes mesmo com mesmos seeds.
Como o Nonce e Calculado
| Sistema | Calculo do Nonce |
|---|---|
| Abertura de Caixa | NonceService.getNextNonce(userId) -> redis.incr('nonce:{userId}') |
| Upgrade | NonceService.getNextNonce(userId) (mesmo contador do usuario) |
| Batalha | NonceService.getNextNonce(player.userId) por player/round (mesmo contador) |
| FLIP | Sem nonce proprio - usa o MESMO roll (e nonce) da abertura |
| Sorteio | prize.displayOrder + 1 (um por premio, clientSeed compartilhado) |
O contador de nonce e por usuario e monotonico (nonce.service.ts): redis.incr('nonce:{userId}'). E compartilhado por todos os jogos que usam o NonceService (case, battle, upgrade), garantindo que o mesmo usuario nunca repita um nonce.
Nonce para Abertura Multipla
Quando o usuario abre ate 100 caixas de uma vez (executeMultiple), cada abertura puxa um novo nonce sequencial do mesmo contador Redis:
// Cada abertura puxa um nonce novo e monotonico
for (let i = 0; i < quantity; i++) {
const serverSeed = this.provablyFairService.generateServerSeed();
const clientSeed = this.provablyFairService.generateClientSeed(clientSeedInput);
const nonce = await this.nonceService.getNextNonce(userId); // redis.incr por usuario
const roll = this.provablyFairService.calculateRoll(serverSeed, clientSeed, nonce);
}Como o nonce vem de redis.incr('nonce:{userId}'), cada abertura (unica ou multipla) recebe um valor unico e crescente, sem colisao. O FLIP nao consome nonce extra (usa o mesmo roll).
Seguranca
Por que HMAC-SHA256?
- Determinístico: Mesmos inputs sempre geram mesmo output
- Unidirecional: Impossivel reverter o hash para descobrir o input
- Resistente a colisoes: Praticamente impossivel encontrar dois inputs que gerem mesmo hash
- Keyed hash: Usa o serverSeed como chave, nao apenas como input (mais seguro que SHA-256 puro)
Por que 13 Caracteres do Hash?
- 13 caracteres hexadecimais = 52 bits
- 2^52 = 4.503.599.627.370.496 (4.5 quadrilhoes)
- Dividido por 10.000.000 = ~450 trilhoes de combinacoes possiveis
- Distribuicao extremamente uniforme
- Dentro do
Number.MAX_SAFE_INTEGERdo JavaScript (2^53 - 1)
Retry para Modulo Bias
Quando o numero decimal e maior que maxSafeValue, ha um pequeno bias no operador modulo. O retry com string diferente (":retry") elimina este bias:
- Probabilidade de retry para max=10.000.000: ~0.00005%
- O retry usa a mesma estrutura HMAC-SHA256 mas com input diferente
- Em caso extremamente raro de dois overflows consecutivos, o segundo resultado e usado sem retry adicional
Verificacao pelo Usuario
Passo a Passo Manual
- Apos a aposta, acesse a pagina de verificacao
- Insira o
serverSeed(revelado apos aposta),clientSeedenonce - O sistema recalcula o roll usando o mesmo algoritmo
- Compare o roll calculado com o roll da aposta
- Verifique que
HMAC-SHA256(serverSeed) === serverSeedHashmostrado antes
Verificacao Programatica
// Verificacao em JavaScript puro (pode rodar no browser)
const crypto = require('crypto');
function calculateRoll(serverSeed, clientSeed, nonce, max = 10000000) {
const hmac = crypto.createHmac('sha256', serverSeed);
hmac.update(`${clientSeed}:${nonce}`);
const hash = hmac.digest('hex');
const hashPrefix = hash.substring(0, 13);
const decimal = parseInt(hashPrefix, 16);
const maxSafeValue = Math.floor(Number.MAX_SAFE_INTEGER / max) * max;
let result = decimal;
if (result >= maxSafeValue) {
const hmac2 = crypto.createHmac('sha256', serverSeed);
hmac2.update(`${clientSeed}:${nonce}:retry`);
const hash2 = hmac2.digest('hex');
result = parseInt(hash2.substring(0, 13), 16);
}
return (result % max) + 1;
}
// Exemplo
const roll = calculateRoll(
'abc123serverSeed',
'myClientSeed',
42
);
console.log('Roll:', roll); // 1 a 10000000Inconsistencias Conhecidas
Default de max no controller: O endpoint
POST /provably-fair/calculate-resultretornamax: dto.max || 100000no response (100.000), mas o service usamax = 10000000como default. Isso pode confundir quem verifica o resultado.Server seed nao persistido no generate-seeds: O endpoint
POST /provably-fair/generate-seedsgera o serverSeed mas nao o salva em nenhum lugar. O serverSeedHash e retornado mas o seed real e descartado. Este endpoint parece ser apenas para demonstracao/testes.
Debugging
Se houver problemas com probabilidades:
- Verificar range: Todos os
hashRangeEnddas caixas devem terminar em 10.000.000 - Verificar roll: O roll deve estar sendo calculado com
max = 10.000.000 - Verificar threshold: O
successThresholddo upgrade deve usar 10.000.000 - Verificar nonce: Nonces nao devem colidir entre aberturas/flips
// Logs de debug
console.log('[ProvablyFair] Server Seed:', serverSeed);
console.log('[ProvablyFair] Client Seed:', clientSeed);
console.log('[ProvablyFair] Nonce:', nonce);
console.log('[ProvablyFair] Hash:', hash);
console.log('[ProvablyFair] Hash Prefix (13 chars):', hashPrefix);
console.log('[ProvablyFair] Decimal:', decimal);
console.log('[ProvablyFair] Roll:', roll);