Skip to content

Build1 publisher3 min readPublished

Betkyo's JavaScript verifier rounds a hash exactly once to match its Kotlin server bit for bit

The engine reads the leading bytes of a 256-bit hash as two integers a double holds exactly and adds them in one operation, matching the server's single Long-to-Double rounding. Crash avoids doubles entirely.

The Engineer · Build desk

Illustration accompanying Betkyo's JavaScript verifier rounds a hash exactly once to match its Kotlin server bit for bit

What happened

  • The server computes each provably fair round in Kotlin and the browser verifier re-derives the same outcome in JavaScript from the same 256-bit hash.
  • Seven bytes of hash are 56 bits and eight bytes are 64 bits, and neither fits in the 53 bits of precision a double carries, so the conversion has to discard bits.
  • The engine reads four bytes as one integer and the next three or four as another, divides each by a power of two, and adds them, so precision is lost only in that one addition.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • constraint A rejection only carries information if both sides round the same number of times; otherwise a player cannot separate an honest last-bit disagreement from a server that cheated.
  • decision Anyone porting an outcome map into a second language now has a spec item to write down: how many times the number is rounded, and at which step.
  • exposure At roughly one round in 2 x 10^14, sampling will not surface the disagreement, so the audit is a line-by-line count of roundings in both implementations.
  • cost Reproducing the crash point exactly costs the browser an arbitrary-precision integer implementation to run a single division per round.

Count the roundings on each path. The high piece is four bytes, 32 bits, which a double holds exactly. The low piece is three bytes in the 56-bit function and four in the 64-bit one, also exact. Dividing each by 2^32, 2^56 or 2^64 shifts an exponent and leaves the significand alone, so it is exact for doubles [8]. One IEEE addition then collapses the value to 53 bits. The comment in _shared/rng.ts reads: "hi/2^32 and lo/2^56 are both exact dyadic doubles, so the one IEEE addition rounds the 56-bit value exactly once" [9].

The naive path is worth counting too. Accumulating one byte at a time, six bytes give at most 48 significant bits, which a double holds exactly; the seventh step lifts the value to 56 bits and rounds once [20]. By that count the seven-byte loop rounds as often as Kotlin's single conversion does. The eighth byte brings the second rounding, and u64, the eight-byte function, is the one every seed-pair original imports [10]. The obvious recipe has another rounding in it: the post describes reading bytes as an integer and dividing by the largest possible value [17], and 2^56 - 1 is not a power of two [22].

The window where any of this changes an outcome is narrow. Doubles just below 1 are spaced 2^-53 apart, so a one-bit disagreement moves 37x by at most 37 * 2^-53, about 4 x 10^-15, and floor(37x) changes only when an integer falls in that gap: about one round in 2 x 10^14 [21]. A defect at that rate does not come out of QA. In the source's own example, the last bit decides whether 36.9999999999999 floors to 36 or 37 [16].

Crash does not build a double at all. Its crash point is the bustabit formula, floor((100 * 2^52 - h) / (2^52 - h)), with h the leading 52 bits of the hash [12], and the post says that division sits on exactly the kind of boundary where a double can be off by one [24]. So the browser computes it with arbitrary-precision integers, the way the server does [13]. The comment in the source gives the reason in one line: a floating-point approximation "would disagree with integer division at floor boundaries and make the verifier reject good rounds" [14].

Two conditions have to hold for this to be your problem. A second implementation must re-derive the outcome from the same input, and the map from number to outcome must floor or index into a table. A verifier that only re-hashes a seed chain and compares digests never builds the double, so the rounding count does not reach it. The account is the site's own, read from its engine source [23], and it describes the affected rounds only as "a small fraction of perfectly honest rounds" [15].

What to watch

  • Whether Betkyo publishes hash-to-outcome test vectors a third party can run against both the Kotlin and JavaScript paths.
  • Whether the ULong-to-double conversion in LimboService.outcome100 rounds identically on every server target, since the u64 match depends on it.
  • A single reproduced rejection, or a measured rate, would move this from a source-comment argument to a measured one.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories