Skip to content

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:

  1. Server Seed: Gerado pelo servidor, mantido em segredo ate ser revelado
  2. Client Seed: Fornecido pelo usuario (ou gerado automaticamente)
  3. Nonce: Contador sequencial que incrementa a cada aposta

O resultado e um numero inteiro no range 1 a 10.000.000 (dez milhoes).

Constantes

typescript
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 numero

Arquivos do Codigo

ArquivoResponsabilidade
src/application/services/provably-fair.service.tsServico principal com todas as funcoes
src/application/use-cases/provably-fair/verify-seed.use-case.tsUse case de verificacao de seed
src/application/use-cases/provably-fair/calculate-result.use-case.tsUse case de calculo de resultado
src/presentation/controllers/provably-fair.controller.tsController com 4 endpoints publicos
src/application/dto/provably-fair.dto.tsDTOs de validacao

Servico: ProvablyFairService

generateServerSeed()

Gera um server seed aleatorio de 32 bytes (64 caracteres hex).

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

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

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

typescript
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:

  1. HMAC-SHA256: Usa serverSeed como chave secreta e "${clientSeed}:${nonce}" como mensagem
  2. Hash prefix: Pega os primeiros 13 caracteres hexadecimais do hash resultante
  3. 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)
  4. Overflow check: Se o numero >= maxSafeValue, faz retry com "${clientSeed}:${nonce}:retry" para evitar modulo bias
    • maxSafeValue = floor(MAX_SAFE_INTEGER / max) * max
    • Para max=10000000: maxSafeValue = floor(9007199254740991 / 10000000) * 10000000 = 9007199250000000
    • Probabilidade de retry: ~0.00005% (extremamente rara)
  5. 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.

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

typescript
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

  1. Servidor gera serverSeed aleatorio
  2. Servidor calcula serverSeedHash = HMAC-SHA256(serverSeed)
  3. Servidor revela APENAS o serverSeedHash ao usuario
  4. Usuario pode definir seu clientSeed (opcional)

Etapa 2: Aposta

  1. Servidor usa serverSeed, clientSeed do usuario e nonce para calcular o roll
  2. O roll determina o resultado do jogo
  3. serverSeed ainda nao e revelado

Etapa 3: Verificacao

  1. Apos a aposta, o serverSeed e revelado ao usuario
  2. 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 serverSeed apos mostrar o hash (qualquer alteracao muda o hash)
  • O usuario pode influenciar o resultado via clientSeed (nao depende so do servidor)
  • O nonce garante resultados diferentes para cada aposta mesmo com mesmos seeds

Uso em Cada Sistema

Abertura de Caixas

typescript
// 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)

typescript
// 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

typescript
// 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 -> FALHA

Batalhas

typescript
// 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)

typescript
// 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):

json
{
  "clientSeed": "minha-seed-opcional"  // opcional
}

Validacao:

  • clientSeed: @IsOptional(), @IsString()

Response (ProvablyFairResponseDto):

json
{
  "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):

json
{
  "serverSeed": "a1b2c3d4e5f6...",
  "expectedHash": "hash-que-foi-mostrado-antes"
}

Validacao:

  • serverSeed: @IsString(), obrigatorio
  • expectedHash: @IsString(), obrigatorio

Response:

json
{
  "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):

json
{
  "serverSeed": "a1b2c3d4e5f6...",
  "clientSeed": "client-seed-do-usuario",
  "nonce": 42,
  "max": 10000000  // opcional, default 10000000
}

Validacao:

  • serverSeed: @IsString(), obrigatorio
  • clientSeed: @IsString(), obrigatorio
  • nonce: @IsNumber(), obrigatorio, transformado via @Type(() => Number)
  • max: @IsOptional(), @IsNumber(), @Min(1), @Max(10000000)

Response:

json
{
  "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):

json
{
  "serverSeed": "a1b2c3d4e5f6...",
  "clientSeed": "client-seed-do-usuario",
  "nonce": 42,
  "expectedRoll": 7385294,
  "max": 10000000  // opcional
}

Validacao:

  • serverSeed: @IsString(), obrigatorio
  • clientSeed: @IsString(), obrigatorio
  • nonce: @IsNumber(), obrigatorio
  • expectedRoll: @IsNumber(), obrigatorio
  • max: @IsOptional(), @IsNumber(), @Min(1), @Max(10000000)

Response:

json
{
  "serverSeed": "a1b2c3d4e5f6...",
  "serverSeedHash": "hash-calculado",
  "clientSeed": "client-seed-do-usuario",
  "nonce": 42,
  "roll": 7385294,
  "max": 10000000,
  "valid": true
}

Campos:

  • roll: O roll recalculado pelo servidor
  • valid: true se roll === 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

SistemaCalculo do Nonce
Abertura de CaixaNonceService.getNextNonce(userId) -> redis.incr('nonce:{userId}')
UpgradeNonceService.getNextNonce(userId) (mesmo contador do usuario)
BatalhaNonceService.getNextNonce(player.userId) por player/round (mesmo contador)
FLIPSem nonce proprio - usa o MESMO roll (e nonce) da abertura
Sorteioprize.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:

typescript
// 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_INTEGER do 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

  1. Apos a aposta, acesse a pagina de verificacao
  2. Insira o serverSeed (revelado apos aposta), clientSeed e nonce
  3. O sistema recalcula o roll usando o mesmo algoritmo
  4. Compare o roll calculado com o roll da aposta
  5. Verifique que HMAC-SHA256(serverSeed) === serverSeedHash mostrado antes

Verificacao Programatica

javascript
// 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 10000000

Inconsistencias Conhecidas

  1. Default de max no controller: O endpoint POST /provably-fair/calculate-result retorna max: dto.max || 100000 no response (100.000), mas o service usa max = 10000000 como default. Isso pode confundir quem verifica o resultado.

  2. Server seed nao persistido no generate-seeds: O endpoint POST /provably-fair/generate-seeds gera 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:

  1. Verificar range: Todos os hashRangeEnd das caixas devem terminar em 10.000.000
  2. Verificar roll: O roll deve estar sendo calculado com max = 10.000.000
  3. Verificar threshold: O successThreshold do upgrade deve usar 10.000.000
  4. Verificar nonce: Nonces nao devem colidir entre aberturas/flips
typescript
// 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);

Documentação Técnica - ProCases