La trampa
Un hash son treinta y dos bytes. Un juego necesita un número entre 0 y 1. La forma obvia de obtener uno es leer algunos de los bytes como un entero y dividir por el valor más grande posible, y cada explicación de provably fair, incluyendo la nuestra, lo describe de esa manera. La forma obvia tiene un problema que solo aparece cuando dos programas diferentes lo hacen.
Tanto el servidor como el navegador almacenan números ordinarios como IEEE 754 doubles: 64 bits, de los cuales 53 llevan precisión. Siete bytes de hash son 56 bits, ocho bytes son 64, y ninguno de los dos encaja. Así que la conversión debe perder bits, y la pregunta no es si redondea sino cuántas veces. Un bucle que acumula bytes uno a uno, multiplicando el total en ejecución por 256 y sumando el siguiente byte, redondea en cada paso una vez que el total pasa 2⁵³. La conversión de Kotlin de un entero de 64 bits a un double redondea exactamente una vez. Dos redondeamientos del mismo valor no siempre llegan al mismo double que uno.
La mayoría de las veces nadie se da cuenta, porque la diferencia está en el último bit de un número con dieciséis dígitos significativos. Luego un juego multiplica el número por 37, o 52, o 1.000.000, y redondea hacia abajo a un entero, y el último bit es exactamente el bit que decide si 36,9999999999999 se convierte en 36 o 37. Un verificador construido de la forma obvia rechazaría una pequeña fracción de rondas perfectamente honestas, y un jugador que viera un rechazo no tendría forma de distinguir un error de redondeo honesto de un servidor deshonesto.
Cómo el engine lo evita
La solución es hacer que el navegador redondee exactamente tan a menudo como lo hace el servidor, que es una vez. El engine lee los primeros cuatro bytes como un entero, que cabe en un double exactamente, y los siguientes tres o cuatro bytes como otro entero, que también cabe exactamente. Cada uno se divide por una potencia de dos, una operación que es exacta para doubles. Luego los dos se suman. Esa única suma es el único lugar donde se pierde precisión, y la pierde de la misma manera que la conversión única de Kotlin.
ENGINE-VERIFIED_shared/rng.ts u56: «primeros 7 bytes → uniforme [0,1), coincidiendo con el redondeo Long→Double del servidor bit por bit: hi/2^32 y lo/2^56 son ambos doubles diádicos exactos, así que la única suma IEEE redondea el valor de 56 bits exactamente una vez — el mismo redondeo único que toDouble() de Kotlin realiza. (Una acumulación ingenua de 56 bits en un double redondearía repetidamente pasado 2^53 y puede diferir en los bordes del piso.)» u64 usa la misma construcción sobre ocho bytes, «coincidiendo con la conversión ULong→Double del servidor en LimboService.outcome100», y es la función que importa cada seed-pair original.»
| FUNCIÓN | BYTES LEÍDOS | CONSTRUCCIÓN | COINCIDE CON |
|---|---|---|---|
| u56 | primeros 7 | hi ÷ 2³² + lo ÷ 2⁵⁶, una suma | la conversión Long → Double del servidor |
| u64 | primeros 8 | hi ÷ 2³² + lo ÷ 2⁶⁴, una suma | la conversión ULong → Double del servidor (Limbo) |
| h52 / bust100 | primeros 6.5 (13 dígitos hex) | aritmética exacta de BigInt, sin double en absoluto | el CrashDerivation.crashPoint100 del servidor |
Cada par de semillas original (bingo, blackjack, hold'em, koban, omikuji, sic bo, video poker, hanabi, fukubukuro, roulette) importa u64 desde rng.ts; el generador y el verificador son la misma importación.
Crash lleva el argumento a su conclusión. Su punto de crash sigue la fórmula de bustabit, floor((100 × 2⁵² − h) ÷ (2⁵² − h)) donde h son los 52 bits iniciales del hash, y esa división se sitúa exactamente en el tipo de límite donde un double puede estar desviado por uno. Así que el motor no usa un double. Calcula la fórmula con enteros de precisión arbitraria, en el navegador, de la misma forma que lo hace el servidor, y el comentario en el código fuente explica por qué en una línea: una aproximación con punto flotante estaría en desacuerdo con la división entera en los límites de floor y haría que el verificador rechace rondas válidas.
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.
Una implementación, dos trabajos
Existe una segunda decisión, más silenciosa, en el mismo archivo. Las funciones que convierten un hash en un número no se escriben dos veces, una para el juego y otra para el verificador. Se escriben una sola vez, en un módulo que el encabezado describe como compartido por el generador y el verificador, y el motor de demostración que juega rondas sin sesión iniciada en tu navegador produce sus resultados llamando a las mismas funciones que el panel de equidad llama para verificarlas.
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.”
Ese diseño tiene una consecuencia que es fácil de enunciar y que vale la pena enunciar. Cuando el verificador dice que una ronda es válida, no está diciendo que su propia aproximación del servidor esté de acuerdo con el servidor dentro de cierta tolerancia. Está diciendo que la misma función, dados los mismos datos de entrada, produjo la misma salida, y no hay tolerancia porque no hay nada que tolerar. Cuando dice que una ronda no es válida, eso no es ruido. Significa que los datos de entrada son diferentes, que es lo único que un verificador existe para detectar.
Un verificador que redondea diferente del servidor es un verificador que a veces da falsas alarmas. Después de la primera alarma falsa, nadie escucha la verdadera.por qué importa el redondeo
Qué sacar de esto
- “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.
- Una discrepancia debería ser lo suficientemente rara como para ser alarmente. En este motor una ronda honesta no puede fallar la verificación debido al redondeo, así que un fallo es información en lugar de un artefacto.
- Puedes leer la función. La construcción es solo unas pocas líneas, y el recorrido de verificación muestra dónde se usa cada una en una ronda en vivo.
Este artículo describe el módulo de derivación del cliente y cita sus comentarios sobre el servidor que refleja; el servidor mismo no está en el repositorio público, y el servicio de la casa es la autoridad pagadora. Es una nota de ingeniería, no una certificación.
Preguntas frecuentes
¿Por qué un verificador y un servidor estarían en desacuerdo si usan el mismo hash?
Porque convertir un hash en un número entre 0 y 1 requiere redondear a 53 bits de precisión, y redondear una sola vez no siempre da el mismo resultado que redondear varias veces. Un verificador que acumula bytes en un bucle puede diferir de un servidor que convierte una sola vez, en el último bit.
¿Realmente importa un bit?
Cuando el número se multiplica por 37, 52 o un millón y se redondea hacia abajo, el último bit puede decidir qué número entero resulta. Ese es un bolsillo, carta o artículo diferente.
¿Cómo lo evita el motor?
Construye el número a partir de dos piezas exactamente representables, los primeros cuatro bytes sobre 2³² y los siguientes bytes sobre 2⁵⁶ o 2⁶⁴, y los suma una vez, coincidiendo con el redondeo único de la conversión integer-to-double del servidor. Crash usa aritmética exacta de números enteros y sin doubles en absoluto.
¿Es el verificador un programa separado del juego?
No. Las funciones hash y number viven en un módulo compartido que tanto el motor demo como el panel de fairness importan, por lo que el generador no puede desviarse del verificador.
¿Qué significa una verificación fallida aquí?
Que los inputs difieren de los que usó el servidor, porque el redondeo no puede causar un fallo falso en este motor. Es la señal que el verificador existe para dar.
FUENTES Y REFERENCIAS
- Betkyo engine source: _shared/rng.ts (u56, u64, h52, bust100, hmacSha256Utf8 y sus comentarios)
- IEEE 754-2019 — Standard for Floating-Point Arithmetic (binary64: 53 bits de precisión de significando)
- Bustabit provably fair crash-point formula, la construcción bust100 refleja
LOS JUEGOS EN ESTE ARTÍCULO
Limbo — rules & free play →Crash — rules & free play →Roulette — rules & free play →