DocsRecords

Inspecting Untrusted Records

inspectRecord validates a record from a stranger before calculating anything, then reports each check on its own line with a status of matches, mismatch or incomplete.

A record pasted into a web form could be anything. It might be 40 MB. It might nest arrays a thousand deep, ask Wheel for a million segments, or name a game called toString. inspectRecord is the function you point at that kind of input. It refuses what it can't bound, recalculates what's left, and writes a report that says which checks ran and which couldn't.

The verifier page is this function with a form around it. If you have a record and no interest in code, go there. It runs in your browser and uploads nothing.

How It Differs from verifyRecord

verifyRecordinspectRecord
InputA typed FairRecordunknown
Size and depthNo limit64 KB of JSON, 12 levels
spec, profile, gameWrong spec or game is a reason. profile is ignoredAll three must be right or it throws
paramsPassed to the mapper as they areValidated per game. Unknown keys refused
Seed not revealedok: falsestatus: "incomplete"
A beacon fieldIgnoredListed as unsupported, status incomplete
Crash and Flight recordsNot acceptedAccepted
Verdictok and a list of reasonsstatus, one line per check, first differing path

The third status matters as much as the validation does. verifyRecord has two answers, and an unrevealed record gets the same false as a forged one. A form shown to the public can't do that. Telling a player their honest, not-yet-rotated bet "failed verification" is wrong, and so is telling them a record with an unchecked beacon passed. Verifying Records covers the two-answer function.

The Report

Here is a Dice record whose seed is still live, which is what a player holds right after betting.

incomplete.mjs
import { inspectRecord } from '@galabet/fair';

const report = await inspectRecord({
  spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
  commitment: 'ab37723062965715c5e6eeb54816f909a8e787bd3bfaee67a3cc0a3b90707de7',
  clientSeed: 'galabet', nonce: 42, cursor: 0, result: 56.12, at: 1790000000000,
});

console.log(report);
Output
{
  kind: 'dice',
  status: 'incomplete',
  computed: null,
  claimed: 56.12,
  checks: [
    {
      name: 'Commitment',
      state: 'not-provided',
      detail: 'The server seed has not been revealed.'
    },
    {
      name: 'Cursor',
      state: 'not-provided',
      detail: 'Reveal the seed before reproducing the calculation.'
    },
    {
      name: 'Signature',
      state: 'not-provided',
      detail: 'This record is unsigned.'
    },
    {
      name: 'Outcome',
      state: 'not-provided',
      detail: 'The seed must be revealed before comparing outcomes.'
    }
  ],
  difference: null,
  recordHash: '9b0d37656b565a66aa44469dd748d6f9ae65c4a254ed556207637c129e1a9d3a',
  note: 'These checks do not establish when a commitment was published, guarantee a payout or certify an operator.'
}

kind is the game name, or Crash, or Galabet Flight. claimed is the record's result and computed is what the inputs produce, null here because there's no seed to produce it from. recordHash is present whenever the record has result, cursor, at and commitment, and it's the same hash recordHash gives.

Each entry in checks has a name, a state and a sentence of detail written for display. The four states:

StateMeaning
matchesThe check ran and agreed
mismatchThe check ran and disagreed
not-providedThe record lacks what the check needs: a seed, a commitment, a result, a signature
unsupportedThe record has the field and this library cannot check it

note is a fixed sentence about scope, and it's there so that any interface rendering the report carries the disclaimer along with the verdict. A green tick with no caveat next to it overstates what happened.

How the Status Is Decided

Any check in state mismatch makes the status mismatch. Otherwise, any unsupported check makes it incomplete, and so does a required check that isn't matches. What's left is matches.

Which checks are required depends on the kind of record:

KindRequired for matches
Single-player gameOutcome, Commitment, Cursor
Crash, with previousHashOutcome, Chain link
Crash, without previousHashOutcome, Commitment
Galabet FlightOutcome, Commitment

Signature is never required. An unsigned record can match. A signed record whose signature fails is a mismatch like any other, and the signature is checked over the record exactly as supplied, so the reveal problem described on Signing Records applies here too.

Leave result out and the Outcome check is not-provided, so the status is incomplete, but computed still holds the answer. That's how the "Enter inputs" tab on the verifier page works as a calculator.

Crash and Flight Records

A record with a gameHash field is treated as Crash. Add an id and it's treated as Galabet Flight. Neither has a server seed, client seed or nonce. The crash point comes from gameHash, salt and houseEdge, as the Crash page explains, and there's no profile field to set. spec may be omitted, but if present it must be GFS/1.0.

The public Crash example hashes the text galabet-game-design-public-example to get its game hash, and crashes at 5.95.

crash.mjs
import { inspectRecord, sha256Hex } from '@galabet/fair';

function show(report) {
  console.log(`${report.kind}: ${report.status}, calculated ${report.computed}`);
  for (const { name, state } of report.checks) console.log(`  ${name}: ${state}`);
}

const gameHash = await sha256Hex('galabet-game-design-public-example');
const previousHash = await sha256Hex(gameHash);
console.log(gameHash);

const crash = { gameHash, salt: 'galabet-design-review-public-salt', houseEdge: 0.01, result: 5.95, previousHash };
show(await inspectRecord(crash));
show(await inspectRecord({ ...crash, previousHash: '0'.repeat(64) }));

const flight = await inspectRecord({
  id: 'example-round', gameHash, salt: crash.salt, houseEdge: 0.01, result: 5.95,
  commitment: previousHash, cashedAt: 2.1, payout: 999999,
});
show(flight);
console.log(flight.note);
Output
85a96b33e69fe1bdfd99d97e021112182907781e4b501bd208e27a47a85a3739
Crash: matches, calculated 5.95
  Commitment: not-provided
  Chain link: matches
  Outcome: matches
Crash: mismatch, calculated 5.95
  Commitment: not-provided
  Chain link: mismatch
  Outcome: matches
Galabet Flight: matches, calculated 5.95
  Commitment: matches
  Outcome: matches
Endpoint checks do not authenticate cash-out timing, stakes or payouts. Those fields are a supplied receipt.

For these records Commitment and Chain link are the same arithmetic under two names. Each takes the SHA-256 of gameHash and compares it with the value supplied, commitment for one and previousHash for the other. The detail text on Chain link says what it's worth: one link to the hash you supplied, not a walk down the whole chain.

Now look at the Flight record. It claims a payout of 999,999 and the status is matches. Nothing is wrong with the function. cashedAt and payout are not derived from anything, so there is nothing to recalculate them from, and the report's note changes to say so: the crash point is verified, the receipt is whatever the sender typed. Don't render a Flight report without that note.

Signatures aren't defined for this format. A Crash or Flight record carrying signature or signer gets a Signature line in state unsupported, which makes it incomplete.

A Beacon Makes a Record Incomplete

beacon.mjs
import { commit, inspectRecord } from '@galabet/fair';

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

const report = await inspectRecord({
  spec: 'GFS/1.0', profile: 'single-player', game: 'roulette', params: {},
  serverSeed, commitment, clientSeed: 'galabet', nonce: 42, cursor: 0, result: 20, at: 0,
  beacon: { source: 'drand', ref: '4242', value: '00' },
});

console.log(report.status);
for (const { name, state } of report.checks) console.log(`${name}: ${state}`);
Output
incomplete
Commitment: matches
Cursor: matches
Signature: not-provided
Beacon: unsupported
Outcome: matches

Everything that could be checked did match. The status is still incomplete, because a record that says "this round also depended on a public beacon" is making a claim 0.1.0 can't test. The library's own test for this is named "beacon checks cannot silently pass". GFS 1.1, which would define the beacon, is planned and not written.

First Difference

On a mismatch, difference names the first place the recorded and calculated results part ways. For a number that's the whole result. For a Plinko path or a shuffled deck it's an index, which saves someone comparing two 52-card arrays by eye.

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

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);
const seeds = { serverSeed, clientSeed: 'galabet', nonce: 42 };

const { result, cursor } = await play({ game: 'plinko', params: { rows: 8 }, ...seeds });
console.log(JSON.stringify(result));

const record = {
  spec: 'GFS/1.0', profile: 'single-player', game: 'plinko', params: { rows: 8 },
  ...seeds, commitment, cursor, at: 0,
};

const flipped = { ...result, path: result.path.map((step, i) => (i === 6 ? 1 - step : step)) };
console.log((await inspectRecord({ ...record, result: flipped })).difference);
console.log((await inspectRecord({ ...record, result: { ...result, path: result.path.slice(0, 7) } })).difference);
console.log((await inspectRecord({ ...record, result: { path: result.path } })).difference);
Output
{"path":[1,0,0,1,0,1,1,1],"bucket":5}
result.path[6]: recorded 0, calculated 1.
result.path: recorded and calculated lengths differ (7 / 8).
result.bucket: field is missing from one result.

Indexes start at zero. Only the first difference is reported, so a record with a flipped step and a wrong bucket mentions the step and stops.

Parsing Text

inspectRecord takes a value. When what you have is text, from a textarea or an uploaded file, parseInspection turns it into an object under the same limits, and it's synchronous, so a form can reject a bad file before doing any hashing.

parse.mjs
import { MAX_RECORD_BYTES, parseInspection } from '@galabet/fair';

console.log(MAX_RECORD_BYTES);

const nested = (levels) => `{"x":${'['.repeat(levels)}0${']'.repeat(levels)}}`;
const inputs = {
  'eleven arrays': nested(11),
  'twelve arrays': nested(12),
  '33,000 x "é"': `{"x":"${'é'.repeat(33000)}"}`,
  'cut off': '{"spec":"GFS/1.0",',
  'an array': '[1,2,3]',
  '1e999': '{"result":1e999}',
};

for (const [name, text] of Object.entries(inputs)) {
  try {
    parseInspection(text);
    console.log(`${name}: parsed`);
  } catch (error) {
    console.log(`${name}: ${error.message}`);
  }
}
Output
65536
eleven arrays: parsed
twelve arrays: Record nesting is too deep.
33,000 x "é": Record is too large. Open a JSON file smaller than 64 KB.
cut off: Record JSON is incomplete or invalid. Include the opening and closing braces.
an array: record: expected a JSON object.
1e999: Record numbers must be finite.

The size limit is 65,536 bytes of UTF-8, not characters, which is why 33,000 two-byte letters are too many. The depth limit allows values twelve levels below the record itself. No GFS result comes anywhere near either limit. 1e999 is legal JSON that JavaScript parses to Infinity, and it's refused because canonical JSON can't represent it.

inspectRecord applies the same limits to objects. It serialises its argument with JSON.stringify and runs the text through parseInspection before looking at a single field, so an object built in code gets no more trust than a pasted string.

Parameter Validation

validateInspectionParams(game, params) is the per-game check on its own. It returns the params if they pass and throws if they don't. The ranges are the ones in the Parameters by Game table, and they're enforced here even where play doesn't enforce them.

params.mjs
import { validateInspectionParams } from '@galabet/fair';

const attempts = [
  ['plinko', { rows: 12 }],
  ['mines', {}],
  ['wheel', { segments: 1000000 }],
  ['limbo', { houseEdge: '0.01' }],
  ['dice', { houseEdge: 0.01 }],
  ['keno', { draws: 10, bonus: true }],
];

for (const [game, params] of attempts) {
  try {
    console.log(game, validateInspectionParams(game, params));
  } catch (error) {
    console.log(game, error.message);
  }
}
Output
plinko { rows: 12 }
mines {}
wheel segments: enter a whole number from 2 to 100.
limbo houseEdge: expected a number from 0 to 0.5.
dice params.houseEdge: not supported for dice.
keno params.bonus: not supported for keno.

An empty object passes, and the game's default applies. params itself can't be missing: a single-player record without the field throws params: expected a JSON object. That's stricter than verifyRecord, which treats a missing params as {}.

Look at the Wheel line. play takes a million segments without complaint and, for the public inputs, lands on segment 561,171. No wheel has a million segments, and a record claiming one is either a mistake or a probe. verifyRecord would call it ok.

Thrown Errors

A report describes a record that was valid enough to check. Everything else throws a plain Error, and the messages are written to be shown to the person who pasted the record. On the verifier page they appear under the form as they are.

MessageCause
Record is too large. Open a JSON file smaller than 64 KB.More than 65,536 bytes
Record JSON is incomplete or invalid. Include the opening and closing braces.JSON.parse failed
Record nesting is too deep.A value more than 12 levels down
Record numbers must be finite.A number such as 1e999
record: expected a JSON object.null, an array, a string or a number at the top level
Unsupported calculation version. Expected GFS/1.0.Wrong spec, or a missing one on a single-player record
Unsupported profile. Expected single-player or a Crash/Flight record with gameHash.No gameHash and profile isn't single-player
game: choose one of the nine supported seed-based games.Unknown game name
params.…: not supported for …A parameter the game doesn't take
…: enter a whole number from … to ….nonce, cursor, at or an integer parameter out of range, fractional, a string, or above 2⁵³ − 1
…: expected a number from … to ….houseEdge outside 0 to 0.5 for Limbo or 0 to 0.999999 for Crash, or a Crash result below 1
…: expected 64 lowercase hexadecimal characters.serverSeed, commitment, signer, gameHash or previousHash in the wrong form, uppercase included
signature: expected 128 lowercase hexadecimal characters.Malformed signature
…: required for a signed record.A signed record missing result, cursor, at or commitment
clientSeed: expected text.Client seed isn't a string
salt: enter 1–1024 characters.Crash salt empty, too long, or not a string

Client seed problems come through with the library's usual wording, client seed must be 1 to 64 characters and client seed must not contain ":". There are no error codes in 0.1.0.

Where It Runs

The verifier page calls parseInspection on the pasted text and hands the object to inspectRecord, all in the browser. Its downloadable report is the object shown on this page as JSON. There are no seeds in it, only the two results, the checks, the hash and the note.

The demo API exposes the same function as POST /api/verify/inspect, for callers who'd rather not run JavaScript. It answers 400 to anything that throws. That API isn't deployed anywhere public, so for now the endpoint exists at http://localhost:3000 on a machine running the repository, and the verify endpoints page is the place for its details.

Both call the same code, which is why it lives in the library and not in the site. A record can't match in the browser and mismatch on the server.