DocsSecurity
Nonce Reuse and Replay
What a player gains when a server seed, client seed and nonce are used for a second bet, the five ways that happens, and what Galabet's demo API does about each.
play has no memory. Hand it the same server seed, client seed and nonce twice and it returns the same result twice. Verification depends on that. It's also why a nonce may be spent once only: the second time, somebody has already seen the answer, and that somebody is usually the player.
Nothing in the library stops a reuse, and nothing in a record shows that one happened. Both records verify. If you're a player who got the same roll twice in a row, this page is the likely explanation, and the fault is in the casino's counter, not in the hashing.
Reuse also doesn't have to be exact to be useful. Every game reads the same stream of floats for a given triple, so a nonce shared between two different games, or between two settings of one game, leaks part of one result into the other. Two of the sections below run that.
Two Requests at Once
A Dice bet and a Mines round, fired together at a backend that reads the nonce, uses it and writes it back.
import { play } from '@galabet/fair';
const seeds = {
serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
clientSeed: 'galabet',
};
const row = { nonce: 0 };
const db = {
read: async () => ({ ...row }),
write: async (next) => void Object.assign(row, next),
};
async function takeNonce() {
const { nonce } = await db.read();
await db.write({ nonce: nonce + 1 });
return nonce;
}
// Dice: the roll goes straight back to the player.
async function diceBet() {
const nonce = await takeNonce();
const { result } = await play({ game: 'dice', ...seeds, nonce });
return { nonce, roll: result };
}
// Mines with 24 mines: the board stays on the server until the round ends.
let hiddenBoard;
async function minesStart() {
const nonce = await takeNonce();
hiddenBoard = (await play({ game: 'mines', params: { mines: 24 }, ...seeds, nonce })).result;
return { nonce, board: 'hidden' };
}
const [dice, mines] = await Promise.all([diceBet(), minesStart()]);
console.log(dice, mines);
// The player's side. One line of arithmetic on the roll they were sent.
const pick = Math.floor((Math.round(dice.roll * 100) / 10001) * 25);
console.log('player picks tile', pick);
const safe = [...Array(25).keys()].filter((tile) => !hiddenBoard.includes(tile));
console.log('safe tiles on the hidden board:', safe);
{ nonce: 0, roll: 53.14 } { nonce: 0, board: 'hidden' }
player picks tile 13
safe tiles on the hidden board: [ 13 ]
Both handlers read the counter before either wrote it, so both got nonce 0. The player never saw the board. They didn't need to. Dice is made from the first float of the stream, and the Mines shuffle spends that same float on its first swap, which moves tile floor(float × 25) into the last position of the shuffled list. Mines are taken from the front of that list, so the last position is never a mine, whatever the mine count. With 24 mines it is the only tile that isn't.
A roll is the float to four digits, which is nearly always enough to name the tile.
import { play } from '@galabet/fair';
const seeds = {
serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
clientSeed: 'galabet',
};
const tally = { 'named the safe tile': 0, 'two candidates': 0, 'named a mine': 0 };
for (let nonce = 0; nonce < 10000; nonce++) {
const roll = (await play({ game: 'dice', ...seeds, nonce })).result;
const board = (await play({ game: 'mines', params: { mines: 24 }, ...seeds, nonce })).result;
// Every float that floors to this roll lies in [h / 10001, (h + 1) / 10001).
const h = Math.round(roll * 100);
const low = Math.floor((h * 25) / 10001);
const high = Math.floor(((h + 1) * 25 - 1e-6) / 10001);
if (low !== high) tally['two candidates']++;
else if (board.includes(low)) tally['named a mine']++;
else tally['named the safe tile']++;
}
console.log(tally);
{
'named the safe tile': 9969,
'two candidates': 31,
'named a mine': 0
}
The demo API closes this in two layers. The nonce is a Redis key of its own and is advanced with INCR, one operation that reads and writes together, so two requests can't be handed the same value however they interleave. On top of that every action on a session takes a lock (withDemoSession in apps/api/src/demo/session-lock.ts), and a request that finds it held is refused with 409 and another action is being processed; retry after it completes. The lock is a Redis key with a 30 second expiry, so it can lapse under a stalled request, and the counter is the part that still holds when it does. Storing Seeds and Reserving Nonces has the store code and the same race on Dice alone.
A Retry That Runs the Handler Again
A client times out and sends the bet again. A queue redelivers a job. A framework replays a request after a worker dies. What happens to the nonce depends on the order of three steps inside the handler: reserve the nonce, derive the result, save the record.
Reserve first and a re-run is harmless to fairness. The second run takes a new nonce, and the first nonce is burned. That leaves a hole in the player's sequence, which costs nothing, since every record verifies on its own and no check expects consecutive nonces. This is the demo's order: deriveRecord calls reserveNonce before it calls play, and the counter has already moved by the time anything can fail.
Derive first and advance the counter afterwards, in the same transaction as the record, and a failure between the two rolls the counter back while the result may already have escaped, in a response that was sent, a websocket push, a log. The retry then derives the same result for a player who might have seen it, and if the retry is allowed to carry different parameters, a Dice player switches from over to under.
The service itself doesn't deduplicate. The controllers put an Idempotency-Key interceptor in front of it, described under Retried Requests, and a retry sent without a key is a second bet at a new nonce. Malformed supplied keys are rejected. On the staked game routes that is also a second stake: a money problem more than a fairness one. Past the interceptor there are two narrower guards. publishRecord skips a record whose commitment, client seed and nonce are already in the history list. And a Mines cash-out is credited through a marker built from that same triple, mines:<commitment>:<clientSeed>:<nonce>, so a retry after a dropped connection can't pay the round twice.
A Restored Backup
The seed survives and the counter goes backwards. Say the snapshot was taken when a session stood at nonce 50 and the failure came at nonce 80. After the restore the server issues 50 again. The player has records for 50 to 79 in their history, knows all thirty results, and is about to be dealt them a second time.
The same thing happens without a disaster wherever the seed and its counter can fall out of step: a counter cached in process memory, a replica that lags the primary, a counter key that expires separately from the session. The current API refreshes seed/counter expiry together and rejects a missing counter, as the storage page describes. A restored or rolled-back snapshot still requires operational recovery; these guards do not prove that an old counter value is fresh.
The defence is cheap and complete. After any restore, rotate every live seed before taking a bet. Under a new server seed the old counter's value stops mattering, since nonce 50 under a key nobody has used is a fresh result. Players lose nothing, and their old records become verifiable early. A round that is still hiding its result, an open Mines board for instance, has to be settled or voided first, for the reason given under Conditions Before a Reveal.
Returning to an Earlier Client Seed
If a client seed change resets the nonce and keeps the server seed, a player can play ten bets under galabet, switch to anything else, switch back, and meet the same ten results in the same order. The client seeds page runs it.
In the demo a change of client seed always retires the server seed. setClientSeed creates a new seed and commitment, resets the counter, and files the outgoing seed as revealed if any bet was placed under it. Sending the client seed the session already has is a no-op that leaves the sequence where it was. The alternative design, one counter per server seed that never resets across client seeds, also works. Resetting the counter while keeping the key is the combination that fails.
A Parameter Change on the Same Nonce
import { play } from '@galabet/fair';
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const at = async (game, params, nonce) =>
(await play({ game, params, serverSeed, clientSeed: 'galabet', nonce })).result;
console.log(await at('mines', { mines: 1 }, 42), await at('mines', { mines: 3 }, 42));
console.log((await at('plinko', { rows: 8 }, 42)).path.join(''));
console.log((await at('plinko', { rows: 16 }, 42)).path.join(''));
let minesNested = 0;
let plinkoPrefixed = 0;
for (let nonce = 0; nonce < 1000; nonce++) {
const [mine] = await at('mines', { mines: 1 }, nonce);
if ((await at('mines', { mines: 3 }, nonce)).includes(mine)) minesNested++;
const short = (await at('plinko', { rows: 8 }, nonce)).path.join('');
const long = (await at('plinko', { rows: 16 }, nonce)).path.join('');
if (long.startsWith(short)) plinkoPrefixed++;
}
console.log(`1-mine board inside the 3-mine board: ${minesNested} of 1000`);
console.log(`8-row path opens the 16-row path: ${plinkoPrefixed} of 1000`);
[ 22 ] [ 9, 17, 22 ]
10010111
1001011100111000
1-mine board inside the 3-mine board: 1000 of 1000
8-row path opens the 16-row path: 1000 of 1000
A parameter doesn't enter the HMAC. The message is clientSeed:nonce:cursor and nothing else, so mines and rows only decide how much of a fixed stream gets read. The Mines shuffle in packages/fair/src/games/shuffle.ts is the same 24 swaps every time, and mines is where the slice stops. It follows that a 1-mine board is always contained in the 3-mine board, and the 3-mine board in the 24-mine one.
So "the player changed the settings" is not a reason to treat a nonce as fresh. A backend that derives a preview when the player opens the game and derives again when they've adjusted the mine count and pressed start, both under one reserved nonce, has shown them a mine. The demo reserves the nonce inside the same call that fixes the parameters into the record, offers no way to change mines on an existing board, and refuses a new board while one is active with finish the current game first.
Finding Reuse in Stored Records
The server seed isn't in a record until it's revealed, but the commitment stands in for it. Two records that share commitment, clientSeed and nonce are a reuse, whatever their games or parameters. For an auditor with a record export that's one GROUP BY with a count above 1. For an operator it's a unique index on those three columns, which turns a silent reuse into a failed insert at the moment it happens.
The demo's publishRecord compares the same three fields, but it uses them to drop a duplicate quietly. That keeps the history list clean, and it would also hide a second derivation under the same nonce. Fail loudly instead.
