DocsBuilding a backend
Testing Your Integration
Tests that show a provably fair backend is wired correctly, from known answers and vectors to concurrency and distribution checks, and what this project's own API tests leave unproven.
The library has its own tests and the vectors pin its arithmetic. Neither says anything about the code you wrote around it: which nonce a bet gets, what a response gives away, whether the seed a player receives at rotation is the one their bets were made with. Those are the places an integration goes wrong, and each can be tested without a casino attached.
A player can't see an operator's test suite and shouldn't be asked to trust one. The player's check is the verifier. This page is for the people whose job is to make sure that check passes.
| Claim | Test | What it can't tell you |
|---|---|---|
| The derivation is the published one | Known answers, then the vectors | Anything about seeds you generate yourself |
| A bet can be verified once its seed is out | Bet, rotate, verifyRecord | Whether the commitment was shown before the bet |
| A live round gives nothing away | Read the bytes of the response | Leaks through another route you didn't test |
| No nonce is used twice | A burst of bets, then count | Much, if it ran against a stand-in store |
| Payouts match the published odds | A histogram, then a tolerance | Rare outcomes, at any sample size you can afford |
Known Answers from Public Seeds
import assert from 'node:assert/strict';
import { createHmac } from 'node:crypto';
import { commit, deriveDigest, play, toHex } from '@galabet/fair';
const seeds = {
serverSeed: '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d',
clientSeed: 'galabet',
nonce: 42,
};
assert.equal((await play({ game: 'dice', ...seeds })).result, 56.12);
assert.equal((await play({ game: 'roulette', ...seeds })).result, 20);
assert.equal((await play({ game: 'limbo', params: { houseEdge: 0.01 }, ...seeds })).result, 1.76);
assert.equal((await commit(seeds.serverSeed)).commitment, 'ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7');
// An oracle that shares no code with what it checks.
const expected = createHmac('sha256', seeds.serverSeed).update('galabet:42:0').digest('hex');
assert.equal(toHex(await deriveDigest(seeds, 0)), expected);
console.log('5 assertions held');
5 assertions held
A unit test for anything seeded needs fixed seeds, and they might as well be the ones printed on every page of these docs. Anyone can look 56.12 up. A fresh random seed in a test gives you a result nobody can compare with anything.
The last assertion is the kind to copy. The library's own core.test.ts checks its digest against node:crypto and its commitment against a plain SHA-256, so the expected value never comes out of the code being tested. The project's own API tests break that rule, as the last section shows.
Point these assertions at your own entry point, the function your bet handler calls, and not at play. If that function maps your product's bet types onto a game name and params, the mapping is what's under test.
Vectors in CI
import { readFile } from 'node:fs/promises';
import { isDeepStrictEqual } from 'node:util';
import { play } from '@galabet/fair';
// Replace with the function your server calls to turn a bet into an outcome.
const deriveOutcome = ({ game, params, serverSeed, clientSeed, nonce }) => play({ game, params, serverSeed, clientSeed, nonce });
const { games } = JSON.parse(await readFile('vectors/gfs-1.0.json', 'utf8'));
const failed = [];
const perGame = {};
for (const vector of games) {
const { result, cursor } = await deriveOutcome(vector);
const ok = isDeepStrictEqual(result, vector.result) && cursor === vector.cursor;
perGame[vector.game] = (perGame[vector.game] ?? 0) + 1;
if (!ok) failed.push(vector);
}
for (const vector of failed.slice(0, 5)) console.error(`mismatch: ${vector.game} ${vector.clientSeed} nonce ${vector.nonce}`);
console.log(Object.entries(perGame).map(([game, count]) => `${game} ${count}`).join(', '));
console.log(`${games.length - failed.length} of ${games.length} vectors match`);
if (failed.length > 0) process.exitCode = 1;
dice 112, limbo 336, roulette 112, wheel 336, plinko 336, mines 336, keno 336, blackjack 224, hilo 112
2240 of 2240 vectors match
The exit code is what CI reads. One mismatch makes it 1 and the job goes red. The file compares cursor as well as result, because a port can land on the right result from the wrong place in the stream and then disagree with every record's cursor field.
If your team runs node --test, the same check is a few lines longer:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { play } from '@galabet/fair';
const { games } = JSON.parse(await readFile('vectors/gfs-1.0.json', 'utf8'));
test(`all ${games.length} GFS 1.0 vectors`, async () => {
for (const vector of games) {
const { result, cursor } = await play(vector);
assert.deepEqual({ result, cursor }, { result: vector.result, cursor: vector.cursor }, `${vector.game} nonce ${vector.nonce}`);
}
});
That one isn't executed by this site's example checker, for a dull reason: the test runner prints durations, and every Output block here has to be identical on every run. We ran it by hand three times. It passed each time, in between 2 and 3 seconds for all 2,240 rounds.
Be clear about what you've bought. Calling play straight from your handler and running the vectors through play repeats a check the library's authors already make, and proves only that the package you installed is the package they published. That has some value after a dependency bump. The vectors earn their keep when there is code of yours in the path: a wrapper, a port to another language, a build step that bundles the library for a runtime it wasn't tested on. Test vectors lists what the files leave out.
This project's workflow file, .github/workflows/ci.yml, does it in two steps. It runs the library's tests, then pnpm vectors:check, which regenerates all three vector files from the source and compares them with the committed ones. On a clean tree it prints vectors match. On a difference it exits 1 with vectors drift: regenerate with pnpm vectors:gen and review the diff. A change to a mapper that alters any outcome fails the build until someone regenerates the vectors on purpose, and that someone then has a diff to explain.
Bet, Rotate, Verify
Known answers test the arithmetic. This tests the plumbing: that the seed you reveal is the seed you used, that the commitment on the record is the one the player was shown, and that the nonce you stored is the nonce you derived with.
import assert from 'node:assert/strict';
import { commit, createServerSeed, play, verifyCommitment, verifyRecord } from '@galabet/fair';
// A stand-in for your service. In your suite, import the real one and hand it an in-memory store.
function createService() {
let session;
const records = [], revealed = {};
const fresh = async () => {
const serverSeed = await createServerSeed();
return { serverSeed, commitment: (await commit(serverSeed)).commitment, nonce: 0 };
};
return {
async open(clientSeed) {
session = { clientSeed, ...(await fresh()) };
return { commitment: session.commitment };
},
async bet(game, params = {}) {
const { serverSeed, commitment, clientSeed } = session;
const nonce = session.nonce++;
const { result, cursor } = await play({ game, params, serverSeed, clientSeed, nonce });
const record = { spec: 'GFS/1.0', profile: 'single-player', game, params, commitment, clientSeed, nonce, cursor, result, at: Date.now() };
records.push(record);
return record;
},
async rotate() {
const retired = { serverSeed: session.serverSeed, commitment: session.commitment };
revealed[retired.commitment] = retired.serverSeed;
Object.assign(session, await fresh());
return retired;
},
history: () => records.map((r) => (revealed[r.commitment] ? { ...r, serverSeed: revealed[r.commitment] } : r)),
};
}
const service = createService();
const shown = await service.open('galabet');
for (const game of ['dice', 'roulette', 'mines']) await service.bet(game, game === 'mines' ? { mines: 5 } : {});
// Before rotation: no seed anywhere, and verification says why it can't run.
const early = await Promise.all(service.history().map((r) => verifyRecord(r)));
assert.ok(service.history().every((r) => !('serverSeed' in r) && r.commitment === shown.commitment));
console.log('before rotation:', early.map((v) => v.ok).join(' '), '|', early[0].reasons[0]);
// After rotation: every record verifies, and the seed fits the commitment shown before the first bet.
const retired = await service.rotate();
assert.equal(await verifyCommitment(retired.serverSeed, shown.commitment), true);
const late = await Promise.all(service.history().map((r) => verifyRecord(r)));
assert.ok(late.every((v) => v.ok));
console.log('after rotation:', late.map((v) => v.ok).join(' '));
// Negative controls. A suite that never sees a failure hasn't shown it can.
const [first] = service.history();
const tampered = await verifyRecord({ ...first, result: first.result === 1 ? 2 : 1 });
const wrongSeed = await verifyRecord({ ...first, serverSeed: await createServerSeed() });
assert.equal(tampered.ok, false);
assert.equal(wrongSeed.ok, false);
console.log('tampered result:', tampered.ok, tampered.reasons);
console.log('someone else\'s seed:', wrongSeed.ok, wrongSeed.commitmentOk);
const next = await service.bet('dice');
assert.equal(next.nonce, 0);
assert.notEqual(next.commitment, shown.commitment);
console.log('first bet on the new seed: nonce', next.nonce, '| waiting for a seed:', !('serverSeed' in service.history().at(-1)));
before rotation: false false false | server seed not revealed yet; verify after rotation
after rotation: true true true
tampered result: false [ 'result does not match seeds' ]
someone else's seed: false false
first bet on the new seed: nonce 0 | waiting for a seed: true
Don't drop the negative controls. A round-trip test that asserts only ok === true also passes against a verify function that returns true for everything. Feed it a record with a changed result and a record with a stranger's seed, and require both to fail.
One thing this test can't reach is timing. It shows the commitment on the records equals the one returned by open. It doesn't show a real player saw that commitment before betting, which is a property of your UI and your logs. What verification proves draws that line.
If you sign records, test that too, and know in advance that a signature made at bet time stops verifying once serverSeed is added, since the signing payload includes it. Signing records has the detail. A round-trip test is where people meet that for the first time.
The Active Record Has No Result
For Mines, and for any game where the result must stay hidden while the player acts, the test is about absence. This is the project's own, from apps/api/test/demo-state.test.ts. It needs the API's services, so it isn't runnable here:
test("Active Mines hides positions from responses/history and blocks seed reveals and changes", async () => {
const { redis, demo, mines, id } = await setup();
const started = await mines.start(id, 100, 3);
assert.equal(started.record.result, undefined);
assert.deepEqual(await demo.history(id), []);
await assert.rejects(() => demo.rotate(id), /finish the active Mines/);
await assert.rejects(() => demo.setClientSeed(id, "new-client"), /finish the active Mines/);
// ...
await mines.pick(id, await safeTile(redis, id));
const finished = await mines.cashout(id);
assert.ok(Array.isArray(finished.record.result));
await demo.rotate(id);
const history = await demo.history(id);
assert.equal(history.length, 1);
assert.equal((await verifyRecord(history[0]!)).ok, true);
});
Four doors are tried while the board is live: the start response, the history route, rotation, and a client seed change. The last two matter as much as the first two. Rotation publishes the seed, and the seed with the public client seed and nonce is the board. Then the round ends, and the same test checks that the record did get published, once, and verifies after rotation. Hiding a result for ever would pass the first half.
That test looks at objects. result: undefined disappears when JSON.stringify runs, and the test trusts that. A stricter version makes a real HTTP request, takes the body as text and searches it for "result" and "serverSeed". Concealed games explains why the bytes are the thing to check.
Two Bets Never Share a Nonce
Test the counter where it lives. This needs a Redis on localhost:6379 and ioredis:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import Redis from 'ioredis';
test('500 reservations over two connections give 500 nonces', async () => {
const a = new Redis(), b = new Redis(), key = 'test:nonce:burst';
await a.del(key);
const reserve = async (redis) => Number((await redis.multi().incr(key).expire(key, 60).exec())[0][1]) - 1;
const nonces = await Promise.all(Array.from({ length: 500 }, (_, i) => reserve(i % 2 ? a : b)));
assert.equal(new Set(nonces).size, 500);
assert.equal(Math.max(...nonces), 499);
await a.del(key);
a.disconnect();
b.disconnect();
});
On the project's Redis container that gave 500 distinct values from 0 to 499, with the key reading 500 afterwards. Swap reserve for a GET followed by a SET and it fails at once, which is the control for this test. Storing seeds shows that failure in a runnable form.
Then test it through the front door, because the service can undo what the store got right. This needs the demo API and its Redis on http://localhost:3000:
import { test } from 'node:test';
import assert from 'node:assert/strict';
const base = 'http://localhost:3000';
test('a burst of bets never shares a nonce', async () => {
const session = await (await fetch(`${base}/api/demo/session`, { method: 'POST' })).json();
const headers = { 'content-type': 'application/json', 'x-demo-session': session.id };
const bet = () => fetch(`${base}/api/demo/bet`, { method: 'POST', headers, body: JSON.stringify({ game: 'dice' }) });
const responses = [];
for (let wave = 0; wave < 5; wave++) {
responses.push(...(await Promise.all(Array.from({ length: 8 }, bet))));
await new Promise((resolve) => setTimeout(resolve, 1100)); // the demo allows 5 bets a second
}
const accepted = await Promise.all(responses.filter((r) => r.ok).map((r) => r.json()));
const nonces = accepted.map((body) => body.record.nonce).sort((x, y) => x - y);
assert.ok(accepted.length > 1, 'at least two bets must get through, or the test proves nothing');
assert.deepEqual(nonces, Array.from({ length: accepted.length }, (_, i) => i)); // 0 to k-1, no repeat, no gap
const after = await (await fetch(`${base}/api/demo/session`, { headers })).json();
assert.equal(after.nonce, accepted.length);
});
We ran it against the local API. Of 40 requests, 5 were accepted with 201, 20 were refused with 409 by the session lock and 15 with 429 by the rate limiter. The accepted nonces were 0 to 4 and the session's next nonce was 5.
Read those numbers twice. One bet got through per wave of eight, so at no point were two bets inside the service together. The lock did its job, and as a result this test never exercised the counter. That's why there are two tests. The first proves the counter under real contention. The second proves that whatever the service lets through is numbered without repeats or gaps, and that refused requests burn no nonce. Neither could stand in for the other.
Include a rotation in a burst as well. The NestJS recipe has the measured result of doing that with the lock removed: in 90 sessions, 11 ended with an already revealed seed live again.
Distribution Checks
An early version of this project's crash formula paid 100 times too much. The formula worked in cents and then multiplied by 100 a second time. It had a unit test, and the unit test passed, because the expected value in the test had been worked out with the same slip. It's number 13 in the project's list of mistakes, and what caught it was a look at the distribution. When every multiplier is 100 times too large, a glance down the column is enough.
So for anything with a payout table, print the distribution first and look at it. Then turn what you saw into a check with a tolerance:
import { play } from '@galabet/fair';
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const N = 20000, EDGE = 0.01;
const results = [];
for (let nonce = 0; nonce < N; nonce++) {
const { result } = await play({ game: 'limbo', params: { houseEdge: EDGE }, serverSeed, clientSeed: 'galabet', nonce });
results.push(result);
}
function check(label, sample) {
for (const target of [2, 10]) {
const p = (1 - EDGE) / target; // the published chance of reaching the target
const expected = N * p, sd = Math.sqrt(N * p * (1 - p));
const hits = sample.filter((r) => r >= target).length;
const z = (hits - expected) / sd;
console.log(`${label} | ${target}x or more: ${hits}, expected ${expected} give or take ${sd.toFixed(0)} | z ${z.toFixed(1)} | ${Math.abs(z) < 4 ? 'pass' : 'FAIL'}`);
}
console.log(`${label} | return of a bet on 2x: ${((sample.filter((r) => r >= 2).length * 2) / N).toFixed(4)}`);
}
check('as shipped', results);
check('planted x100', results.map((r) => r * 100));
as shipped | 2x or more: 9930, expected 9900 give or take 71 | z 0.4 | pass
as shipped | 10x or more: 1949, expected 1980 give or take 42 | z -0.7 | pass
as shipped | return of a bet on 2x: 0.9930
planted x100 | 2x or more: 20000, expected 9900 give or take 71 | z 142.8 | FAIL
planted x100 | 10x or more: 20000, expected 1980 give or take 42 | z 426.6 | FAIL
planted x100 | return of a bet on 2x: 2.0000
The second block is a fault we planted: every result multiplied by 100, the same size of error as the crash bug. The check doesn't need subtlety to catch it.
Fixed seeds make this test deterministic. The same 20,000 rounds come out every time, so it can't flake, and the threshold of four standard deviations is there for the day someone changes the seed or the sample size. What fixed seeds can't do is tell you about outcomes too rare to show up. A 10,000x Limbo result is expected about twice in 20,000 rounds, and no sample you'd run in CI says whether it pays correctly. For the tail, test the mapper at chosen floats, the way the Dice page tests dice(0) and dice(1 - 2 ** -32).
For payout tables the project keeps the rule in its contributor notes: a new game's payout maths goes in with a return-to-player test beside it.
The API Tests Use a Redis Command Double
The demo API's state tests run without Redis. demo-state.test.ts opens with a class named RedisDouble and this comment:
// In-memory Redis command double. Validates service sequencing and retry behavior,
// not Redis connectivity or Lua parsing; the Lua operation is modeled explicitly.
class RedisDouble {
data = new Map<string, string>();
hashes = new Map<string, Record<string, string>>();
lists = new Map<string, string[]>();
failNextBoardSave = false;
// get, set (with NX), del, hgetall, lrange, multi().incr/incrby/expire/hset/lpush/ltrim, eval
}
It implements the ioredis calls the services make, seven on the client and seven on the transaction that multi() returns, over three Maps. It is passed to new DemoService(redis as any, config) where the client would go, so the six tests in that file need no container. When we ran the suites, all 26 API tests and all 39 library tests passed.
The double earns its place with failNextBoardSave. One test sets it, and the next write of a Mines board throws. That is how the suite proves a cash-out that was credited but never saved can be retried without paying twice. A real Redis won't fail one chosen write on request.
The comment is honest about the limits, and they're worth spelling out, because a green run invites over-reading.
The Lua is never executed. The double's eval decides which script it has been given by looking for the text return redis.call('DEL' and then by the number of keys, and runs a JavaScript imitation. A typo inside the debit script would pass every test and fail on the first real bet.
expire is a function that returns 1. PX on the lock is ignored. So nothing that depends on time can fail here: not the lock's 30 second lease, and not the nonce key expiring before the session. We found that hazard by reading the code. The tests could never have shown it.
Atomicity is borrowed from JavaScript. The double's multi().exec() runs its queued functions in a row on one thread, which says nothing about two API workers talking to one Redis.
The workflow file narrows the gap a little. It starts Postgres and Redis as service containers, boots the built API, and requests /api/health and one Dice verification with the public seeds. No step in it places a bet against the real Redis. The two burst tests in the previous section are the kind of thing that would.
