DocsPorting

Already Running a Provably Fair System

How an operator with a working provably fair system finds out whether it already matches GFS 1.0, which differences are the usual ones, and what to do about bets already placed.

GFS 1.0 wasn't invented from a blank page. It writes down a scheme that was already in wide use: a server seed whose hash is published first, a client seed the player controls, a nonce that counts bets, HMAC-SHA256, four bytes to a float. If your system was built from the same descriptions, it may match today, or miss by one line.

Reading the spec next to your code won't settle it. The differences that matter are a missing :0, a 10000, a round. Eyes slide over those. Vectors don't.

Wrap Your Code and Run the Vectors

Write one function that takes (server_seed, client_seed, nonce, game, params) and returns a result, and have it call the code that settles bets in production. Not a fresh rewrite. A rewrite tests the rewrite.

The stand-in below plays the part of an existing system. It offers three games, and its Dice was written the way a lot of Dice was written.

adapter.py
import hashlib
import hmac
import json
import math
from collections import Counter


def existing(server_seed, client_seed, nonce, game, params):
    """Your system goes here. Return None for a game you don't offer."""
    message = f"{client_seed}:{nonce}:0".encode()
    digest = hmac.new(server_seed.encode(), message, hashlib.sha256).digest()
    f = int.from_bytes(digest[:4], "big") / 2**32
    if game == "dice":
        return math.floor(f * 10000) / 100
    if game == "roulette":
        return math.floor(f * 37)
    if game == "limbo":
        return max(1, math.floor(1e8 / (f * 1e8 + 1) * (1 - params["houseEdge"]) * 100) / 100)
    return None


with open("vectors/gfs-1.0.json") as handle:
    vectors = json.load(handle)["games"]

total, passed, first = Counter(), Counter(), {}
for v in vectors:
    got = existing(v["serverSeed"], v["clientSeed"], v["nonce"], v["game"], v["params"])
    if got is None:
        continue
    total[v["game"]] += 1
    if got == v["result"]:
        passed[v["game"]] += 1
    else:
        first.setdefault(v["game"], f"nonce {v['nonce']}: expected {v['result']}, got {got}")

for game in total:
    print(f"{game:9} {passed[game]:3} of {total[game]}  {first.get(game, '')}".rstrip())
Output
dice       51 of 112  nonce 0: expected 75.69, got 75.68
limbo     336 of 336
roulette  112 of 112

Roulette and Limbo already conform. Dice doesn't, and the harness shows the first round where it parts from the reference. Six games return None and aren't counted, which is fine: GFS defines nine seed-based games and nothing obliges you to run them all.

The vectors file also has commitments, digests and floats sections ahead of the games. If your code exposes those stages, check them first, in that order. The vectors page describes the file and has a complete Python runner for all nine games that you can diff your results against.

Partial Passes Mean Nothing

A system that computes every digest wrong still passes some vectors. We keyed the HMAC with the wrong bytes on purpose and ran all 2,240: 130 passed. Of those, 112 are Keno rounds that draw 40 numbers out of 40, and once sorted that result is 1 to 40 whatever the shuffle did. The other 18 are luck, nearly all in games with few outcomes: 2 of 112 Roulette, 6 of 336 Wheel, 7 of 336 Mines.

So don't read 114 of 336 on Keno as "mostly working". A game conforms when every one of its vectors passes. What a partial count is good for is diagnosis, because each of the usual differences fails in its own pattern:

What the run showsLook at
Digests 0 of 24, and every game down to the lucky fewThe HMAC key, or the message
Digests pass, one single-float game passes about halfThat game's arithmetic: a multiplier, a round
Digests pass, single-float games pass, Mines and Keno and the card games failThe shuffle
Only Mines fails, or only Blackjack and Hi-Lo, on every vectorNumbering
Only Limbo fails, and not at houseEdge: 0Where the edge is applied

Below, each difference comes with what it does to the public inputs: server seed 5c1f7d3e…1c2d, client seed galabet, nonce 42, where GFS gives Dice 56.12, Roulette 20, Limbo 1.76 and the Mines board 9, 17, 22.

Key as Decoded Bytes

key.py
import hashlib
import hmac
import json

server_seed = "5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d"


def roll(key):
    digest = hmac.new(key, b"galabet:42:0", hashlib.sha256).digest()
    return int.from_bytes(digest[:4], "big") * 10001 // 2**32 / 100


print("seed as text ", roll(server_seed.encode()))
print("seed decoded ", roll(bytes.fromhex(server_seed)))

with open("vectors/gfs-1.0.json") as handle:
    vectors = json.load(handle)

digests = sum(
    hmac.new(bytes.fromhex(v["serverSeed"]), f"{v['clientSeed']}:{v['nonce']}:{v['cursor']}".encode(), hashlib.sha256).hexdigest() == v["digest"]
    for v in vectors["digests"]
)
commitments = sum(hashlib.sha256(bytes.fromhex(v["serverSeed"])).hexdigest() == v["commitment"] for v in vectors["commitments"])
print(f"decoded key: {digests} of 24 digests, decoded commitment: {commitments} of 4")
Output
seed as text  56.12
seed decoded  52.21
decoded key: 0 of 24 digests, decoded commitment: 0 of 4

The server seed is 64 hex characters. GFS uses those 64 characters, as UTF-8 text, for the HMAC key, and hashes the same text for the commitment. A system that turns the seed into 32 bytes first is not weaker. It's a different scheme, and every number it produces is different.

Which side you're on tends to follow your language. PHP's hash_hmac takes its key as a string, so PHP code that was handed a hex seed keys with the text unless somebody went out of their way:

dice.php
$digest = hash_hmac('sha256', 'galabet:42:0', $serverSeed, true);            // GFS: the key is the text
$other  = hash_hmac('sha256', 'galabet:42:0', hex2bin($serverSeed), true);   // the decoded-bytes scheme

That snippet isn't run by our example checker, since PHP isn't installed where the docs are built. Python and Go ask for bytes, and a developer holding a hex string reaches for bytes.fromhex or hex.DecodeString without thinking of it as a decision.

Check the commitment separately. It's possible to key the HMAC one way and hash the commitment the other, and then the commitments section fails while digests passes, or the reverse.

A Message Without the Cursor

message.py
import hashlib
import hmac

server_seed = "5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d"

for message in ("galabet:42:0", "galabet:42", "galabet-42-0", "galabet-42"):
    digest = hmac.new(server_seed.encode(), message.encode(), hashlib.sha256).digest()
    print(f"{message:13} {int.from_bytes(digest[:4], 'big') * 10001 // 2**32 / 100}")
Output
galabet:42:0  56.12
galabet:42    80.91
galabet-42-0  47.16
galabet-42    95.42

The GFS message is clientSeed:nonce:cursor. A system that only ever ran Dice, Limbo and Roulette needs one digest per bet, so it had no reason to grow a cursor, and its message stops after the nonce. In the vectors this looks the same as the wrong key: 0 of 24 digests and nothing after them. The public inputs tell the two apart, 52.21 against 80.91.

If the missing cursor is your only difference, it's the smallest change on this page. Append :0, and for games that read more than eight floats count up from there, one step per digest. Nonce and cursor has the detail.

Dice Scaled by 10000

This is the Dice in adapter.py above, and its output is the symptom: 51 of 112, with digests and floats all passing. The public inputs give 56.11.

Every failure is low by exactly 0.01, never more. The two products differ by the float itself, so they floor to the same integer for small floats and part ways as the float grows. In the vectors, 43 of the 59 rounds below 50 still match and only 8 of the 53 at 50 or above do. A spot check on a few low rolls would pass.

You can't adjust your way out of this one. floor(float × 10000) has 10,000 outcomes and tops out at 99.99. GFS Dice has 10,001 and reaches 100.00, for the reasons on the Dice page. They're different games with different odds tables.

Modulo in Shuffles

shuffles.py
import hashlib
import hmac
import json
import math


def uint32s(server_seed, client_seed, nonce, count):
    out, cursor = [], 0
    while len(out) < count:
        digest = hmac.new(server_seed.encode(), f"{client_seed}:{nonce}:{cursor}".encode(), hashlib.sha256).digest()
        out += [int.from_bytes(digest[i:i + 4], "big") for i in range(0, 32, 4)]
        cursor += 1
    return out[:count]


def gfs(values, i):        # floor(float * (i + 1))
    return math.floor(values / 2**32 * (i + 1))


def modulo(values, i):
    return values % (i + 1)


def fisher_yates(numbers, pick):
    tiles = list(range(25))
    for n, i in enumerate(range(24, 0, -1)):
        j = pick(numbers[n], i)
        tiles[i], tiles[j] = tiles[j], tiles[i]
    return tiles


def draw_from_pool(numbers, _):
    pool, drawn = list(range(25)), []
    for n in range(24):
        drawn.append(pool.pop(math.floor(numbers[n] / 2**32 * len(pool))))
    return drawn + pool


with open("vectors/gfs-1.0.json") as handle:
    rounds = [v for v in json.load(handle)["games"] if v["game"] == "mines"]

seed = "5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d"
for name, shuffle, pick in (("GFS", fisher_yates, gfs), ("modulo", fisher_yates, modulo), ("draw from pool", draw_from_pool, None)):
    board = sorted(shuffle(uint32s(seed, "galabet", 42, 24), pick)[:3])
    ok = sum(sorted(shuffle(uint32s(v["serverSeed"], v["clientSeed"], v["nonce"], 24), pick)[:v["params"]["mines"]]) == v["result"] for v in rounds)
    print(f"{name:15} {str(board):13} {ok:3} of {len(rounds)}")
Output
GFS             [9, 17, 22]   336 of 336
modulo          [11, 16, 22]   10 of 336
draw from pool  [1, 2, 14]      0 of 336

Two different habits, same symptom. The first takes the 32-bit integer modulo the number of remaining positions. It's the obvious thing to write. With 32 bits behind each pick its unevenness is as tiny as the scaled version's, and the floats page counts both, so this is a question of matching and not of one being unfair. The second isn't Fisher-Yates at all: it keeps a pool, picks an index into it, removes that item and repeats. Both are legitimate ways to place three mines. Neither places them where GFS does.

The 10 Mines vectors that the modulo version passes are luck: 5 on one-mine boards and 5 on 24-mine boards, the two settings with only 25 possible answers. The card games have none of that slack, and in our run both variants passed 0 of 336 Blackjack and Hi-Lo vectors.

A third variant walks the swap index up from 0. It's also a correct shuffle and also passed 0 of 336 Mines vectors. GFS walks i from the last position down to 1 and swaps with floor(float × (i + 1)), one fresh float per swap.

Card and Tile Numbering

Here the shuffle is right and the labels are not.

GFSA common alternativePublic inputs under the alternative
Mines tiles0 to 241 to 2510, 18, 23 where GFS has 9, 17, 22
Keno numbers1 to 400 to 39every number one lower
Card indexsuit × 13 + rank, suits C D H S, ranks A 2 … 9 T J Q Krank × 4 + suitthe deck opens 2H 4C JD AC TD where GFS deals 7C KC 3S AC QH

Note that GFS itself is not consistent here: Mines counts from 0 and Keno from 1. A system that picked one convention for both is wrong on one of them.

With tiles numbered from 1, all 336 Mines vectors fail and every entry is one too high, which is about as readable as a failure gets. With the other card numbering, 0 of 336 card vectors pass, and the results look like a working shuffle of a real deck. It's tempting to call this cosmetic and fix it with a lookup table. For new bets, that is the fix. For old ones it isn't cosmetic: the player was shown the two of hearts, and a GFS verifier fed the same seeds says seven of clubs.

Multi-deck shoes number cards 0 to 52 × decks − 1 and take the index modulo 52 before labelling. Mines and Keno sort their output ascending, so a system that returns draw order fails too: 131 of 336 Mines and 112 of 336 Keno still pass, which are the one-mine boards plus a few lucky ones, and the one-number draws.

Rounding Instead of Flooring

rounding.py
import hashlib
import hmac
import json
import math


def first_float(server_seed, client_seed, nonce):
    digest = hmac.new(server_seed.encode(), f"{client_seed}:{nonce}:0".encode(), hashlib.sha256).digest()
    return int.from_bytes(digest[:4], "big") / 2**32


def nearest(x):
    return math.floor(x + 0.5)


rounded = {
    "dice": lambda f, p: nearest(f * 10001) / 100,
    "roulette": lambda f, p: nearest(f * 37),
    "wheel": lambda f, p: nearest(f * p["segments"]),
}

f = first_float("5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d", "galabet", 42)
print("public inputs:", rounded["dice"](f, {}), rounded["roulette"](f, {}))

with open("vectors/gfs-1.0.json") as handle:
    vectors = json.load(handle)["games"]

for game, mapper in rounded.items():
    rounds = [v for v in vectors if v["game"] == game]
    ok = sum(mapper(first_float(v["serverSeed"], v["clientSeed"], v["nonce"]), v["params"]) == v["result"] for v in rounds)
    print(f"{game:9} {ok:3} of {len(rounds)}")
Output
public inputs: 56.12 21
dice       62 of 112
roulette   54 of 112
wheel     165 of 336

Dice still says 56.12. That's the trouble with rounding: it agrees with flooring whenever the fraction is under a half, so it's right about half the time, and the public Dice inputs happen to land on the agreeing side. Roulette gives it away with 21 where the pocket is 20.

A rounded Roulette has a second problem that no vector shows. A float of 0.99 times 37 is 36.63, which rounds to 37, and the wheel has no pocket 37. Pocket 0 would also come up half as often as the others. Flooring is what keeps every outcome the same width.

Limbo Edge Applied Elsewhere

GFS computes 1e8 / (float × 1e8 + 1), multiplies by 1 − houseEdge, multiplies by 100, floors, divides by 100, and lifts anything under 1 to 1.00. Existing systems mostly agree on the shape and disagree on where the edge goes.

limbo-edge.py
import hashlib
import hmac
import json
import math


def first_float(server_seed, client_seed, nonce):
    digest = hmac.new(server_seed.encode(), f"{client_seed}:{nonce}:0".encode(), hashlib.sha256).digest()
    return int.from_bytes(digest[:4], "big") / 2**32


def gfs(f, edge):
    return max(1, math.floor(1e8 / (f * 1e8 + 1) * (1 - edge) * 100) / 100)


def edge_after_floor(f, edge):
    return max(1, math.floor(math.floor(1e8 / (f * 1e8 + 1) * 100) / 100 * (1 - edge) * 100) / 100)


def instant_bust(f, edge):      # the edge is a share of rounds that pay 1.00
    return 1 if f < edge else max(1, math.floor(1e8 / (f * 1e8 + 1) * 100) / 100)


def no_plus_one(f, edge):
    return max(1, math.floor((1 - edge) / f * 100) / 100)


with open("vectors/gfs-1.0.json") as handle:
    rounds = [v for v in json.load(handle)["games"] if v["game"] == "limbo"]

f = first_float("5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d", "galabet", 42)
for mapper in (gfs, edge_after_floor, instant_bust, no_plus_one):
    ok = sum(mapper(first_float(v["serverSeed"], v["clientSeed"], v["nonce"]), v["params"]["houseEdge"]) == v["result"] for v in rounds)
    print(f"{mapper.__name__:17} {mapper(f, 0.01):5} {ok:3} of {len(rounds)}")

sample = range(1, 2**32, 4099)
differs = [gfs(u / 2**32, 0.01) for u in sample if gfs(u / 2**32, 0.01) != no_plus_one(u / 2**32, 0.01)]
print(f"no_plus_one differs on {len(differs)} of {len(sample)} evenly spaced floats")
print(f"lowest {min(differs)}, {sum(x >= 1000 for x in differs)} of them at 1000 or more")
Output
gfs                1.76 336 of 336
edge_after_floor   1.76 226 of 336
instant_bust       1.78 112 of 336
no_plus_one        1.76 336 of 336
no_plus_one differs on 2027 of 1047809 evenly spaced floats
lowest 1.7, 1038 of them at 1000 or more

A third of the Limbo vectors run at houseEdge: 0, where the edge can't be in the wrong place, so a misplaced edge never scores below 112. Flooring to cents first and taking the edge off afterwards loses about half of the rest and still returns 1.76 for the public inputs. Treating the edge as a share of rounds that bust at 1.00 loses all of the rest and gives 1.78.

The last row is a limit of the vectors and it's better you hear it from us. Leaving out the + 1 passes all 336. It isn't equivalent. Across a little over a million evenly spaced floats it disagrees on 2,027, about one in five hundred. Half of those are results of 1000 or more, where the + 1 is always worth a cent, but the lowest is a 1.7, so no part of the range is safe. It also divides by zero on a float of exactly 0. If your Limbo has no + 1, the vectors won't tell you. Compare the formula by eye, this once.

Multiplications folded together are harmless. We tried (1 − edge) × 1e8 / (…) and (1 − edge) × 100 as one constant over a million floats and both matched the reference on every one. The Limbo page has the reference order.

When Your System Differs

You cannot change how a settled bet was derived. Every bet placed under your current scheme was computed by it, shown to a player by it, and can only ever be verified by it. So the old verifier stays, for as long as you keep those records.

What you can choose is where the line falls, and the clean place is a seed rotation. A server seed is committed under one scheme and lives its whole life under that scheme. Rotate, reveal the old seed as usual, and commit the new one under GFS: commitment over the seed text, HMAC keyed the same way, message with the cursor, mappers as specified. No seed ever has bets from both schemes, so no player has to ask which rules applied to nonce 118. Rotation and reveal covers the mechanics.

Then say which scheme in every record. That is what the spec field is for. New records carry "spec": "GFS/1.0". Give the old scheme a name of its own, put it in the old records, and publish what it means. The reference verifier reads the field before anything else:

old-record.mjs
import { verifyRecord } from '@galabet/fair';

// A Dice round from a system that keyed the HMAC with decoded bytes. 52.21 was the honest result there.
const record = {
  spec: 'house/1', profile: 'single-player', game: 'dice', params: {},
  serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
  commitment: 'ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7',
  clientSeed: 'galabet', nonce: 42, cursor: 0, result: 52.21, at: 0,
};

const { ok, reasons } = await verifyRecord(record);
console.log(ok, reasons);
Output
false [ 'unsupported spec house/1', 'result does not match seeds' ]

unsupported spec house/1 is the useful half of that answer. Without an honest spec, the same record labelled GFS/1.0 would come back as a plain mismatch, and a mismatch reads as cheating. inspectRecord, which is what the verifier page runs, is stricter still and throws Unsupported calculation version. Expected GFS/1.0. before it checks anything. Route old records to your old verifier by that field, and keep its description public next to them.

Passing the vectors says your derivation matches GFS 1.0 and nothing beyond that. It doesn't cover when commitments are published, how nonces are reserved, or payouts, and What verification proves is plain about the rest.