DocsBuilding a backend

Storing Seeds and Reserving Nonces

What a backend has to keep for each player session, why the nonce counter gets a key of its own, and what disappears when seeds live only in memory.

A provably fair bet can be checked later only if the operator still has the server seed when the time comes to reveal it. Lose the seed and every bet placed under it stays unverifiable for good. Nothing in the cryptography helps with that. It's a storage problem, and so is the other thing this page covers: making sure two bets never get the same nonce.

@galabet/fair stores nothing. play takes seeds and a nonce and forgets them. Everything below is what Galabet's demo API keeps in Redis around that call, in apps/api/src/demo/seed-store.ts.

What Is Stored per Session

FieldSecretPurpose
serverSeedUntil rotationThe HMAC key for every bet under this seed
commitmentNoSHA-256 of the seed string. Shown before the first bet, copied into every record
clientSeedNoThe player's part of the HMAC message
betsUnderSeedNoWhether a rotation would reveal anything
createdAtNoWhen the session began, Unix milliseconds
NonceNoThe next unused nonce. Not a field. It has its own key

The first five are one JSON value under demo:session:<id>. When the API shows a session to the browser it removes serverSeed from that object and adds the current nonce, read from the counter without consuming one.

betsUnderSeed looks like bookkeeping and does a real job. A seed nobody has bet under has no records to check, so the demo refuses to rotate it, with nothing to reveal: no bets under this seed yet. And when a player changes their client seed, the outgoing server seed is filed as revealed only if this count is above zero.

createdAt is weaker than it sounds. Rotation copies it forward, so it's the age of the session and says nothing about when the current commitment was first shown. If you ever need to argue that a commitment was published before a bet, store a time for each commitment. The demo doesn't.

Two Bets, One Nonce

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

// Behaves like a database: reads and writes are async, and a read hands back a copy.
const rows = new Map();
const store = {
  get: async (id) => structuredClone(rows.get(id)),
  put: async (session) => void rows.set(session.id, structuredClone(session)),
};

await store.put({
  id: 'ana',
  serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
  clientSeed: 'galabet',
  nonce: 0,
});

async function bet(id) {
  const session = await store.get(id);
  const { serverSeed, clientSeed, nonce } = session;                  // read
  const { result } = await play({ game: 'dice', serverSeed, clientSeed, nonce });
  await store.put({ ...session, nonce: nonce + 1 });                  // write back
  return { nonce, result };
}

console.log(await Promise.all([bet('ana'), bet('ana')]));
console.log('stored nonce:', (await store.get('ana')).nonce);
Output
[ { nonce: 0, result: 53.14 }, { nonce: 0, result: 53.14 } ]
stored nonce: 1

Both requests read the session before either wrote it back. They got the same nonce and therefore the same roll, and the counter ended at 1 after two bets. No error, no warning. The only trace is two records that are identical except for their timestamps.

A double roll is the mild version. Every game reads the same float stream, so a Dice bet and a Mines board that share a nonce share their first float. The Dice record goes to the player at once and pins that float down to one part in 10,001, and that float decides the first swap of the Mines shuffle. A result the server meant to keep hidden has started leaking through a different game.

Nor does the fault need two servers. The example is a single process, and it's the await between the read and the write that leaves the door open. More workers only make it happen more often.

Reserving a Nonce Atomically

The counter has to be advanced by one operation that reads and writes together. That's why the demo keeps it out of the session JSON. A JSON blob read by your code, changed and written back is the pattern that failed above. A bare integer key can be checked and raised with INCR inside one Lua script, which Redis runs to the end before it serves anyone else.

seed-store.ts (inside RedisSeedStore)
async reserveNonce(id: string, ttl: number, expectedCommitment: string) {
  const value = Number(
    await this.run(
      `
    if redis.call('EXISTS', KEYS[1]) == 0 or redis.call('EXISTS', KEYS[2]) == 0 then return -1 end
    if cjson.decode(redis.call('GET', KEYS[1])).commitment ~= ARGV[2] then return -1 end
    local n = tonumber(redis.call('GET', KEYS[2]))
    if not n or n < 0 or n >= 9007199254740991 then return -1 end
    redis.call('INCR', KEYS[2])
    touch(ARGV[1])
    return n`,
      id,
      ttl,
      expectedCommitment,
    ),
  );
  if (value < 0)
    throw new ConflictException(
      "session nonce unavailable; start a new demo session",
    );
  return value;
}

The script returns the counter as it was and leaves it one higher. It answers -1, which the method turns into a 409, when the session or its counter is missing, when the session holds a different commitment from the one the caller loaded, or when the counter doesn't read as a sane number. Nothing ever starts a missing counter again at 0.

run passes the session's keys and puts touch in front of the script, a loop that gives the session, the counter, the revealed seeds, the records and an open Mines board the same TTL. Rotation doesn't reset the counter as a separate step either: put writes the new seed and a counter of 0 in one MSET. The Redis recipe prints the rest of the class and goes through each script.

Below is the same idea with a Map, so that it runs anywhere. It leaves out the TTLs and the commitment check on put. reserveNonce has no await between its read and its write, and inside one Node process that is enough to make it indivisible.

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

class MemorySeedStore {
  sessions = new Map();
  nonces = new Map();
  revealedSeeds = new Map();

  async get(id) { return structuredClone(this.sessions.get(id) ?? null); }
  async put(session) {
    const old = this.sessions.get(session.id);
    if (!old || old.commitment !== session.commitment) this.nonces.set(session.id, 0); // a new seed starts at nonce 0
    this.sessions.set(session.id, structuredClone(session));
  }
  async reserveNonce(id, expectedCommitment) {
    const nonce = this.nonces.get(id);
    if (nonce === undefined || this.sessions.get(id)?.commitment !== expectedCommitment) {
      throw new Error('session nonce unavailable; start a new demo session');
    }
    this.nonces.set(id, nonce + 1);
    return nonce;
  }
  async peekNonce(id) { return this.nonces.get(id); }
  async reveal(id, commitment, serverSeed) {
    this.revealedSeeds.set(id, { ...this.revealedSeeds.get(id), [commitment]: serverSeed });
  }
  async revealed(id) { return this.revealedSeeds.get(id) ?? {}; }
}

const store = new MemorySeedStore();

async function freshSeed() {
  const serverSeed = await createServerSeed();
  return { serverSeed, commitment: (await commit(serverSeed)).commitment };
}

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

async function rotate(id) {
  const session = await store.get(id);
  await store.reveal(id, session.commitment, session.serverSeed);
  await store.put({ ...session, ...(await freshSeed()) }); // the new commitment sets the counter to 0
}

await store.put({ id: 'ana', clientSeed: 'galabet', ...(await freshSeed()) });

const records = await Promise.all(Array.from({ length: 50 }, () => bet('ana')));
console.log('bets:', records.length, 'different nonces:', new Set(records.map((r) => r.nonce)).size);
console.log('next nonce:', await store.peekNonce('ana'));

await rotate('ana');
console.log('next nonce after rotation:', await store.peekNonce('ana'));

const seeds = await store.revealed('ana');
let verified = 0;
for (const record of records) {
  const { ok } = await verifyRecord({ ...record, serverSeed: seeds[record.commitment] });
  if (ok) verified++;
}
console.log('verified with seeds found by commitment:', verified);
Output
bets: 50 different nonces: 50
next nonce: 50
next nonce after rotation: 0
verified with seeds found by commitment: 50

The demo has a second guard on top of this one. Every action on a session runs behind a Redis lock taken with SET NX, and a request that finds the lock held is refused with 409 and another action is being processed; retry after it completes. That lock carries a 30 second expiry so that a crashed worker can't hold a session forever, which also means it can lapse under a slow request. The atomic counter still holds when it does, and the commitment check turns away a bet whose seed was rotated while it waited. Concealed games need the lock for other reasons.

The SeedStore Interface

This is the shape the demo codes against. RedisSeedStore implements it. The API's state tests run the same services against an in-memory stand-in for the Redis client, which is a fair sign that the boundary is in the right place, and apps/api/test/demo-redis.test.ts runs the real class against a Redis when REDIS_TEST_URL is set.

seed-store.ts
export interface DemoSession {
  id: string;
  serverSeed: string;
  commitment: string;
  clientSeed: string;
  /** Bets placed under the current server seed. Decides if rotation reveals anything. */
  betsUnderSeed: number;
  createdAt: number;
}

export interface SeedStore {
  get(id: string, ttlSeconds?: number): Promise<DemoSession | null>;
  put(
    session: DemoSession,
    ttlSeconds: number,
    expectedCommitment?: string,
  ): Promise<void>;
  /** Reserve the next nonce. Returns the nonce this bet must use. */
  reserveNonce(
    id: string,
    ttlSeconds: number,
    expectedCommitment: string,
  ): Promise<number>;
  /** Current nonce without consuming one. */
  peekNonce(id: string): Promise<number>;
  /** Remember a revealed server seed under its commitment, for the verify page. Expires with the session. */
  reveal(
    id: string,
    commitment: string,
    serverSeed: string,
    ttlSeconds: number,
  ): Promise<void>;
  revealed(id: string): Promise<Record<string, string>>;
  pushRecord(
    id: string,
    record: FairRecord,
    keep: number,
    ttlSeconds: number,
  ): Promise<void>;
  records(id: string, limit: number): Promise<FairRecord[]>;
  /** Per-IP session creation counter, expires at end of window. */
  countSession(ip: string, windowSeconds: number): Promise<number>;
}

There is no method that resets the nonce. put does it: when the session it is given carries a new commitment, the counter is written as 0 in the same step, and when the commitment is unchanged the counter is kept. expectedCommitment makes put a compare-and-set. It names the commitment the caller loaded, or nothing for a brand new session, and a session that has changed or expired in the meantime is refused with a 409 rather than overwritten. reserveNonce checks the same thing before it spends a nonce.

get takes a TTL as well, and the demo passes one every time it loads a session, so looking at a session keeps it alive as a bet does. It returns the seed, so nothing that calls it may sit on a path that serialises its return value to a client. In the demo's service one function, publicView, drops the seed and adds the nonce, and every session that goes back to a client passes through it.

pushRecord takes a keep argument because the demo holds only the latest 100 records per session, with LPUSH followed by LTRIM in one script. That's a demo's budget. An operator keeps every record for as long as a bet can be disputed, and in a database, not a capped list.

countSession has nothing to do with fairness. It caps new sessions at 30 per IP address per day by default, because each session costs a seed and a starting stack of chips.

Revealed Seeds Are Kept by Commitment

On rotation the old seed goes into a Redis hash, demo:revealed:<id>, with the commitment as the field and the seed as the value. Records never store a seed. When the player asks for their history, the API reads the records and the hash together and attaches serverSeed to each record whose commitment is in the hash.

Keying by commitment is what lets that work however many rotations ago the bet was placed. A record already names its commitment, so the lookup needs nothing else, and since the commitment is the hash of the seed the pairing can be re-checked by anyone. reserve.mjs above does the same lookup: fifty records, one revealed seed, found through record.commitment.

Expiry

A session's seed, counter, revealed seeds, records and open Mines board share one TTL, DEMO_SESSION_TTL, which defaults to 86,400 seconds. The store sets it on all of them in the same script whenever it writes any of them, and whenever the service loads the session. A session that keeps betting, or is only looked at, stays alive. One left alone for a day is deleted with everything in it, and after that the API answers 404 with demo session not found or expired.

The chip balance and the credit markers keep clocks of their own. So do the action lock, the per-address counter and the idempotency records, which are idem: keys kept for 24 hours. The Redis key layout lists the demo: keys with their expiries.

That deletion destroys an unrevealed seed. For practice chips it's acceptable. For stakes it isn't: reveal first, then expire, and never let a TTL be the thing that ends a seed's life.

An earlier version of the store renewed each key only when that key was written. The counter was renewed a few milliseconds before the session on every bet, so a day later it expired first, and the next bet would have started again at nonce 0 under a seed the player had already seen results from. The revealed seeds were renewed only by a reveal, and could expire while the session that owned them was still in use. Both are closed. The keys share one expiry, and a missing counter is refused with a 409 instead of being re-created at zero. Every Session Key Shares One Expiry has a model with a clock.

None of this makes a rolled-back Redis snapshot safe, because the seed and its counter come back together and both are old. After restoring state, resolve or void concealed rounds and rotate live seeds before accepting new play.

The project's Redis runs with --appendonly yes and --maxmemory-policy noeviction. We turned eviction off because BullMQ complained about it at startup, but the setting matters as much for seeds. Under an evicting policy a full Redis quietly drops keys, and one of them could be a live seed. With noeviction a full Redis refuses the write and the bet fails, which is the better failure.

Seeds Held Only in Memory

The server in Your first verified round keeps its seed in a variable. Restart it and here is what goes:

LostConsequence
The live server seedEvery record under it is unverifiable for good. The commitment is still on those records and nothing can ever be shown to match it
The nonce counterIf the seed had survived and the counter hadn't, bets would restart at 0 under the same seed, and the player would already know those results
Revealed seedsOld records lose their seed unless the player saved it at rotation
RecordsWhatever wasn't sent to the player

The second row is the dangerous one, because it can happen without a restart. A cache in front of the database, a replica that lags, a counter held in process memory while the seed sits in Postgres: each of them can serve a stale nonce. Keep the seed and its counter in the same store, and have the store do the increment.