DocsBuilding a backend

Verifying in the Browser

Running play, verifyRecord and inspectRecord on the player's device, handing a record from one page to another without sending it to a server, and the size limits that apply.

A verify endpoint on your own API asks the player to trust the party they're checking. The answer comes from the operator's server, and so could any answer. Run the same check in the player's browser and that dependency goes away: the revealed seed, the client seed and the nonce go into an HMAC on their device, and the result is compared there.

The second reason is quieter. Nothing has to be sent. Galabet's verifier makes no request when you press the button, and the page says as much next to its API example: "Calling the API sends your record to that server. The tool above runs locally and does not call it." A player can confirm it with the network panel open.

The library needs nothing special for this. It's the same import through your bundler, and it uses Web Crypto, which has two conditions described under Browsers on the Installation page.

What a Local Check Still Depends On

The JavaScript. If your site serves the verifier, the player is running your code to check your result, and a dishonest operator could ship a verifier that always says yes. Running in the browser removes the server from the check. It doesn't remove the author of the page.

So build one, because players will use the one in front of them, and also tell them they don't have to. A record that verifies on your page should verify on galabets.org/verify/ and in a short script the player wrote. That's what a published calculation buys.

Three Functions, Same as on the Server

FunctionUse it in a browser whenOn bad input
playYou're showing a calculation: a practice round, a "what would nonce 43 give" toolThrows
verifyRecordYour own code built the record and its shape is knownThrows on malformed fields, returns reasons for wrong values
inspectRecordThe record came from a person: pasted, opened from a file, read from a linkThrows a message fit to show in a form

For anything a user can type into, use inspectRecord, with parseInspection in front of it when you start from text. The pair enforces a size limit, a nesting limit and per-game parameter ranges before any calculation runs. verifyRecord doesn't. Hand it a Wheel record with segments of 5,000,000 and it computes a result. inspectRecord stops at segments: enter a whole number from 2 to 100.

The examples on this page run in Node, because the checker that executes them has no browser. That's less of a cheat than it sounds. These functions touch no DOM and make no requests, so the Node run and the browser run are the same code path.

pasted.mjs
import { commit, inspectRecord, parseInspection, play } from '@galabet/fair';

// What a form handler does with the contents of a textarea.
async function check(text) {
  try {
    const report = await inspectRecord(parseInspection(text));
    return `${report.status}${report.difference ? ` (${report.difference})` : ''}`;
  } catch (error) {
    return `refused: ${error.message}`;
  }
}

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);
const { result, cursor } = await play({ game: 'dice', serverSeed, clientSeed: 'galabet', nonce: 42 });
const record = {
  spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
  serverSeed, commitment, clientSeed: 'galabet', nonce: 42, cursor, result, at: 0,
};
const text = JSON.stringify(record, null, 2);

console.log(await check(text));
console.log(await check(text.replace('56.12', '65.12')));
console.log(await check(text.slice(0, -1)));
console.log(await check(text.replace('"nonce": 42', '"nonce": "42"')));
console.log(await check(text.replace(/\s*"serverSeed": "[0-9a-f]+",/, '')));
console.log(await check('[1, 2, 3]'));
Output
matches
mismatch (result: recorded 65.12, calculated 56.12.)
refused: Record JSON is incomplete or invalid. Include the opening and closing braces.
refused: nonce: enter a whole number from 0 to 9007199254740991.
incomplete
refused: record: expected a JSON object.

Six inputs, and the handler never needed to know which kind of wrong it was looking at. Anything thrown is a sentence you can put under the field. There are no error codes in 0.1.0, so the sentence is all there is.

The fifth line is the one to design for. A record with no serverSeed is not an error and not a mismatch. It's incomplete, and a player will paste one sooner or later, because the record they saved at bet time doesn't have the seed yet.

Passing a Record Between Pages

A game page knows the record. The verifier page needs it. The obvious way to carry it across is a query string, and it's the wrong one: /verify/?record=... puts the record into the request line, so it reaches your server, your CDN and every access log between them. A revealed seed is no longer a secret, but the record still ties a client seed, a result and a time to whoever made the request, and a page that promises "Your inputs are not uploaded" shouldn't upload them on the way in.

The site uses the URL fragment. Browsers keep everything after # to themselves. It's not part of the HTTP request and it's stripped from the Referer header.

This is the whole of it, from apps/site/src/lib/game-practice.ts:

game-practice.ts
export function recordHref(record: FairRecord) {
  return `/verify/#${new URLSearchParams({ record: JSON.stringify(record) })}`;
}

URLSearchParams is doing the escaping. Its string form is record= followed by the percent-encoded JSON, and nothing says that format may only follow a ?.

link.mjs
import { commit, play } from '@galabet/fair';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const clientSeed = 'lucky seven';
const { commitment } = await commit(serverSeed);
const { result, cursor } = await play({ game: 'dice', serverSeed, clientSeed, nonce: 42 });
const record = {
  spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
  serverSeed, commitment, clientSeed, nonce: 42, cursor, result, at: 0,
};

const href = `/verify/#${new URLSearchParams({ record: JSON.stringify(record) })}`;
console.log(href.slice(0, 60));

// What the browser puts in the request, and what it keeps.
const url = new URL(href, 'https://casino.example');
console.log('requested:', url.pathname + url.search);
console.log('kept in the browser:', url.hash.length, 'characters');

// The reading side, as the verifier does it.
const fromLink = new URLSearchParams(url.hash.slice(1)).get('record');
console.log('round trip:', fromLink === JSON.stringify(record));

// The reading side done by hand.
const byHand = decodeURIComponent(url.hash.slice('#record='.length));
console.log('decodeURIComponent:', byHand === JSON.stringify(record), JSON.parse(byHand).clientSeed);
Output
/verify/#record=%7B%22spec%22%3A%22GFS%2F1.0%22%2C%22profile
requested: /verify/
kept in the browser: 429 characters
round trip: true
decodeURIComponent: false lucky+seven

Look at the last line. URLSearchParams writes a space as +, and only URLSearchParams turns it back. Decode the fragment with decodeURIComponent and a client seed of lucky seven arrives as lucky+seven, which is a different seed, so a genuine record reports a mismatch. Use the same class on both ends.

Reading and Removing It

The verifier reads the fragment once when it loads, fills the textarea, and then erases the fragment from the address bar. Below is that logic from VerifyForm.tsx with the React state calls swapped for a plain textarea. It needs a browser, so the checker doesn't run it.

read-link.js
const textarea = document.querySelector('textarea');

function readLink() {
  const source = location.hash.startsWith('#record=') ? location.hash.slice(1) : location.search;
  const q = new URLSearchParams(source);
  if (!q.has('record')) return;
  textarea.value = q.get('record') ?? '';
  history.replaceState(null, '', location.pathname);
}

readLink();
window.addEventListener('hashchange', () => {
  if (location.hash.startsWith('#record=')) readLink();
});

history.replaceState rewrites the current history entry to the bare path. The record is gone from the address bar, it isn't in the link a player copies to a friend a minute later, and pressing Back doesn't bring it up again.

The hashchange listener is there because following a second #record= link to a page that's already open doesn't reload it. Without the listener the second record would never be read. In the real component that handler also throws away the report on screen before reading, so an old verdict can't sit above a new record. The React verifier recipe builds that guard.

Reading the link fills the form and stops. The player still presses "Check record". A link that arrived from somewhere else shouldn't get to run a calculation and paint a green result before the player has looked at what was loaded.

The component still accepts ?record=..., and an older ?server=...&client=...&nonce=...&game=... form that fills the input fields. It removes those from the address bar too. That tidies the display and nothing more, since a query string has been sent to the server by the time any script runs. They're kept so old links don't break. The homepage's public-example links still use the query form, which is harmless for a public seed and a leftover all the same. Don't generate new ones.

Size Limits

sizes.mjs
import { MAX_RECORD_BYTES, commit, parseInspection, play } from '@galabet/fair';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);

console.log('limit:', MAX_RECORD_BYTES, 'bytes');
for (const [game, params] of [['dice', {}], ['plinko', { rows: 16 }], ['blackjack', { decks: 1 }], ['blackjack', { decks: 8 }]]) {
  const { result, cursor } = await play({ game, params, serverSeed, clientSeed: 'galabet', nonce: 42 });
  const json = JSON.stringify({
    spec: 'GFS/1.0', profile: 'single-player', game, params,
    serverSeed, commitment, clientSeed: 'galabet', nonce: 42, cursor, result, at: 0,
  });
  const link = `/verify/#${new URLSearchParams({ record: json })}`;
  console.log(game, JSON.stringify(params), '| record', new TextEncoder().encode(json).length, 'bytes | link', link.length, 'characters');
}

// The limit counts UTF-8 bytes, not characters.
const padded = JSON.stringify({ note: 'é'.repeat(40000) });
console.log(padded.length, 'characters,', new TextEncoder().encode(padded).length, 'bytes');
try {
  parseInspection(padded);
} catch (error) {
  console.log(error.message);
}
Output
limit: 65536 bytes
dice {} | record 297 bytes | link 433 characters
plinko {"rows":16} | record 356 bytes | link 550 characters
blackjack {"decks":1} | record 567 bytes | link 1023 characters
blackjack {"decks":8} | record 2388 bytes | link 5028 characters
40011 characters, 80011 bytes
Record is too large. Open a JSON file smaller than 64 KB.

Two different things have a size here, and only one of them has a limit in code.

LimitValueWhere
Record text65,536 UTF-8 bytes, exported as MAX_RECORD_BYTESparseInspection, and again inside inspectRecord, which serialises the object it was given and measures that
Nesting12 levelsparseInspection, with Record nesting is too deep.
A file, before it's readSame 65,536, against file.sizeThe verifier page, so a huge file is never loaded into memory
Crash salt1 to 1,024 charactersinspectRecord

The byte count is of UTF-8, not of text.length. The last three lines of the output show a string of 40,011 characters refused because it's 80,011 bytes. It also counts whitespace, so a pretty-printed file can be refused where its compact form would pass.

None of that gets close to a real record. An eight-deck shoe is the largest result any of the nine games produces, and its record is 2,388 bytes, under 4% of the limit. The limit is there for the input nobody intended.

The link has no limit in the library or the site, and that's the number to watch. Percent-encoding turns every quote, colon and comma into three characters. The Dice record grows from 297 bytes to a 433-character link. The eight-deck record more than doubles, to 5,028. A fragment link is a good way to move a record between two pages of one site. For anything a player will paste into a chat or an email, give them the JSON as a file. The verifier has "Open record file" for exactly that.

Revealed Seeds Only

Everything on this page assumes the seed has been rotated out. A browser is the player's machine, and a live server seed that reaches it is a list of every result still to come. The verifier's own hint under the seed field reads "Use a revealed value from completed play, never an active secret." If your verifier page ever needs a live seed to work, the design is wrong somewhere upstream of the page.