Die Falle
Ein Hash hat zweiunddreißig Bytes. Ein Spiel braucht eine Zahl zwischen 0 und 1. Der naheliegende Weg, eine solche zu bekommen, besteht darin, einige der Bytes als Ganzzahl zu lesen und durch den größtmöglichen Wert zu teilen, und jeder Provably-Fair-Erklärtext, auch unserer, beschreibt es so. Der naheliegende Weg hat ein Problem, das erst sichtbar wird, wenn zwei verschiedene Programme es tun.
Sowohl der Server als auch der Browser speichern gewöhnliche Zahlen als IEEE-754-Doubles: 64 Bit, von denen 53 Präzision tragen. Sieben Bytes eines Hash sind 56 Bit, acht Bytes sind 64, und keines von beidem passt hinein. Also muss die Umwandlung Bits verlieren, und die Frage ist nicht, ob gerundet wird, sondern wie oft. Eine Schleife, die Bytes einzeln akkumuliert, indem sie den laufenden Gesamtwert mit 256 multipliziert und das nächste Byte addiert, rundet bei jedem Schritt, sobald die Summe 2⁵³ übersteigt. Kotlins Umwandlung einer 64-Bit-Ganzzahl in ein Double rundet genau einmal. Zwei Rundungen desselben Werts landen nicht immer auf demselben Double wie eine einzige.
Meistens bemerkt es niemand, weil der Unterschied im letzten Bit einer Zahl mit sechzehn signifikanten Stellen liegt. Dann multipliziert ein Spiel die Zahl mit 37, oder 52, oder 1.000.000, und rundet auf eine Ganzzahl ab, und genau das letzte Bit entscheidet, ob 36,9999999999999 zu 36 oder 37 wird. Ein auf naheliegende Weise gebauter Verifier würde einen kleinen Anteil vollkommen ehrlicher Runden ablehnen, und ein Spieler, der eine Ablehnung sähe, hätte keine Möglichkeit, einen ehrlichen Rundungsfehler von einem unehrlichen Server zu unterscheiden.
Wie die Engine das umgeht
Die Lösung besteht darin, den Browser genau so oft runden zu lassen wie der Server, nämlich einmal. Die Engine liest die ersten vier Bytes als Ganzzahl, die exakt in ein Double passt, und die nächsten drei oder vier Bytes als eine weitere Ganzzahl, die ebenfalls exakt passt. Jede wird durch eine Zweierpotenz geteilt, eine Operation, die für Doubles exakt ist. Dann werden die beiden addiert. Diese einzelne Addition ist die einzige Stelle, an der Präzision verloren geht, und sie verliert sie auf dieselbe Weise wie Kotlins einzelne Umwandlung.
ENGINE-VERIFIED_shared/rng.ts u56: „erste 7 Bytes → gleichverteilt [0,1), passend zur Long→Double-Rundung des Servers bitgenau: hi/2^32 und lo/2^56 sind beide exakte dyadische Doubles, sodass die eine IEEE-Addition den 56-Bit-Wert genau einmal rundet — dieselbe einzelne Rundung, die Kotlins toDouble() durchführt. (Eine naive 56-Bit-Akkumulation in einem Double würde wiederholt über 2^53 hinaus runden und könnte an Abrundungsgrenzen abweichen.)“ u64 verwendet dieselbe Konstruktion über acht Bytes, „passend zur ULong→Double-Umwandlung des Servers in LimboService.outcome100“, und ist die Funktion, die jedes Seed-Paar-Original importiert.
| FUNKTION | GELESENE BYTES | KONSTRUKTION | ENTSPRICHT |
|---|---|---|---|
| u56 | erste 7 | hi ÷ 2³² + lo ÷ 2⁵⁶, eine Addition | der Long → Double-Umwandlung des Servers |
| u64 | erste 8 | hi ÷ 2³² + lo ÷ 2⁶⁴, eine Addition | der ULong → Double-Umwandlung des Servers (Limbo) |
| h52 / bust100 | erste 6,5 (13 Hexadezimalstellen) | exakte BigInt-Ganzzahlarithmetik, überhaupt kein Double | das serverseitige CrashDerivation.crashPoint100 |
Jedes Seed-Pair-Original (Bingo, Blackjack, Hold’em, Koban, Omikuji, Sic Bo, Video Poker, Hanabi, Fukubukuro, Roulette) importiert u64 aus rng.ts; der Generator und der Verifier nutzen denselben Import.
Crash treibt das Argument auf die Spitze. Sein Crash-Point folgt der Bustabit-Formel, floor((100 × 2⁵² − h) ÷ (2⁵² − h)), wobei h die führenden 52 Bits des Hashes sind, und diese Division liegt genau auf der Art von Grenze, an der ein Double um eins daneben liegen kann. Also verwendet die Engine kein Double. Sie berechnet die Formel mit Ganzzahlen beliebiger Präzision, im Browser, genauso wie der Server, und der Kommentar im Quellcode erklärt in einer Zeile warum: eine Float-Näherung würde an Floor-Grenzen von der Integer-Division abweichen und dazu führen, dass der Verifier gute Runden ablehnt.
ENGINE-VERIFIZIERT_shared/rng.ts bust100: h = h52(digest), die ersten 13 Hex-Zeichen als BigInt; p = (100·2⁵² − h) ÷ (2⁵² − h) in BigInt-Division, begrenzt auf [100, 1.000.000]; der Kommentar lautet: „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.“ Die BigInt-Werte werden mit BigInt()-Aufrufen statt Literalen erzeugt, damit die Datei unter dem es5-Ziel des Projekts kompiliert.
Eine Implementierung, zwei Aufgaben
In derselben Datei steckt eine zweite, leisere Entscheidung. Die Funktionen, die einen Hash in eine Zahl verwandeln, sind nicht zweimal geschrieben, einmal für das Spiel und einmal für den Prüfer. Sie sind einmal geschrieben, in einem Modul, das die Kopfzeile als von Generator und Verifier gemeinsam genutzt beschreibt, und die Demo-Engine, die abgemeldete Runden in deinem Browser spielt, erzeugt ihre Ergebnisse, indem sie dieselben Funktionen aufruft, die das Fairness-Panel zur Prüfung aufruft.
ENGINE-VERIFIZIERT_shared/rng.ts Kopfzeile: „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 wird so beschrieben, dass es key und message „exactly as RandomUtils.generateHash consumes them on the server“ verarbeitet … eine Implementierung, damit die Demo niemals von dem abweichen kann, was der Verifier akzeptiert.
Dieses Design hat eine Konsequenz, die leicht zu formulieren ist und es wert ist, formuliert zu werden. Wenn der Verifier sagt, eine Runde stimmt, sagt er nicht, dass seine eigene Annäherung an den Server innerhalb einer gewissen Toleranz mit dem Server übereinstimmt. Er sagt, dass dieselbe Funktion, bei denselben Eingaben, dieselbe Ausgabe erzeugt hat, und es gibt keine Toleranz, weil es nichts zu tolerieren gibt. Wenn er sagt, eine Runde stimmt nicht, ist das kein Rauschen. Es bedeutet, dass die Eingaben unterschiedlich sind, und genau das zu erkennen ist der Zweck eines Verifiers.
Ein Verifier, der anders rundet als der Server, ist ein Verifier, der manchmal falschen Alarm schlägt. Nach dem ersten Fehlalarm hört niemand mehr auf den echten.warum das Runden wichtig ist
Was man daraus mitnehmen sollte
- „Provably fair“ ist eine Aussage über Arithmetik, und Arithmetik hat Kanten. Das Commitment-Schema ist die Schlagzeile; die bitgenaue Ableitung ist das, was die Schlagzeile durchsetzbar macht.
- Eine Abweichung sollte selten genug sein, um alarmierend zu sein. Bei dieser Engine kann eine ehrliche Runde nicht wegen Rundung die Verifikation nicht bestehen, also ist ein Fehlschlag Information und kein Artefakt.
- Man kann die Funktion lesen. Die Konstruktion umfasst wenige Zeilen, und die Verifikations-Walkthrough zeigt, wo jede davon bei einer Live-Runde eingesetzt wird.
Dieser Artikel beschreibt das Client-Ableitungsmodul und zitiert dessen Kommentare zum Server, den es spiegelt; der Server selbst ist nicht im öffentlichen Repository, und der Haus-Dienst ist die zahlende Instanz. Es ist eine technische Notiz, keine Zertifizierung.
FAQ
Warum sollten ein Verifier und ein Server unterschiedlicher Meinung sein, wenn sie denselben Hash verwenden?
Weil das Umwandeln eines Hashes in eine Zahl zwischen 0 und 1 eine Rundung auf 53 Bit Präzision erfordert, und einmaliges Runden nicht immer dasselbe Ergebnis liefert wie mehrfaches Runden. Ein Verifier, der Bytes in einer Schleife akkumuliert, kann sich im letzten Bit von einem Server unterscheiden, der einmal konvertiert.
Spielt ein einzelnes Bit wirklich eine Rolle?
Wenn die Zahl mit 37, 52 oder einer Million multipliziert und abgerundet wird, kann das letzte Bit entscheiden, welche Ganzzahl dabei herauskommt. Das ist dann ein anderes Feld, eine andere Karte oder ein anderer Gegenstand.
Wie vermeidet die Engine das?
Sie baut die Zahl aus zwei exakt darstellbaren Teilen auf – den ersten vier Bytes über 2³² und den nächsten Bytes über 2⁵⁶ oder 2⁶⁴ – und addiert sie einmal, passend zur einzigen Rundung bei der Integer-zu-Double-Umwandlung des Servers. Crash verwendet exakte Ganzzahlarithmetik und überhaupt keine Doubles.
Ist der Verifier ein separates Programm gegenüber dem Spiel?
Nein. Die Hash- und Zahlenfunktionen liegen in einem gemeinsamen Modul, das sowohl die Demo-Engine als auch das Fairness-Panel importieren, sodass der Generator nicht vom Verifier abweichen kann.
Was bedeutet hier eine fehlgeschlagene Verifizierung?
Dass die Eingaben von denen abweichen, die der Server verwendet hat, denn Rundung kann bei dieser Engine keinen falschen Fehlschlag verursachen. Genau dieses Signal soll der Verifier liefern.
QUELLEN & REFERENZEN
- Betkyo Engine-Quellcode: _shared/rng.ts (u56, u64, h52, bust100, hmacSha256Utf8 und deren Kommentare)
- IEEE 754-2019 — Standard für Gleitkommaarithmetik (binary64: 53 Bit Mantissenpräzision)
- Bustabit Provably-Fair-Crash-Point-Formel, deren Konstruktion bust100 nachbildet
DIE SPIELE IN DIESEM ARTIKEL
Limbo — Regeln & kostenlos spielen →Crash — Regeln & kostenlos spielen →Roulette — Regeln & kostenlos spielen →