DocsCore concepts

Nonce and Cursor

Two counters in the HMAC message that get confused, one counting bets and one counting digests inside a single bet.

NonceCursor
CountsBets under one pair of seedsDigests read inside one bet
Moved byThe operator, once per betThe library, when a game needs more than eight floats
Starts at 0For each new seed pairFor each bet
Has to be stored between betsYes, and reserved atomicallyNo
In the recordThe value usedThe highest value read

Both end up in the same string. The HMAC message is clientSeed:nonce:cursor, so for the public inputs the first bet's first digest comes from galabet:0:0. Players meet the nonce, because it's the bet number shown next to their seeds. Most never see a cursor, and for four of the nine games it is always 0.

Crash has neither. Its rounds come from a hash chain, described on the Crash page.

The Message for a Few Pairs

messages.mjs
import { createHmac } from 'node:crypto';
import { deriveDigest, toHex } from '@galabet/fair';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const clientSeed = 'galabet';

for (const [nonce, cursor] of [[0, 0], [1, 0], [42, 0], [42, 1], [42, 2], [43, 0]]) {
  const message = `${clientSeed}:${nonce}:${cursor}`;
  const mine = createHmac('sha256', serverSeed).update(message).digest('hex');
  const library = toHex(await deriveDigest({ serverSeed, clientSeed, nonce }, cursor));
  console.log(message.padEnd(14), mine.slice(0, 16), mine === library);
}
Output
galabet:0:0    880855911adc8beb true
galabet:1:0    81f708bb2ae0e403 true
galabet:42:0   8fa8eae01ccd4312 true
galabet:42:1   6c6f0bb466d3db74 true
galabet:42:2   146299d3e8bdf3cc true
galabet:43:0   76b5b4c97c71e8d6 true

The numbers are written as plain decimal with no padding, 42 and never 042. The third line, galabet:42:0, is the digest the Dice walkthrough takes apart byte by byte. The two lines under it belong to the same bet. A game that needs a ninth float asks for galabet:42:1, and a seventeenth comes from galabet:42:2. Then bet 43 starts over at cursor 0.

Changing either number by one gives a digest with nothing visibly in common with its neighbour, which is what HMAC is for. Neither counter is more "random" than the other. They differ in who moves them and why.

Cursor Reached by Each Game

A digest holds eight floats. How far the cursor travels depends only on how many floats the game asks for.

cursors.mjs
import { play } from '@galabet/fair';

const seeds = {
  serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
  clientSeed: 'galabet',
  nonce: 42,
};

const rounds = [
  ['dice'], ['limbo'], ['roulette'], ['wheel'], ['plinko'], ['mines'], ['keno'], ['blackjack'], ['hilo'],
  ['plinko', { rows: 8 }], ['blackjack', { decks: 8 }],
];

for (const [game, params] of rounds) {
  const { cursor, floats } = await play({ game, params, ...seeds });
  const label = params ? `${game} ${JSON.stringify(params)}` : game;
  console.log(label.padEnd(22), `floats ${String(floats.length).padStart(3)}  cursor ${cursor}`);
}
Output
dice                   floats   1  cursor 0
limbo                  floats   1  cursor 0
roulette               floats   1  cursor 0
wheel                  floats   1  cursor 0
plinko                 floats  16  cursor 1
mines                  floats  24  cursor 2
keno                   floats  39  cursor 4
blackjack              floats  51  cursor 6
hilo                   floats  51  cursor 6
plinko {"rows":8}      floats   8  cursor 0
blackjack {"decks":8}  floats 415  cursor 51

The first nine lines use default parameters. Dice, Limbo, Roulette and Wheel take one float and stay at cursor 0. Plinko takes a float per row, so its default of 16 rows fills two digests exactly. The shuffles cost one float per swap: 24 for the Mines board, 39 for Keno's 40 numbers, 51 for a deck.

The last two lines are there because parameters move the cursor. Plinko with 8 rows fits in one digest. An eight-deck shoe needs 415 floats, which is 52 digests and a cursor of 51. The cursor is a consequence of game and params, never something the operator picks, and the game pages say where each count comes from.

The Record Stores the Highest Cursor Read

Keno's record says "cursor": 4. Five digests were computed for it, cursors 0 to 4. The field is an index, and the count is always one more. People read it as a count often enough that it's worth stating the formula: for a bet that needs n floats, the cursor is ceil(n / 8) - 1.

Could the field be left out, since anyone can recompute it? Yes, in the sense that it carries no information the other fields don't. It's there as a cross-check. verifyRecord recomputes the cursor and compares, and a disagreement means the operator's implementation and the verifier's don't agree on how many floats the game consumes, which is worth knowing even when the result happens to match.

wrong-cursor.mjs
import { commit, play, verifyRecord } from '@galabet/fair';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);
const { result, cursor } = await play({ game: 'keno', serverSeed, clientSeed: 'galabet', nonce: 42 });

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

console.log(cursor, (await verifyRecord(record)).ok);

const counted = await verifyRecord({ ...record, cursor: 5 }); // someone stored the digest count
console.log(counted.ok, counted.reasons);
Output
4 true
false [ 'cursor mismatch: computed 4, record 5' ]

Nonce Must Be a Non-Negative Integer

And a JavaScript number. JSON bodies and form fields are where a string nonce comes from, and the library does not coerce it.

bad-nonce.mjs
import { deriveDigest, play } from '@galabet/fair';

const seeds = {
  serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
  clientSeed: 'galabet',
};

for (const nonce of ['42', -1, 1.5, NaN]) {
  try {
    await play({ game: 'dice', ...seeds, nonce });
  } catch (error) {
    console.log(typeof nonce === 'string' ? `"${nonce}"` : nonce, '->', error.message);
  }
}

try {
  await deriveDigest({ ...seeds, nonce: 42 }, -1);
} catch (error) {
  console.log(error.message);
}
Output
"42" -> nonce must be a non-negative integer
-1 -> nonce must be a non-negative integer
1.5 -> nonce must be a non-negative integer
NaN -> nonce must be a non-negative integer
cursor must be a non-negative integer

Refusing "42" looks fussy, since it would produce the right message string. It's the right call anyway. A nonce that is sometimes a string will sooner or later be "42" + 1, which is "421". verifyRecord runs the same check, so a record with a string nonce throws where you might expect a reasons entry. The cursor has the same rule, though you only meet it when calling deriveDigest yourself.

No Upper Bound

Nothing in play or deriveDigest stops a nonce from being too large, and JavaScript numbers stop telling neighbouring integers apart at 2⁵³.

big-nonce.mjs
import { deriveDigest, toHex } from '@galabet/fair';

const seeds = {
  serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
  clientSeed: 'galabet',
};

const a = toHex(await deriveDigest({ ...seeds, nonce: 2 ** 53 }));
const b = toHex(await deriveDigest({ ...seeds, nonce: 2 ** 53 + 1 }));

console.log(Number.MAX_SAFE_INTEGER);
console.log(a === b);
console.log(`galabet:${1e21}:0`);
Output
9007199254740991
true
galabet:1e+21:0

Two different bets, one digest, and no error. Past 10²¹ the message stops being decimal digits at all, and a port in a language with real integers would disagree with the reference. inspectRecord refuses a nonce above Number.MAX_SAFE_INTEGER with nonce: enter a whole number from 0 to 9007199254740991. play doesn't. None of this comes up if seeds are rotated in any sensible way, since one bet a second reaches 2⁵³ in about 285 million years. But if your nonce comes from a database sequence shared across players, or from anything that isn't a small per-seed counter, put your own ceiling on it and keep it far below 2⁵³.

Reserving a Nonce

Every rule above can be followed and the scheme still broken by handing one nonce to two bets. Same seeds, same nonce, same result: the second bettor is playing a round whose outcome already exists. This is the usual way it happens.

race.mjs
let stored = 0;
const readNonce = async () => stored;
const writeNonce = async (value) => { stored = value; };

async function reserve() {
  const nonce = await readNonce();
  await writeNonce(nonce + 1);
  return nonce;
}

console.log(await Promise.all([reserve(), reserve()]));
Output
[ 0, 0 ]

Both calls read before either writes. The two async functions stand in for a database round trip, and against a real database the window is milliseconds wide instead of one tick, which makes it rarer and harder to find. The reservation has to be a single atomic step in whatever holds the counter.

Galabet's demo keeps the nonce in its own Redis key, apart from the rest of the session, and reserves it inside a Lua script, which Redis runs as one step. The script reads the counter, refuses if it or the session is missing or the session's commitment has changed, increments it and returns the value it read, so the first bet gets nonce 0. It also renews the session and counter expiry together. The following fragment needs a running Redis and is not executed by the docs checker.

reserve.lua (simplified)
local n = tonumber(redis.call('GET', KEYS[2]))
if not n or n < 0 then return -1 end
redis.call('INCR', KEYS[2])
return n

A -1 becomes a 409 in the API. There is no fallback to 0: nonce 0 is the one value certain to have been used already.

A nonce reserved for a bet that then fails is spent. Leave the gap. Each record stands alone, so a history of 0, 1, 3 verifies fine. The demo behaves this way: it reserves before it calls play, and if play throws, that nonce is gone.

The rest of the storage side is on Storing seeds and reserving nonces: the Postgres form of the same step, a store that runs without Redis, and the session lock the demo puts in front of the counter. The other ways one nonce ends up on two bets, a retried request or a restored backup among them, are collected under Nonce reuse and replay.