A armadilha
Um hash tem trinta e dois bytes. Um jogo precisa de um número entre 0 e 1. A forma óbvia de conseguir um é ler alguns dos bytes como um inteiro e dividir pelo maior valor possível, e todo explicador provably fair, incluindo o nosso, descreve desta forma. A forma óbvia tem um problema que só aparece quando dois programas diferentes a executam.
Tanto o servidor quanto o navegador armazenam números ordinários como doubles IEEE 754: 64 bits, dos quais 53 têm precisão. Sete bytes de hash são 56 bits, oito bytes são 64, e nenhum dos dois cabe. Portanto, a conversão deve perder bits, e a questão não é se há arredondamento, mas quantas vezes. Um loop que acumula bytes um de cada vez, multiplicando o total anterior por 256 e adicionando o próximo byte, arredonda a cada passo uma vez que o total ultrapassa 2⁵³. A conversão de Kotlin de um inteiro de 64 bits para um double arredonda exatamente uma vez. Dois arredondamentos do mesmo valor nem sempre chegam ao mesmo double que um.
Na maioria das vezes ninguém percebe, porque a diferença está no último bit de um número com dezesseis dígitos significativos. Depois um jogo multiplica o número por 37, ou 52, ou 1.000.000, e arredonda para baixo até um inteiro, e o último bit é exatamente o bit que decide se 36.9999999999999 vira 36 ou 37. Um verificador construído da forma óbvia rejeitaria uma pequena fração de rodadas perfeitamente honestas, e um jogador que visse uma rejeição não teria como distinguir um erro de arredondamento honesto de um servidor desonesto.
Como o engine contorna isso
A solução é fazer o navegador arredondar exatamente quantas vezes o servidor faz, que é uma vez. O engine lê os primeiros quatro bytes como um inteiro, que cabe exatamente em um double, e os próximos três ou quatro bytes como outro inteiro, que também cabe exatamente. Cada um é dividido por uma potência de dois, uma operação que é exata para doubles. Depois os dois são somados. Essa única adição é o único lugar onde a precisão é perdida, e ela a perde da mesma forma que a conversão única de Kotlin faz.
ENGINE-VERIFIED_shared/rng.ts u56: “first 7 bytes → uniform [0,1), matching the server’s Long→Double rounding bit-for-bit: hi/2^32 and lo/2^56 are both exact dyadic doubles, so the one IEEE addition rounds the 56-bit value exactly once — the same single rounding Kotlin’s toDouble() performs. (A naive 56-bit accumulate in a double would round repeatedly past 2^53 and can differ at floor edges.)” u64 uses the same construction over eight bytes, “matching the server’s ULong→Double conversion in LimboService.outcome100”, and is the function every seed-pair original imports.
| FUNÇÃO | BYTES LIDOS | CONSTRUÇÃO | CORRESPONDE A |
|---|---|---|---|
| u56 | primeiros 7 | hi ÷ 2³² + lo ÷ 2⁵⁶, uma adição | a conversão Long → Double do servidor |
| u64 | primeiros 8 | hi ÷ 2³² + lo ÷ 2⁶⁴, uma adição | a conversão ULong → Double do servidor (Limbo) |
| h52 / bust100 | primeiros 6,5 (13 dígitos hex) | aritmética BigInt exata, sem double algum | o CrashDerivation.crashPoint100 do servidor |
Cada seed-pair original (bingo, blackjack, hold'em, koban, omikuji, sic bo, video poker, hanabi, fukubukuro, roulette) importa u64 de rng.ts; o gerador e o verificador são a mesma importação.
Crash leva o argumento à conclusão. Seu crash point segue a fórmula bustabit, floor((100 × 2⁵² − h) ÷ (2⁵² − h)) onde h são os 52 bits iniciais do hash, e essa divisão fica exatamente no tipo de limite onde um double pode estar errado por um. Assim o engine não usa um double. Ele computa a fórmula com inteiros de precisão arbitrária, no navegador, do jeito que o servidor faz, e o comentário no source diz por que em uma linha: uma aproximação float discordaria da divisão inteira nos limites do floor e faria o verificador rejeitar boas rodadas.
ENGINE-VERIFIED_shared/rng.ts bust100: h = h52(digest), the first 13 hex characters as a BigInt; p = (100·2⁵² − h) ÷ (2⁵² − h) in BigInt division, clamped to [100, 1,000,000]; the comment reads “Exact BigInt math to match the server (NewWhiteBack CrashDerivation.crashPoint100) bit-for-bit — a float approximation would disagree with integer division at floor boundaries and make the verifier reject good rounds.” The BigInt values are built with BigInt() calls rather than literals so the file compiles under the project’s es5 target.
Uma implementação, dois trabalhos
Há uma segunda decisão, mais silenciosa, no mesmo arquivo. As funções que transformam um hash em um número não são escritas duas vezes, uma para o jogo e outra para o verificador. Elas são escritas uma vez, em um módulo que o header descreve como compartilhado pelo gerador e pelo verificador, e o demo engine que executa rodadas não-conectadas no seu navegador produz seus resultados chamando as mesmas funções que o painel de fairness chama para verificá-las.
ENGINE-VERIFIED_shared/rng.ts header: “Crypto primitives shared by the provably-fair GENERATOR (demoLocal.ts, the per-game derive modules) and the VERIFIER (fairness.tsx). Extracted from fairness.tsx so a game’s derivation can be imported by both sides without a circular import.” hmacSha256Utf8 is described as consuming key and message “exactly as RandomUtils.generateHash consumes them on the server … one implementation, so the demo can never drift from what the verifier accepts.”
Esse design tem uma consequência que é fácil de afirmar e vale a pena afirmar. Quando o verificador diz que uma rodada passa na verificação, ele não está dizendo que sua própria aproximação do servidor concorda com o servidor dentro de alguma tolerância. Ele está dizendo que a mesma função, dados os mesmos inputs, produziu o mesmo output, e não há tolerância porque não há nada para tolerar. Quando ele diz que uma rodada não passa na verificação, isso não é ruído. Significa que os inputs diferem, que é a única coisa para a qual um verificador existe para detectar.
Um verificador que arredonda diferente do servidor é um verificador que às vezes soa o alarme falso. Depois do primeiro alarme falso, ninguém ouve o real.por que o arredondamento importa
O que tirar disso
- “Provably fair” is a claim about arithmetic, and arithmetic has edges. The commitment scheme is the headline; the bit-for-bit derivation is what makes the headline enforceable.
- Uma discrepância deve ser rara o suficiente para ser alarmante. Neste engine uma rodada honesta não pode falhar na verificação por causa do arredondamento, então uma falha é informação em vez de um artefato.
- Você pode ler a função. A construção é algumas linhas, e o walkthrough de verificação mostra onde cada uma é usada em uma rodada real.
Este artigo descreve o módulo de derivação do cliente e cita seus comentários sobre o servidor que espelha; o servidor em si não está no repositório público, e o serviço da casa é a autoridade pagadora. É uma nota de engenharia, não uma certificação.
FAQ
Por que um verificador e um servidor discordariam se usam o mesmo hash?
Porque transformar um hash em um número entre 0 e 1 requer arredondamento para 53 bits de precisão, e arredondar uma vez nem sempre dá o mesmo resultado que arredondar várias vezes. Um verificador que acumula bytes em um loop pode diferir de um servidor que converte uma vez, no último bit.
Um único bit realmente importa?
Quando o número é multiplicado por 37 ou 52 ou um milhão e arredondado para baixo, o último bit pode decidir qual número inteiro resulta. Esse é um bolso, carta ou item diferente.
Como a engine evita isso?
Ela constrói o número a partir de duas peças exatamente representáveis, os primeiros quatro bytes sobre 2³² e os próximos bytes sobre 2⁵⁶ ou 2⁶⁴, e as adiciona uma vez, correspondendo ao arredondamento único da conversão inteiro-para-double do servidor. Crash usa aritmética de inteiros exatos e nenhum double.
O verificador é um programa separado do jogo?
Não. As funções hash e number vivem em um módulo compartilhado que tanto a demo engine quanto o painel de fairness importam, então o gerador não pode divergir do verificador.
O que uma verificação falhada significa aqui?
Que as entradas diferem do que o servidor usou, porque o arredondamento não pode causar uma falha falsa nesta engine. É o sinal que o verificador existe para dar.
FONTES & REFERÊNCIAS
- Betkyo engine source: _shared/rng.ts (u56, u64, h52, bust100, hmacSha256Utf8 e seus comentários)
- IEEE 754-2019 — Standard for Floating-Point Arithmetic (binary64: 53 bits de precisão de significando)
- Fórmula provably fair crash-point do Bustabit, a construção bust100 espelha
OS JOGOS NESTE ARTIGO
Limbo — regras & jogo gratuito →Crash — regras & jogo gratuito →Roulette — regras & jogo gratuito →