Provably Fair
O sistema Provably Fair é fundamental em gambling online. Permite que jogadores verifiquem matematicamente que os resultados não foram manipulados.
RANGE OFICIAL
O range do sistema Provably Fair do ProCases é de 1 a 10.000.000 (dez milhões).
Todos os cálculos de roll, hashRange e successThreshold DEVEM usar este range.
O que é Provably Fair?
É um método criptográfico que garante:
- O site não pode manipular o resultado após a aposta
- O jogador pode verificar que o resultado foi justo
- Transparência total sobre como resultados são gerados
Como Funciona?
Algoritmo
1. Geração do Server Seed
generateServerSeed(): string {
// 32 bytes aleatórios = 64 caracteres hex
return crypto.randomBytes(32).toString('hex');
}
// Exemplo: "a7b9c2d4e6f8g0h1i3j5k7l9m1n3o5p7q9r1s3t5u7v9w1x3y5z7"2. Hash do Server Seed
hashServerSeed(seed: string): string {
// Commitment = HMAC-SHA256 com o seed como chave e mensagem VAZIA (não é sha256(seed) puro)
return crypto.createHmac('sha256', seed).digest('hex');
}
// Entrada: "a7b9c2d4e6f8..."
// Saída: "8f3a2b1c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1"3. Cálculo do Roll
calculateRoll(
serverSeed: string,
clientSeed: string,
nonce: number,
max: number = 10000000,
): number {
const message = `${clientSeed}:${nonce}`;
const hmac = crypto.createHmac('sha256', serverSeed)
.update(message)
.digest('hex');
const hashPrefix = hmac.substring(0, 13); // 13 chars hex = 52 bits
const decimal = parseInt(hashPrefix, 16);
const maxSafeValue = Math.floor(Number.MAX_SAFE_INTEGER / max) * max;
if (decimal >= maxSafeValue) {
// Dead zone: re-sorteia anexando ':retry' ao MESMO nonce (não incrementa o nonce)
const retry = crypto.createHmac('sha256', serverSeed)
.update(`${clientSeed}:${nonce}:retry`)
.digest('hex');
return (parseInt(retry.substring(0, 13), 16) % max) + 1;
}
return (decimal % max) + 1; // Range 1-based: 1 a 10.000.000
}
// Exemplo:
// serverSeed: "a7b9c2d4..."
// clientSeed: "player123"
// nonce: 1
// Resultado: 4538200 (roll de 1 a 10.000.000)Detalhes importantes:
substring(0, 13)= 52 bits de entropia (não 8 chars / 32 bits)serverSeedé a chave HMAC,clientSeed:nonceé a mensagem- Dead zone: se decimal >= maxSafeValue, re-sorteia anexando o sufixo
:retryao MESMO nonce (elimina viés de módulo — não incrementa o nonce)
4. Seleção do Item
Os itens têm faixas de roll (hash ranges) de 1 a 10.000.000:
// Exemplo de caixa com 4 itens (range total: 1 a 10.000.000):
// AWP Dragon Lore: 1 - 10000 (0.1% chance)
// AK-47 Fire Serpent: 10001 - 60000 (0.5% chance)
// M4A4 Howl: 60001 - 460000 (4% chance)
// Glock Fade: 460001 - 10000000 (95.4% chance)
selectItem(items: CaseItem[], roll: number): Item {
for (const item of items) {
if (roll >= item.hashRangeStart && roll <= item.hashRangeEnd) {
return item;
}
}
throw new Error('No item found for roll');
}
// Se roll = 4538200:
// 4538200 >= 460001 && 4538200 <= 10000000 ✓
// Resultado: Glock FadeImplementação no ProCases
ProvablyFairService
// src/application/services/provably-fair.service.ts
@Injectable()
export class ProvablyFairService {
generateServerSeed(): string {
return crypto.randomBytes(32).toString('hex');
}
generateClientSeed(userInput?: string): string {
// Se o jogador fornece o próprio clientSeed, usa ele; senão gera 32 bytes
if (userInput && userInput.length > 0) return userInput;
return crypto.randomBytes(32).toString('hex');
}
hashServerSeed(seed: string): string {
// Commitment = HMAC-SHA256 com o seed como chave e mensagem VAZIA (não é sha256(seed) puro)
return crypto.createHmac('sha256', seed).digest('hex');
}
calculateRoll(
serverSeed: string,
clientSeed: string,
nonce: number,
max: number = 10000000,
): number {
const message = `${clientSeed}:${nonce}`;
const hmac = crypto.createHmac('sha256', serverSeed)
.update(message)
.digest('hex');
const hashPrefix = hmac.substring(0, 13);
const decimal = parseInt(hashPrefix, 16);
const maxSafeValue = Math.floor(Number.MAX_SAFE_INTEGER / max) * max;
if (decimal >= maxSafeValue) {
const retry = crypto.createHmac('sha256', serverSeed)
.update(`${clientSeed}:${nonce}:retry`)
.digest('hex');
return (parseInt(retry.substring(0, 13), 16) % max) + 1;
}
return (decimal % max) + 1;
}
// Confere que o serverSeed revelado corresponde ao hash publicado ANTES da aposta
verifyServerSeedHash(serverSeed: string, serverSeedHash: string): boolean {
return this.hashServerSeed(serverSeed) === serverSeedHash;
}
// Recalcula o roll a partir dos seeds gravados e compara com o resultado
verifyResult(
serverSeed: string,
clientSeed: string,
nonce: number,
expectedRoll: number,
max: number = 10000000,
): boolean {
return this.calculateRoll(serverSeed, clientSeed, nonce, max) === expectedRoll;
}
}Uso em Case Opening
// src/application/use-cases/case-opening/open-case.use-case.ts
async execute(userId: bigint, caseId: bigint, clientSeed?: string) {
// 1. Gera seeds
const serverSeed = this.provablyFairService.generateServerSeed();
const serverSeedHash = this.provablyFairService.hashServerSeed(serverSeed);
const finalClientSeed = clientSeed || this.provablyFairService.generateClientSeed();
// 2. Busca próximo nonce do usuário
const nonce = await this.openingRepository.getNextNonce(userId);
// 3. Calcula roll
const roll = this.provablyFairService.calculateRoll(
serverSeed,
finalClientSeed,
nonce,
);
// 4. Seleciona item baseado no roll
const items = await this.caseRepository.getItemsWithRanges(caseId);
const wonItem = this.selectItem(items, roll);
// 5. Salva todos os dados para verificação futura
const opening = await this.openingRepository.create({
userId,
caseId,
itemId: wonItem.id,
serverSeed, // Revelado após o jogo
serverSeedHash, // Compromisso (enviado antes)
clientSeed: finalClientSeed,
nonce,
roll,
});
return {
id: opening.id,
item: wonItem,
serverSeedHash, // Jogador recebe isso ANTES de ver o resultado
};
}Verificação pelo Usuário
// src/application/use-cases/case-opening/verify-opening.use-case.ts
async execute(userId: bigint, openingId: bigint) {
const opening = await this.openingRepository.findById(openingId);
if (opening.userId !== userId) {
throw new ForbiddenException('Not your opening');
}
// Verificação em 2 passos (os métodos reais do ProvablyFairService)
const hashValid = this.provablyFairService.verifyServerSeedHash(
opening.serverSeed,
opening.serverSeedHash,
);
const rollValid = this.provablyFairService.verifyResult(
opening.serverSeed,
opening.clientSeed,
opening.nonce,
opening.roll,
);
return {
isValid: hashValid && rollValid,
hashValid,
rollValid,
serverSeed: opening.serverSeed,
serverSeedHash: opening.serverSeedHash,
clientSeed: opening.clientSeed,
nonce: opening.nonce,
roll: opening.roll,
item: opening.item,
itemRangeStart: opening.item.hashRangeStart,
itemRangeEnd: opening.item.hashRangeEnd,
};
}Por que é Seguro?
1. Server Seed é Secreto Até o Fim
ANTES do jogo:
- Servidor tem: serverSeed = "abc123..."
- Jogador tem: serverSeedHash = HMAC-SHA256(key=serverSeed, "") = "8f3a2b..."
O jogador NÃO SABE o serverSeed, então não pode prever o resultado.
O servidor NÃO PODE MUDAR o serverSeed, porque mudaria o hash.2. Client Seed Adiciona Imprevisibilidade
- O jogador pode fornecer seu próprio clientSeed
- Mesmo que o servidor soubesse o clientSeed com antecedência,
não poderia escolher um serverSeed que desse um resultado específico
(teria que adivinhar o hash inverso - impossível)3. Nonce Garante Unicidade
- Cada jogo tem um nonce diferente (incrementado)
- Mesmo com os mesmos seeds, cada jogo tem resultado diferente
- Impossível reutilizar um resultado favorável4. Verificação Independente
// O jogador pode verificar em qualquer calculadora online ou localmente:
const crypto = require('crypto');
// Dados do jogo
const serverSeed = "a7b9c2d4e6f8..."; // Revelado após o jogo
const clientSeed = "player123";
const nonce = 5;
// Verifica o hash (HMAC-SHA256 com o serverSeed como chave e mensagem vazia)
const hash = crypto.createHmac('sha256', serverSeed).digest('hex');
console.log(hash); // Deve bater com serverSeedHash recebido ANTES
// Recalcula o roll
const hmac = crypto.createHmac('sha256', serverSeed)
.update(`${clientSeed}:${nonce}`)
.digest('hex');
const hashPrefix = hmac.substring(0, 13);
const decimal = parseInt(hashPrefix, 16);
const roll = (decimal % 10000000) + 1;
console.log(roll); // Deve bater com o roll do jogoSistema FLIP e Provably Fair
O FLIP usa o mesmo roll da abertura — não há um segundo sorteio nem um segundo conjunto de seeds. Cada caixa tem um flipThreshold (nullable; null = sem FLIP). Quando o roll cai no topo do range, a seleção acontece no tier de itens raros:
// Roll único da abertura
const roll = calculateRoll(serverSeed, clientSeed, nonce);
// FLIP ativa quando o roll passa do threshold da caixa
const flipThreshold = caseData.flipThreshold; // dinâmico por caixa, nullable
const isFlip = flipThreshold !== null && roll >= flipThreshold;
if (isFlip) {
// Itens raros = os que começam acima do threshold (hashRangeStart >= flipThreshold)
const flipItems = getFlipItems(caseItems, flipThreshold);
// O MESMO roll seleciona o item raro (nenhum roll novo)
wonItem = selectFlipWinner(flipItems, roll);
}FLIP é um único roll
Não existe flipRoll independente. Na abertura de caixa, as colunas flipServerSeed/flipClientSeed/flipNonce/flipRoll são gravadas como null — o FLIP hoje é essencialmente um flag visual (isFlip) que dispara a animação de roleta dupla no front, enquanto a seleção do item usa o roll único. A verificação pública recalcula esse mesmo roll.
Interface do Usuário
Antes do Jogo
// Resposta da API ao abrir caixa
{
"id": "123456",
"serverSeedHash": "8f3a2b1c9d8e7f6a...", // Compromisso
"item": {
"name": "AK-47 | Redline",
"value": 2500
}
}Verificação
// Resposta da API ao verificar
{
"isValid": true,
"serverSeed": "a7b9c2d4e6f8...", // Agora revelado
"serverSeedHash": "8f3a2b1c9d8e7f6a...",
"clientSeed": "player123",
"nonce": 5,
"roll": 45382,
"item": {
"name": "AK-47 | Redline",
"hashRangeStart": 40000,
"hashRangeEnd": 60000 // 45382 está nessa faixa ✓
}
}Cálculo de Probabilidades
Configuração dos Ranges
O range total é de 1 a 10.000.000 (dez milhões de possibilidades).
const HASH_MAX = 10000000;
// Ao criar/editar uma caixa, calculamos os ranges
function calculateHashRanges(items: { item: Item; weight: number }[]) {
// Total de peso
const totalWeight = items.reduce((sum, i) => sum + i.weight, 0);
let currentPosition = 1; // Começa em 1
return items.map((item, index, arr) => {
// Calcula quantos "slots" este item ocupa
const slots = Math.floor((item.weight / totalWeight) * HASH_MAX);
// Último item vai até HASH_MAX para cobrir arredondamentos
const rangeEnd = index === arr.length - 1
? HASH_MAX
: currentPosition + slots - 1;
const range = {
itemId: item.item.id,
hashRangeStart: currentPosition,
hashRangeEnd: rangeEnd,
dropChance: (slots / HASH_MAX) * 100, // Porcentagem
};
currentPosition = rangeEnd + 1;
return range;
});
}
// Exemplo com HASH_MAX = 10.000.000:
// Item A: weight 1 → range 1-100000 (1%)
// Item B: weight 9 → range 100001-1000000 (9%)
// Item C: weight 90 → range 1000001-10000000 (90%)Segurança Adicional
Rotação de Seeds
Cada usuário tem um nonce que incrementa:
async getNextNonce(userId: bigint): Promise<number> {
const result = await this.prisma.caseOpening.aggregate({
where: { userId },
_max: { nonce: true },
});
return (result._max.nonce || 0) + 1;
}Auditoria
Todas as aberturas são logadas com dados completos:
// Audit log automático
{
action: 'CASE_OPENING',
userId: 123,
entityId: 456,
metadata: {
caseId: 789,
serverSeedHash: '8f3a2b...',
roll: 45382,
itemId: 101,
itemValue: 2500,
}
}Arquivos Fonte Relacionados
Principais Arquivos
src/application/services/provably-fair.service.ts- Algoritmo principalsrc/application/use-cases/case-opening/open-case.use-case.ts- Uso em aberturassrc/application/use-cases/case-opening/verify-opening.use-case.ts- Verificaçãosrc/presentation/controllers/provably-fair.controller.ts- Endpoints de verificação
