DocsCore concepts

Rotation and Reveal

Retiring a server seed publishes it, and that is the moment every bet made under it becomes checkable.

Until a server seed is rotated, nothing played under it can be verified by anyone. The record says which commitment the bet belongs to, and that's all it can say. Rotation is the operator giving up the seed: it's published, it's never used again, and a new one takes over with its nonce back at 0.

So for a player the practical rule is short. Want to check your bets? Rotate first. In Galabet's demo API that's a single request, and it answers with the old seed. The practice pages on the site skip the wait entirely, because their seed is public from the start.

The library has no rotate function. Your backend does it: file the old seed under its commitment, make the next one with createServerSeed, commit to it, start the nonce again at 0. The order of those steps, and what has to be true before the first of them, is what this page is about.

Conditions Before a Reveal

Three things have to hold at the moment the old seed goes public.

The old seed is finished. No bet can be derived from it after the reveal, because anyone holding the seed, the client seed and the next nonce can compute that bet's result before placing it. In a backend with more than one worker this means the swap to the new seed and the reveal happen under the same lock as betting does. The demo API takes a per-session lock (withDemoSession, a Redis key with a 30 second expiry) around rotate, around every bet and around every Mines action, so none of them can interleave.

No round is still hiding its result. A Mines board is the clearest case: the mine positions are fully determined by the server seed, the client seed and the nonce, and the last two are public. Reveal the seed mid-round and the player can compute the board they're standing on. The demo refuses:

Response from POST /api/demo/rotate while a board is active
400  finish the active Mines board before changing or revealing seeds

The same check guards a client seed change, because in the demo a new client seed also retires the server seed.

The next commitment reaches the player before the first bet under the next seed. That's the ordinary commitment rule applied to seed number two. The demo creates the new seed inside rotate and returns its commitment in the same response as the reveal, under next, so there's no bet-sized gap between them. A stricter arrangement creates the next seed early and shows its commitment beside the current one for the whole life of the seed, which the demo does not do; Publishing Commitments explains what that buys. commit is the same call either way. What matters is that the player could have saved the hash before betting against it.

What Resets and What Does Not

After rotation
Server seedNew, from createServerSeed
CommitmentNew, from commit
NonceBack to 0
Count of bets under the seedBack to 0
Client seedUnchanged
Old recordsUnchanged. They keep the old commitment, which is how they find their seed later

The nonce can restart because the HMAC key changed. galabet:0:0 under seed A and galabet:0:0 under seed B are unrelated digests.

Rotation with No Bets

The demo won't rotate a seed nobody has bet under.

Response from POST /api/demo/rotate on a fresh seed
400  nothing to reveal: no bets under this seed yet

It keeps a counter, betsUnderSeed, in the session for this purpose. The one place an unused seed does get replaced is a client seed change with zero bets, and there the old seed is discarded without being published. A commitment that was never bet against has nothing depending on it.

Keeping Revealed Seeds

A record made under seed A still has to verify after seeds B, C and D have come and gone. The record itself never stored seed A, only its commitment. So the server needs a lookup from commitment to revealed seed, and it needs to keep it.

The demo's is a Redis hash per session. rotate writes commitment → serverSeed into it, and the history endpoint attaches serverSeed to every record whose commitment appears there, "however many rotations ago" in the words of the source comment.

Here is the same arrangement in memory. It rotates twice and then verifies the very first record.

rotate-twice.mjs
import { commit, createServerSeed, play, verifyRecord } from '@galabet/fair';

const session = { clientSeed: 'galabet', revealed: new Map() };

async function startSeed() {
  session.serverSeed = await createServerSeed();
  session.commitment = (await commit(session.serverSeed)).commitment;
  session.nonce = 0;
  session.betsUnderSeed = 0;
}

async function bet(game, params = {}) {
  const nonce = session.nonce++;
  const { result, cursor } = await play({ game, params, serverSeed: session.serverSeed, clientSeed: session.clientSeed, nonce });
  session.betsUnderSeed++;
  return { spec: 'GFS/1.0', profile: 'single-player', game, params, commitment: session.commitment, clientSeed: session.clientSeed, nonce, cursor, result, at: Date.now() };
}

async function rotate() {
  if (session.betsUnderSeed === 0) throw new Error('nothing to reveal: no bets under this seed yet');
  const revealed = { commitment: session.commitment, serverSeed: session.serverSeed, bets: session.betsUnderSeed };
  session.revealed.set(revealed.commitment, revealed.serverSeed);
  await startSeed();
  return { revealed, next: session.commitment };
}

const withSeed = (record) => ({ ...record, serverSeed: session.revealed.get(record.commitment) });

await startSeed();
await rotate().catch((e) => console.log('refused:', e.message));

const first = await bet('dice');
await bet('dice');
await bet('mines', { mines: 3 });
console.log('before rotation:', (await verifyRecord(withSeed(first))).reasons);

const one = await rotate();
console.log('rotation 1 revealed', one.revealed.bets, 'bets, nonce is now', session.nonce);
console.log('next commitment differs:', one.next !== one.revealed.commitment);

const later = await bet('dice');
console.log('first bet under the new seed has nonce', later.nonce);

const two = await rotate();
console.log('rotation 2 revealed', two.revealed.bets, 'bet, seeds kept:', session.revealed.size);

console.log('first record, two rotations later:', (await verifyRecord(withSeed(first))).ok);
console.log('first record with the wrong seed:', (await verifyRecord({ ...first, serverSeed: two.revealed.serverSeed })).reasons);
Output
refused: nothing to reveal: no bets under this seed yet
before rotation: [ 'server seed not revealed yet; verify after rotation' ]
rotation 1 revealed 3 bets, nonce is now 0
next commitment differs: true
first bet under the new seed has nonce 0
rotation 2 revealed 1 bet, seeds kept: 2
first record, two rotations later: true
first record with the wrong seed: [
  'server seed does not match commitment',
  'result does not match seeds'
]

The last line is why the map is keyed by commitment and not by "most recent". Hand a record the wrong revealed seed and it fails on the commitment check before any game arithmetic runs.

How long is "keep"? In the demo, as long as the session. Every Redis key belonging to a session, the revealed seeds included, carries the same expiry, 24 hours by default (DEMO_SESSION_TTL), and each bet, reveal or session read renews all of them together. The record list holds the last 100 records. So a session that is still playing keeps its revealed seeds, and one left alone for a day loses everything at once (Expiry has the details). That's acceptable for play chips. For an operator it isn't. A dispute can arrive months later, and a seed you can't produce makes every record under it uncheckable for good. There's no way to recompute a seed from its commitment; that's the property the scheme rests on.

Effect on Hashes and Signatures

Adding serverSeed to a record changes its canonical form, so recordHash gives a different value before and after reveal, and an Ed25519 signature made at bet time stops verifying against the revealed record. Neither is a fault. Both need handling, and The Reveal Problem shows the failing case and the two ways round it.

How Often to Rotate

GFS 1.0 sets no interval. The library has no timer, and the demo rotates only when the player asks or changes their client seed.

Two costs grow with the age of a seed. The player waits longer before they can check anything, since not one bet under a live seed is verifiable. And the operator holds a longer run of results it can calculate in advance. Against that, every rotation is a write to permanent storage and one more commitment the player ought to save.

Letting the player trigger rotation settles most of it, because the person who wants to verify decides when. If you also rotate on a schedule or after a bet count, publish the rule. Whatever you choose, a seed that's never rotated is a commitment that's never opened, and a record under it proves nothing.