Le piège
Un hash est trente-deux octets. Un jeu a besoin d'un nombre entre 0 et 1. La façon évidente de l'obtenir est de lire certains octets comme un entier et de diviser par la plus grande valeur possible, et chaque expliquant sur le fair play prouvé, y compris le nôtre, le décrit ainsi. La façon évidente a un problème qui ne se manifeste que lorsque deux programmes différents le font.
Le serveur et le navigateur stockent tous deux les nombres ordinaires sous forme de doubles IEEE 754 : 64 bits, dont 53 portent la précision. Sept octets de hash font 56 bits, huit octets font 64, et aucun ne convient. Donc la conversion doit perdre des bits, et la question n'est pas si elle s'arrondit mais combien de fois. Une boucle qui accumule les octets un à la fois, en multipliant le total courant par 256 et en ajoutant l'octet suivant, s'arrondit à chaque étape une fois que le total dépasse 2⁵³. La conversion Kotlin d'un entier 64 bits en double s'arrondit exactement une fois. Deux arrondis de la même valeur ne se posent pas toujours sur le même double qu'un seul.
La plupart du temps, personne ne remarque, car la différence est dans le dernier bit d'un nombre avec seize chiffres significatifs. Ensuite, un jeu multiplie le nombre par 37, ou 52, ou 1 000 000, et arrondit vers le bas à un entier, et le dernier bit est exactement le bit qui détermine si 36,9999999999999 devient 36 ou 37. Un vérificateur construit de la façon évidente rejetterait une petite fraction de tours parfaitement honnêtes, et un joueur qui verrait un rejet n'aurait aucun moyen de distinguer une erreur d'arrondi honnête d'un serveur malhonnête.
Comment le moteur le contourne
La solution est de faire en sorte que le navigateur s'arronisse exactement autant de fois que le serveur, c'est-à-dire une fois. Le moteur lit les quatre premiers octets comme un entier, qui rentre exactement dans un double, et les trois ou quatre octets suivants comme un autre entier, qui rentre aussi exactement. Chacun est divisé par une puissance de deux, une opération exacte pour les doubles. Ensuite les deux sont ajoutés. Cette addition unique est le seul endroit où la précision est perdue, et elle la perd de la même manière que la conversion unique de Kotlin.
ENGINE-VERIFIED_shared/rng.ts u56 : « premiers 7 octets → uniforme [0,1), correspondant à l'arrondi Long→Double du serveur bit pour bit : hi/2^32 et lo/2^56 sont tous deux des doubles dyadiques exacts, donc l'addition IEEE unique arrondit la valeur 56 bits exactement une fois — le même arrondi unique que Kotlin's toDouble() effectue. (Une accumulation naïve 56 bits dans un double s'arrondit à plusieurs reprises au-delà de 2^53 et peut différer aux bords du plancher.) » u64 utilise la même construction sur huit octets, « correspondant à la conversion ULong→Double du serveur dans LimboService.outcome100 », et est la fonction que chaque importation de paire de semences originale.»
| FONCTION | OCTETS LUS | CONSTRUCTION | CORRESPOND À |
|---|---|---|---|
| u56 | premiers 7 | hi ÷ 2³² + lo ÷ 2⁵⁶, une addition | la conversion Long → Double du serveur |
| u64 | premiers 8 | hi ÷ 2³² + lo ÷ 2⁶⁴, une addition | la conversion ULong → Double du serveur (Limbo) |
| h52 / bust100 | premiers 6,5 (13 chiffres hexadécimaux) | arithmétique BigInt exacte, aucun double | le CrashDerivation.crashPoint100 du serveur |
Chaque paire de seeds original (bingo, blackjack, hold'em, koban, omikuji, sic bo, video poker, hanabi, fukubukuro, roulette) importe u64 depuis rng.ts ; le générateur et le vérificateur sont la même importation.
Crash mène l'argument à sa conclusion. Son crash point suit la formule bustabit, floor((100 × 2⁵² − h) ÷ (2⁵² − h)) où h sont les 52 premiers bits du hash, et cette division se situe exactement au type de limite où un double peut être décalé d'une unité. Donc le moteur n'utilise pas de double. Il calcule la formule avec des entiers de précision arbitraire, dans le navigateur, de la même façon que le serveur, et le commentaire dans le code explique pourquoi en une ligne : une approximation en virgule flottante serait en désaccord avec la division entière au niveau des limites de floor et ferait rejeter les bons rounds par le vérificateur.
ENGINE-VERIFIED_shared/rng.ts bust100 : h = h52(digest), les 13 premiers caractères hexadécimaux en tant que BigInt ; p = (100·2⁵² − h) ÷ (2⁵² − h) en division BigInt, limité à [100, 1 000 000] ; le commentaire lit « Arithmétique BigInt exacte pour correspondre au serveur (NewWhiteBack CrashDerivation.crashPoint100) bit pour bit — une approximation en virgule flottante serait en désaccord avec la division entière au niveau des limites de floor et ferait rejeter les bons rounds par le vérificateur. » Les valeurs BigInt sont construites avec des appels BigInt() plutôt que des littéraux afin que le fichier compile selon la cible es5 du projet.
Une implémentation, deux tâches
Il y a une deuxième décision, plus discrète, dans le même fichier. Les fonctions qui transforment un hash en nombre ne sont pas écrites deux fois, une fois pour le jeu et une fois pour le vérificateur. Elles sont écrites une fois, dans un module que l'en-tête décrit comme étant partagé par le générateur et le vérificateur, et le moteur de démonstration qui joue les rounds hors ligne dans votre navigateur produit ses résultats en appelant les mêmes fonctions que le panneau d'équité appelle pour les vérifier.
ENGINE-VERIFIED_shared/rng.ts en-tête : « Primitives cryptographiques partagées par le GÉNÉRATEUR provably-fair (demoLocal.ts, les modules de dérivation par jeu) et le VÉRIFICATEUR (fairness.tsx). Extraits de fairness.tsx afin que la dérivation d'un jeu puisse être importée par les deux côtés sans import circulaire. » hmacSha256Utf8 est décrit comme consommant clé et message « exactement comme RandomUtils.generateHash les consomme sur le serveur … une implémentation, donc la démo ne peut jamais dériver de ce que le vérificateur accepte. »
Cette conception a une conséquence qui est facile à énoncer et mérite d'être énoncée. Quand le vérificateur dit qu'un round est valide, il ne dit pas que sa propre approximation du serveur s'accorde avec le serveur à une certaine tolérance près. Il dit que la même fonction, avec les mêmes entrées, a produit la même sortie, et il n'y a pas de tolérance parce qu'il n'y a rien à tolérer. Quand il dit qu'un round n'est pas valide, ce n'est pas du bruit. Cela signifie que les entrées diffèrent, ce qui est la seule chose qu'un vérificateur existe pour détecter.
Un vérificateur qui arrondit différemment du serveur est un vérificateur qui crie parfois au loup. Après la première fausse alerte, personne n'écoute la vraie.pourquoi l'arrondi importe
Ce qu'il faut en retenir
- « Provably fair » est une affirmation sur l'arithmétique, et l'arithmétique a des limites. Le schéma d'engagement est l'annonce ; la dérivation bit pour bit est ce qui rend l'annonce exécutoire.
- Une inadéquation devrait être rare au point d'être alarmante. Sur ce moteur, un round honnête ne peut pas échouer la vérification à cause de l'arrondi, donc un échec est une information plutôt qu'un artefact.
- Vous pouvez lire la fonction. La construction compte quelques lignes, et la procédure de vérification montre où chacune est utilisée sur un round en direct.
Cet article décrit le module de dérivation client et cite ses commentaires sur le serveur qu'il reflète ; le serveur lui-même n'est pas dans le dépôt public, et le service de la maison est l'autorité payante. C'est une note technique, pas une certification.
FAQ
Pourquoi un vérificateur et un serveur seraient-ils en désaccord s'ils utilisent le même hash ?
Parce que transformer un hash en nombre entre 0 et 1 nécessite un arrondi à 53 bits de précision, et arrondir une fois ne donne pas toujours le même résultat qu'arrondir plusieurs fois. Un vérificateur qui accumule les octets dans une boucle peut différer d'un serveur qui convertit une fois, dans le dernier bit.
Est-ce qu'un seul bit compte vraiment ?
Quand le nombre est multiplié par 37 ou 52 ou un million et arrondi à l'entier inférieur, le dernier bit peut décider quel entier en résulte. C'est une case, une carte ou un item différent.
Comment le moteur l'évite-t-il ?
Il construit le nombre à partir de deux éléments exactement représentables, les quatre premiers bytes sur 2³² et les bytes suivants sur 2⁵⁶ ou 2⁶⁴, et les ajoute une seule fois, en correspondant avec l'arrondi unique de la conversion integer-to-double du serveur. Crash utilise l'arithmétique entière exacte et pas de doubles du tout.
Le vérificateur est-il un programme distinct du jeu ?
Non. Les fonctions hash et number vivent dans un module partagé que le moteur de démo et le panel de fairness importent tous les deux, donc le générateur ne peut pas dériver du vérificateur.
Qu'est-ce qu'une vérification échouée signifie ici ?
Que les entrées diffèrent de ce que le serveur a utilisé, car l'arrondi ne peut pas causer un faux échec sur ce moteur. C'est le signal que le vérificateur existe pour donner.
SOURCES & RÉFÉRENCES
- Source du moteur Betkyo : _shared/rng.ts (u56, u64, h52, bust100, hmacSha256Utf8 et leurs commentaires)
- IEEE 754-2019 — Standard for Floating-Point Arithmetic (binary64 : 53 bits de précision de significande)
- Formule du crash provably fair de Bustabit, la construction bust100 s'en inspire
LES JEUX DANS CET ARTICLE
Limbo — règles & jeu gratuit →Crash — règles & jeu gratuit →Roulette — règles & jeu gratuit →