DocsRecords

Canonical JSON and Record Hash

The exact serialisation rules behind recordHash and record signatures, what canonicalJson does with values that are not JSON, and a Python port that produces the same bytes.

JSON doesn't have one spelling. {"nonce":42,"game":"dice"} and { "game": "dice", "nonce": 42 } are the same data and different bytes, and SHA-256 and Ed25519 only ever see bytes. Two honest implementations that serialise a record their own way will compute two hashes for it, and a signature made by one won't verify in the other.

So GFS fixes the spelling. canonicalJson turns a value into one specific string, that string is what gets hashed and signed, and a port in another language has to produce the same string, character for character. If you're a player, all you need from this page is that a record hash is a fingerprint: same hash, same record, down to the last digit.

The Rules

rules.mjs
import { canonicalJson } from '@galabet/fair';

console.log(canonicalJson({ b: 1, a: 2, B: 3, _: 0, 10: 'x', 9: 'y' }));
console.log(canonicalJson({ outer: { z: [{ b: 1, a: 2 }], y: null } }));
console.log(canonicalJson({ kept: 1, dropped: undefined, list: [1, undefined, 3] }));
console.log(canonicalJson([54.70, 100.0, 5000 / 100, 0.1 + 0.2, 1e21, 1e-7, 0.000001, -0]));
Output
{"10":"x","9":"y","B":3,"_":0,"a":2,"b":1}
{"outer":{"y":null,"z":[{"a":2,"b":1}]}}
{"kept":1,"list":[1,null,3]}
[54.7,100,50,0.30000000000000004,1e+21,1e-7,0.000001,0]

Keys are sorted, at every depth, and nothing else is reordered. Arrays keep their order because in a record the order of an array is data.

The sort compares UTF-16 code units, which is what JavaScript's < does to strings and what RFC 8785 asks for. It is not alphabetical: "10" sorts before "9", capitals before _, and _ before lowercase. Every field name in a GFS record is plain ASCII, where code units, code points and bytes all give the same order, so this only becomes a porting question if you add fields with unusual names. There's an example of that further down.

There is no whitespace anywhere.

An object member whose value is undefined is left out, the way JSON.stringify leaves it out. Inside an array undefined becomes null, again like JSON.stringify, since removing it would shift everything after it. A member that is null stays.

Numbers are printed the way JavaScript prints them, which is the shortest string that reads back as the same double. 54.70 is 54.7. 100.0 is 100, and so is any other whole number however it was computed. Exponents appear at 10²¹ and above and below 10⁻⁶, written 1e+21 and 1e-7. Negative zero is 0. This is the rule a port is most likely to get wrong, because most languages have their own idea of how to print a float.

Strings are escaped as JSON.stringify escapes them. Non-ASCII characters are written as themselves, not as \u escapes, and the string is encoded as UTF-8 before hashing.

Values That Are Not JSON

not-json.mjs
import { canonicalJson } from '@galabet/fair';

const at = new Date(1790000000000);
console.log(JSON.stringify({ at }));
console.log(canonicalJson({ at }));
console.log(canonicalJson({ seen: new Set([1, 2]), byGame: new Map([['dice', 1]]) }));

for (const value of [{ edge: NaN }, [1 / 0], { nonce: 42n }, { result: 1, toJSON() { return 'x'; } }]) {
  try {
    canonicalJson(value);
  } catch (error) {
    console.log(error.message);
  }
}
Output
{"at":"2026-09-21T14:13:20.000Z"}
{"at":{}}
{"byGame":{},"seen":{}}
canonicalJson: non-finite number
canonicalJson: non-finite number
canonicalJson: unsupported type bigint
canonicalJson: unsupported type function

JSON.stringify calls toJSON when a value has one, which is how a Date becomes an ISO string. canonicalJson never calls it. It treats a Date as an object with no fields of its own and writes {}, without complaint. A record built with at: new Date() would hash and sign happily and carry no timestamp. at is Unix milliseconds, a number. Set and Map go the same way.

Non-finite numbers throw, since JSON has no way to write them, and JSON.stringify would have quietly written null. A bigint, a function or a symbol throws unsupported type. The last case in the example is an object that defines its own toJSON method: the method is a function-valued member, so it throws too.

Record Hash

recordHash(record) is the SHA-256, in lowercase hex, of the record's signing payload: canonicalJson of the record with signature and signer removed. Record Format shows that removal at work. signRecord signs the same payload, so the hash and the signature always describe the same bytes.

hash.mjs
import { recordHash } from '@galabet/fair';

const record = {
  spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
  commitment: 'ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7',
  clientSeed: 'galabet', nonce: 42, cursor: 0, result: 56.12, at: 1790000000000,
};
const reordered = Object.fromEntries(Object.entries(record).reverse());
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';

const variants = {
  'as written': record,
  'fields reversed': reordered,
  'result as text': { ...record, result: '56.12' },
  'one extra field': { ...record, stake: 25 },
  'seed revealed': { ...record, serverSeed },
};

for (const [name, variant] of Object.entries(variants)) {
  console.log((await recordHash(variant)).slice(0, 16), name);
}
Output
9b0d37656b565a66 as written
9b0d37656b565a66 fields reversed
9ce16e88284a1840 result as text
0ac2239581f6202a one extra field
4bea13903c9ef849 seed revealed

Field order is the one thing that can change without changing the hash. Everything else in that list is a different record as far as the hash is concerned, and the last line is the one to plan for. A record gains its serverSeed at rotation, so it has one hash while the seed is live and another afterwards. If you show players a hash at bet time, or store one as an id, it's the unrevealed hash, and you get it back later by hashing the record with serverSeed removed. The same thing happens to signatures, and Signing Records deals with it at length.

Porting

The test for a port is short. Serialise the revealed public Dice record, hash it, and compare with this:

reference.mjs
import { canonicalJson, sha256Hex } from '@galabet/fair';

const record = {
  spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
  serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
  commitment: 'ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7',
  clientSeed: 'galabet', nonce: 42, cursor: 0, result: 56.12, at: 1790000000000,
};

console.log(canonicalJson(record));
console.log(await sha256Hex(canonicalJson(record)));

const awkward = [{ result: 5000 / 100 }, { houseEdge: 0.00001 }, { clientSeed: 'café' }, { '\u{1F600}': 2, '\uFB01': 1 }];
for (const value of awkward) {
  const text = canonicalJson(value);
  console.log((await sha256Hex(text)).slice(0, 12), text);
}
Output
{"at":1790000000000,"clientSeed":"galabet","commitment":"ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7","cursor":0,"game":"dice","nonce":42,"params":{},"profile":"single-player","result":56.12,"serverSeed":"5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d","spec":"GFS/1.0"}
4bea13903c9ef8493c60e6422b067abed2bf2c6440df1c97fbdc603cca522705
82cc0f5c2f5b {"result":50}
8039c1ff090f {"houseEdge":0.00001}
d4242f3e183b {"clientSeed":"café"}
14dc6c14e11d {"😀":2,"fi":1}

The four short values after the record are there because the Dice record is too polite. It has ASCII keys, a float with two decimals and nothing else that a serialiser could disagree about, so a sloppy port passes it.

Where json.dumps Differs

Python's standard library gets close with two arguments.

naive.py
import hashlib
import json

record = {
    "spec": "GFS/1.0", "profile": "single-player", "game": "dice", "params": {},
    "serverSeed": "5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d",
    "commitment": "ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7",
    "clientSeed": "galabet", "nonce": 42, "cursor": 0, "result": 56.12, "at": 1790000000000,
}


def naive(value):
    return json.dumps(value, sort_keys=True, separators=(",", ":"))


print(hashlib.sha256(naive(record).encode()).hexdigest())

awkward = [{"result": 5000 / 100}, {"houseEdge": 0.00001}, {"clientSeed": "café"}, {"\U0001F600": 2, "\uFB01": 1}]
for value in awkward:
    text = naive(value)
    print(hashlib.sha256(text.encode()).hexdigest()[:12], text)
Output
4bea13903c9ef8493c60e6422b067abed2bf2c6440df1c97fbdc603cca522705
fb635ff0e614 {"result":50.0}
d7443a3f2b26 {"houseEdge":1e-05}
1151dbb42051 {"clientSeed":"caf\u00e9"}
6b8490ef19a6 {"\ufb01":1,"\ud83d\ude00":2}

The record hash matches the reference. None of the other four do.

5000 / 100 is a float in Python and prints as 50.0. This is the dangerous one, because it isn't exotic. A Dice roll of exactly 50 is an ordinary result, and a port that divides by 100 to get it will hash that record differently from the reference while agreeing on nearly every other roll. We ran 50,000 random two-decimal values through both: json.dumps disagreed on 499, and all 499 were whole numbers.

Small numbers switch to exponent form earlier in Python, at 10⁻⁴, and pad the exponent to two digits. ensure_ascii defaults to on, which turns é into \u00e9. Passing ensure_ascii=False fixes that line, and the escapes in the last one, but not its order: Python sorts keys by code point, which puts fi (U+FB01) before the emoji (U+1F600), while UTF-16 code units put the emoji first because its leading surrogate is 0xD83D.

A Port That Matches

canonical.py
import hashlib
import json


def js_number(x):
    if isinstance(x, int):
        return str(x)
    if x != x or x in (float("inf"), float("-inf")):
        raise ValueError("non-finite number")
    if x == 0:
        return "0"
    text = repr(x)
    if "e" not in text:
        return text[:-2] if text.endswith(".0") else text
    mantissa, exponent = text.split("e")
    exponent = int(exponent)
    sign = "-" if mantissa.startswith("-") else ""
    digits = mantissa.lstrip("-").replace(".", "")
    if 0 < exponent < 21:
        return sign + digits + "0" * (exponent - len(digits) + 1)
    if -7 < exponent < 0:
        return sign + "0." + "0" * (-exponent - 1) + digits
    return mantissa + "e" + ("+" if exponent > 0 else "-") + str(abs(exponent))


def canonical(value):
    if value is None:
        return "null"
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, str):
        return json.dumps(value, ensure_ascii=False)
    if isinstance(value, (int, float)):
        return js_number(value)
    if isinstance(value, (list, tuple)):
        return "[" + ",".join(canonical(v) for v in value) + "]"
    if isinstance(value, dict):
        keys = sorted(value, key=lambda k: k.encode("utf-16-be"))
        return "{" + ",".join(json.dumps(k, ensure_ascii=False) + ":" + canonical(value[k]) for k in keys) + "}"
    raise TypeError("unsupported type " + type(value).__name__)


def sha256_hex(text):
    return hashlib.sha256(text.encode("utf-8")).hexdigest()


record = {
    "spec": "GFS/1.0", "profile": "single-player", "game": "dice", "params": {},
    "serverSeed": "5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d",
    "commitment": "ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7",
    "clientSeed": "galabet", "nonce": 42, "cursor": 0, "result": 56.12, "at": 1790000000000,
}

print(canonical(record))
print(sha256_hex(canonical(record)))

awkward = [{"result": 5000 / 100}, {"houseEdge": 0.00001}, {"clientSeed": "café"}, {"\U0001F600": 2, "\uFB01": 1}]
for value in awkward:
    print(sha256_hex(canonical(value))[:12])
Output
{"at":1790000000000,"clientSeed":"galabet","commitment":"ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7","cursor":0,"game":"dice","nonce":42,"params":{},"profile":"single-player","result":56.12,"serverSeed":"5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d","spec":"GFS/1.0"}
4bea13903c9ef8493c60e6422b067abed2bf2c6440df1c97fbdc603cca522705
82cc0f5c2f5b
8039c1ff090f
d4242f3e183b
14dc6c14e11d

Six lines, and they match the reference. The last four are hashes only, so that the example doesn't depend on your terminal being able to print an emoji.

repr already gives Python the shortest digits that round-trip, so js_number only has to rearrange them into JavaScript's layout. Python's bool is a subclass of int, which is why the bool test comes first. Integers are printed as they are, which is right below 2⁵³ and that covers every integer a record holds.

We checked this port against canonicalJson beyond the six lines above: 190,021 doubles, among them 100,000 random bit patterns, and 20,000 randomly generated nested objects with control characters, accented letters and astral characters in their keys and strings. It agreed on all of them. json.dumps with sort_keys, compact separators and ensure_ascii=False disagreed on 25,785 of the numbers and 2,870 of the objects. That's a test we ran once for this page, not part of the repository's test suite, and it says nothing about ports in other languages. PHP's json_encode and Java's Double.toString both print a whole-number float as 50.0, for a start, so ports in those languages need their own version of js_number.

Relation to RFC 8785

RFC 8785, the JSON Canonicalization Scheme, specifies the same three things: sort keys by UTF-16 code unit, no whitespace, numbers as ECMAScript prints them. For any value that came out of JSON.parse, canonicalJson is meant to agree with it, and it gets there by handing the hard parts, number and string formatting, to the JavaScript engine.

The source calls it an "RFC 8785 subset sufficient for records", and that's the honest description. It is not a full implementation, and nothing on this page was checked against the RFC's own test data. The places we know it departs are all inputs a record shouldn't contain: it writes {} for a Date instead of rejecting it, it emits [1,,3] for an array with a hole, and it passes a lone surrogate in a string through as an escape where the RFC calls for an error. The source comment describes the sort as "by code point". The code sorts by code unit, which is the RFC's rule, and this page describes the code.

A record made of the fields on Record Format, with finite numbers and ordinary strings, is inside what has been tested. Stay there.