DocsReference
Troubleshooting
Symptoms people hit with @galabet/fair 0.1.0, each reproduced before it was written down, with the causes in the order they usually turn out to be true.
Headings on this page are the text you see, so searching the page for your message should land on it. Every entry was reproduced against 0.1.0 on Node 24, and the two browser entries in Chrome. The full list of messages, without the diagnosis, is on the errors page.
Several of the messages below come out of this one file. It is CommonJS on purpose, which settles the require question at the bottom of the page as well.
const { commit, generateKeyPair, play, signRecord, verifyRecord } = require('@galabet/fair');
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const seeds = { serverSeed, clientSeed: 'galabet', nonce: 42 };
async function main() {
const inputs = [
{ game: 'dice', ...seeds, serverSeed: serverSeed.toUpperCase() },
{ game: 'plinko', params: { rows: '12' }, ...seeds },
{ game: 'blackjack', params: { decks: 0 }, ...seeds },
];
for (const input of inputs) {
try {
await play(input);
} catch (error) {
console.log(error.message);
}
}
const { commitment } = await commit(serverSeed);
const { result, cursor } = await play({ game: 'mines', params: { mines: 3 }, ...seeds });
const record = {
spec: 'GFS/1.0', profile: 'single-player', game: 'mines', params: { mines: 3 },
commitment, clientSeed: 'galabet', nonce: 42, cursor, result, at: 0,
};
console.log((await verifyRecord(record)).reasons);
console.log((await verifyRecord({ ...record, serverSeed, cursor: 3 })).reasons);
console.log((await verifyRecord({ ...record, serverSeed, cursor: '2' })).reasons);
const keys = await generateKeyPair();
const signedAtBetTime = await signRecord(record, keys.secretKey);
console.log((await verifyRecord({ ...signedAtBetTime, serverSeed })).reasons);
const signedAfterReveal = await signRecord({ ...record, serverSeed }, keys.secretKey);
console.log((await verifyRecord(signedAfterReveal)).ok);
}
main();
server seed must be 64 lowercase hex characters
count must be a positive integer
count must be a positive integer
[ 'server seed not revealed yet; verify after rotation' ]
[ 'cursor mismatch: computed 2, record 3' ]
[ 'cursor mismatch: computed 2, record 2' ]
[ 'signature does not verify under signer' ]
true
server seed must be 64 lowercase hex characters
The seed has capital letters. That is the usual answer when the seed passed through a form, a spreadsheet or a database column with a case-insensitive collation and came back as 5C1F.... The check is the regular expression /^[0-9a-f]{64}$/ and nothing is lowercased for you, because the seed string is the HMAC key and 5C1F is a different key from 5c1f. Lowercase it yourself only if you know the original was lowercase. Seeds from createServerSeed always are.
Next most likely is whitespace. A leading space or a trailing newline from a text file fails the same check with the same message, and so does a 0x prefix. Then length: 63 characters after a careless copy, or 128 because a signing key was passed where the seed belonged.
Last, the value isn't a string at all. undefined produces this message, which is what you get when the record field is called server_seed or seed in your storage and serverSeed in the call. A Buffer holding the 32 decoded bytes fails too. The library wants the text.
commit, verifyCommitment, deriveDigest, deriveFloats, play and verifyRecord all throw it. inspectRecord words the same problem differently: serverSeed: expected 64 lowercase hexadecimal characters.
count must be a positive integer
You didn't pass a count. You passed rows or decks.
play asks the game how many floats it needs before the mapper has validated anything. Plinko needs rows floats and a card game needs 52 × decks − 1. When that arithmetic gives zero, a negative or a fraction, deriveFloats refuses it in its own vocabulary, and the message never names the parameter. These inputs all produced it: rows: 0, rows: -1, rows: 8.5, rows: '12', decks: 0, decks: -1.
The string is the one that catches people. '12' arrives from a query string or a form field, the float count becomes the string '12', and a string is not an integer. Convert with Number() and check the range (8 to 16 rows, 1 to 8 decks) before calling play.
Values that are whole numbers but out of range get the message you'd expect. rows: 7 throws rows must be 8 to 16 and decks: 9 throws decks must be 1 to 8. One oddity going the other way: decks: '2' throws decks must be 1 to 8, because 52 × '2' − 1 is a perfectly good 103 in JavaScript and the string is only noticed later by the mapper.
A Port Returns 52.21 for the Public Dice Inputs
The port hex-decoded the server seed before using it as the HMAC key. GFS keys the HMAC with the 64 characters of the seed as text. The Dice page has the wrong version as a runnable Python file.
52.21 is the one worth memorising, but the other mistakes have signatures too. With server seed 5c1f7d3e…1c2d, client seed galabet and nonce 42, where the right answer is 56.12:
| Your port prints | What it did |
|---|---|
| 52.21 | Decoded the seed to 32 bytes and used those as the key |
| 80.91 | Left the cursor off the message: galabet:42 and not galabet:42:0 |
| 0.57 | Zero-padded the nonce: galabet:042:0 |
| 87.86 | Read the first four bytes little-endian |
| 56.11 | Multiplied by 10000 where the mapper multiplies by 10001 |
Rounding where the mapper floors also gives 56.12 for these inputs, which is why a single matching round proves so little. Run the test vectors.
verifyRecord Returns "cursor mismatch"
The full reason reads cursor mismatch: computed 2, record 3, and the two numbers tell you which cause you have.
If the record is one higher than computed, the writer stored the number of digests. cursor is the highest cursor index the round read, counting from 0. Mines reads three digests and its cursor is 2. Keno's is 4, a single deck's is 6, Plinko's is 1 at 16 rows and 0 at 8. The nonce and cursor page explains the counting.
If the reason shows the same number twice, as computed 2, record 2 does in the output above, the types differ. The comparison is ===, and the record carries the string '2', usually because the record was rebuilt from a database row or a URL. A record with no cursor field at all reports record undefined.
If result does not match seeds is in the list with it, the parameters in the record aren't the ones the round was played with. A Plinko round played at 8 rows and stored with params: {} is replayed at the default 16, which gives computed 1, record 0 and a different path.
verifyRecord Returns "server seed not revealed yet; verify after rotation"
Nothing is wrong with the round. The record has no serverSeed, so there was nothing to recompute, and ok is false because nothing was confirmed. computed is null and commitmentOk and cursorOk are both false for the same reason. Don't show this to a player as a failed check. Wait for the seed pair to be rotated, add the revealed seed to the record, and verify again.
If the seed has been revealed and you still see this, the field is there under another name, or it's an empty string or null. The test is truthiness of record.serverSeed, so server_seed and seed are invisible to it.
"signature does not verify under signer" on an Honest Record
This happens to every record that was signed when the bet was placed and verified after the seed was revealed. It is a property of 0.1.0 and not a fault in your keys.
The signature covers the canonical record minus signature and signer. Everything else is in the payload, and after reveal "everything else" includes serverSeed, which wasn't there when the operator signed. The payload changed, so the signature over the old payload fails, and verifyRecord reports it without any hint that the rest of the record is fine. In the output at the top of this page, that is the second-to-last line. commitmentOk and cursorOk are both true on that record. inspectRecord behaves the same way and gives the whole record a status of mismatch.
There are two ways through. Verify the signature against the record as it was signed, by removing serverSeed and calling verifyRecordSignature on what's left, and check the seed-dependent parts separately. Or have the operator sign the revealed record again, which is the last line of the output. A re-signed record proves less about timing, since that signature was made after the seed was public. Sign Again at Reveal says more about the trade.
Once that is ruled out, the ordinary causes apply. Any field edited after signing breaks the signature, including at and params. A record with signer and no signature, or the reverse, fails. Uppercase hex in either field fails, because malformed keys and signatures return false and don't throw.
crypto.subtle Is Undefined in the Browser
The page is on an insecure origin. Browsers expose Web Crypto only on https://, on http://localhost and on http://127.0.0.1. Open the same page through a LAN address or any other plain http:// hostname, which is what happens when you test on a phone against your laptop, and window.isSecureContext is false, crypto is still an object, and crypto.subtle is undefined.
We loaded the library on such an origin in Chrome to see what it does. Every function that hashes or draws random bytes rejects, and the message depends on how the library reached the page. Bundled with Vite it was TypeError: Cannot read properties of undefined (reading 'subtle'), and createServerSeed failed with (reading 'getRandomValues'). Loaded as a plain ES module with no bundler it was TypeError: Failed to resolve module specifier 'crypto'. Both are the library falling through to its Node fallback, which a browser can't satisfy. None of these messages mention secure contexts, so check window.isSecureContext first.
The synchronous exports kept working on that origin: the mappers in @galabet/fair/games and canonicalJson don't touch crypto. createServerSeed fails even though crypto.getRandomValues exists there, because the library looks for crypto.subtle before deciding which crypto object to use.
The fix is on your side. Serve over HTTPS, or use localhost with a port forward. Installation lists the browser minimums for signing, which is a separate limit.
Module "crypto" has been externalized for browser compatibility
[plugin vite:resolve] Module "crypto" has been externalized for browser compatibility, imported by ".../@galabet/fair/dist/index.js".
Harmless, as long as the page is on a secure origin. The source has await import('node:crypto') as a fallback for a Node without a global crypto, and the built file has it as import('crypto'), which is the name Vite prints. The import sits behind if (globalThis.crypto?.subtle), so a browser with Web Crypto returns before reaching it. We built a page with that warning and it computed 56.12 on http://127.0.0.1.
The warning and the previous entry are the same line of code seen from two sides. The stub that Vite substitutes has no webcrypto export, and that is where reading 'subtle' comes from when the origin is insecure.
Two Bets Have the Same Result
Were the inputs the same? Server seed, client seed and nonce decide the round completely, so two records that agree on all three will agree on the result forever, and that is a bug in the operator's nonce handling: a counter that didn't advance, two requests that read it at the same moment, or a nonce that was reset without rotating the server seed. Storing seeds and reserving nonces covers the race.
If the nonces differ, it's chance, and more common than it feels. Dice has 10,001 outcomes. Under the public seeds, nonces 56 and 216 both roll 39.54, and in the first 1,000 nonces 48 rolls repeat an earlier one. Roulette has 37 pockets, and nonces 2 and 13 both land on 26. Limbo at a 1% edge paid exactly 1.00 on 19 of the first 1,000 nonces. Compare the nonces before anything else.
One more cause sits outside the safe range of a double. Nonces 2**53 and 2**53 + 1 are the same number in JavaScript and both rolled 57.35. play accepts them. inspectRecord stops at Number.MAX_SAFE_INTEGER.
Record Hashes Differ Between Two Implementations
Both sides computed the same result and the hashes still disagree, so the canonical strings differ. Print the two strings next to each other before comparing hashes. The difference is nearly always visible in the first place you look, and it is one of these.
Number formatting comes first. The reference prints numbers the way JavaScript does, in their shortest form, so a Dice roll of 50 is 50 and a Limbo result of 1 is 1. A language that keeps floats and integers apart will write 50.0. Python does, and it has three more defaults that break a hash:
import json
print(json.dumps(50.0), json.dumps(1e-7))
print(json.dumps({"clientSeed": "é", "nonce": 42}))
print(json.dumps({"nonce": 42, "clientSeed": "é"}, sort_keys=True, separators=(",", ":"), ensure_ascii=False))
50.0 1e-07
{"clientSeed": "\u00e9", "nonce": 42}
{"clientSeed":"�","nonce":42}
The first line shows 50.0, and an exponent written 1e-07 where JavaScript writes 1e-7. The second shows spaces after the separators, unsorted keys and é escaped to é, where the reference writes the character itself. The third line fixes those three with arguments. The 50.0 still needs code of your own: emit a float with no fractional part as an integer. Formatting to two decimals is wrong in the other direction, since 54.70 is not 54.7. A result stored as the string "56.12" verifies as a mismatch and hashes differently as well.
Extra fields are second. The hash covers every field except signature and signer. A stake, a playerId or an id added for convenience changes it, and so does a parameter the game doesn't read, such as houseEdge on a Dice record. Keep bet data beside the record, not inside it. The opposite slip is quieter: a member set to null is hashed as null, while a member that is undefined is dropped, so serverSeed: null on an unrevealed record gives a different hash from leaving the field out.
Then params. {} and { rows: 16 } produce the same Plinko path and both verify, and they are different strings with different hashes. Two implementations have to agree on which one they write, and the record format page argues for the explicit value.
If all of that matches, check whether one side hashed the record before reveal and the other after. Adding serverSeed changes the hash by design.
inspectRecord Reports "incomplete"
incomplete means nothing contradicted the record and something needed couldn't be checked. It is not a softer word for mismatch. Read checks and look for any state other than matches.
For a seed-based game, three checks have to reach matches: Commitment, Cursor and Outcome. Leave out serverSeed and all three are not-provided. Leave out commitment, cursor or result and the corresponding one is. A result of null counts as absent. Signature: not-provided doesn't hold a record back, and neither does a missing at, though without at there is no recordHash in the report.
A beacon field forces incomplete every time. 0.1.0 can't check a beacon, reports it as unsupported, and any unsupported check blocks matches. The same goes for a signature on a Crash or Flight record, and for a signed single-player record on a platform without Ed25519.
Crash records need Outcome plus one of commitment or previousHash. With neither, the multiplier is recomputed and the report still says incomplete, because nothing tied the game hash to anything published beforehand.
require() of the Package
It works. The package ships an ES module build and a CommonJS build, and exports in its package.json routes require to dist/index.cjs for both @galabet/fair and @galabet/fair/games. The example at the top of this page is a .cjs file that the documentation checker runs.
What goes wrong is the asynchrony. console.log(play(...)) prints Promise { <pending> }. Top-level await in a CommonJS file is a SyntaxError: await is only valid in async functions and the top level bodies of modules. Wrap the calls in an async function, as the example does, or use .then. The mappers are synchronous, so require('@galabet/fair/games').dice(0.5611712262034416) returns 56.12 directly.
TypeScript has two problems of its own, both reproduced with TypeScript 5.7. With moduleResolution set to node (the old node10 mode), the main import resolves and the subpath doesn't: Cannot find module '@galabet/fair/games' or its corresponding type declarations. That mode can't read exports. Use node16, nodenext or bundler. And under node16, a file that compiles to CommonJS gets error TS1479 on the import, saying the referenced file is an ECMAScript module. The build contains index.d.cts, but the exports map in 0.1.0 points require at the same .d.ts as import, so TypeScript never finds the CommonJS typings. The JavaScript runs either way. Until that is fixed, compile as ESM, use bundler resolution, or use a dynamic import().
