DocsRecords

Verifying Records

What verifyRecord checks, every field it returns, every reason string it can give, and the things it leaves unchecked.

verifyRecord answers one narrow question. Given a record that carries its revealed server seed, do those seeds hash to the commitment and produce the result the record claims? It recalculates the round and tells you where it disagrees.

If you were handed a record and want a yes or no, paste it into the verifier. That page runs inspectRecord, a stricter sibling meant for input you don't trust. This page is for code that verifies records it wrote itself, or has already validated. The fields of a record are described on Record Format.

The Outcome Object

outcome.mjs
import { commit, verifyRecord } from '@galabet/fair';

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

const record = {
  spec: 'GFS/1.0', profile: 'single-player', game: 'limbo', params: { houseEdge: 0.01 },
  serverSeed, commitment, clientSeed: 'galabet', nonce: 42, cursor: 0, result: 1.76, at: 1790000000000,
};

console.log(await verifyRecord(record));
Output
{
  ok: true,
  computed: 1.76,
  claimed: 1.76,
  commitmentOk: true,
  cursorOk: true,
  signatureOk: null,
  recordHash: '6c7d84677a6244a38ffbfa017f94f35305f71c4f154bc663e6e6a54f03ff79fd',
  reasons: []
}
FieldMeaning
oktrue exactly when reasons is empty. There is no other rule
computedWhat play returned for the record's game, params, seeds and nonce. null if the seed is missing or the game name is unknown
claimedrecord.result, passed through untouched
commitmentOkSHA-256 of the serverSeed string equals commitment
cursorOkThe recalculated cursor equals record.cursor
signatureOknull, true or false. See below
recordHashSHA-256 of the canonical record, as handed in
reasonsOne string per failed check, in a fixed order

Two of those booleans need care. commitmentOk and cursorOk start as false and only become true when the check ran and passed, so on an unrevealed record both are false although nothing is wrong with it. Read reasons before you read the booleans.

recordHash is taken over whatever you passed, serverSeed included. It won't equal a hash you stored at bet time, when the seed wasn't in the record. Canonical JSON and Record Hash covers why.

Results are compared as canonical JSON, not with ===. An object result with its keys in another order still matches. An array in another order does not, and neither does "1.76" where 1.76 was expected.

Reason Strings

Seven strings, and these are all of them in 0.1.0. They're listed in the order they appear in reasons.

ReasonProduced when
unsupported spec …spec is anything but "GFS/1.0". A missing field reads unsupported spec undefined
server seed not revealed yet; verify after rotationserverSeed is absent or an empty string. The commitment, cursor and result are not checked; spec, game and any signature still are
server seed does not match commitmentThe seed is present and its SHA-256 is not commitment
cursor mismatch: computed N, record MThe round reads up to cursor N and the record says M
result does not match seedsThe recalculated result differs from result
unknown game "…"game is not one of the nine names. Takes the place of the cursor and result checks
signature does not verify under signersignature or signer is present and the pair does not verify
reasons.mjs
import { commit, verifyRecord } from '@galabet/fair';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);
const record = {
  spec: 'GFS/1.0', profile: 'single-player', game: 'limbo', params: { houseEdge: 0.01 },
  serverSeed, commitment, clientSeed: 'galabet', nonce: 42, cursor: 0, result: 1.76, at: 0,
};
const { serverSeed: hidden, ...unrevealed } = record;

const cases = {
  'spec': { ...record, spec: 'GFS/2.0' },
  'no seed': unrevealed,
  'commitment': { ...record, commitment: '0'.repeat(64) },
  'cursor': { ...record, cursor: 1 },
  'result': { ...record, result: 2.5 },
  'game': { ...record, game: 'craps' },
  'signer only': { ...record, signer: 'cd'.repeat(32) },
  'several': { ...record, spec: 'GFS/2.0', commitment: '0'.repeat(64), cursor: 1, result: 2.5 },
};

for (const [name, changed] of Object.entries(cases)) {
  const { reasons } = await verifyRecord(changed);
  console.log(`${name}:`);
  for (const reason of reasons) console.log(`  ${reason}`);
}
Output
spec:
  unsupported spec GFS/2.0
no seed:
  server seed not revealed yet; verify after rotation
commitment:
  server seed does not match commitment
cursor:
  cursor mismatch: computed 0, record 1
result:
  result does not match seeds
game:
  unknown game "craps"
signer only:
  signature does not verify under signer
several:
  unsupported spec GFS/2.0
  server seed does not match commitment
  cursor mismatch: computed 0, record 1
  result does not match seeds

The function never stops at the first failure. The last case has four things wrong and reports four reasons, so a support tool can show the whole list instead of making someone fix and retry.

These are message strings, not codes. 0.1.0 has no error codes, and matching on text will break when a message is reworded. If you need to branch, branch on commitmentOk, cursorOk, signatureOk and on whether computed equals claimed.

Malformed Input Throws

A reason describes a well-formed record with a wrong value in it. A record whose values have the wrong form never gets that far, because verifyRecord passes the seeds to play, and play throws.

throws.mjs
import { commit, verifyRecord } from '@galabet/fair';

const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);
const record = {
  spec: 'GFS/1.0', profile: 'single-player', game: 'mines', params: { mines: 3 },
  serverSeed, commitment, clientSeed: 'galabet', nonce: 42, cursor: 2, result: [9, 17, 22], at: 0,
};

console.log((await verifyRecord(record)).ok);

const malformed = {
  'uppercase seed': { ...record, serverSeed: serverSeed.toUpperCase() },
  'string nonce': { ...record, nonce: '42' },
  'colon in client seed': { ...record, clientSeed: 'lucky:seven' },
  'mines out of range': { ...record, params: { mines: 99 } },
};

for (const [name, bad] of Object.entries(malformed)) {
  try {
    await verifyRecord(bad);
  } catch (error) {
    console.log(`${name}: ${error.message}`);
  }
}
Output
true
uppercase seed: server seed must be 64 lowercase hex characters
string nonce: nonce must be a non-negative integer
colon in client seed: client seed must not contain ":" (reserved as the HMAC message separator)
mines out of range: mines must be 1 to 24

The uppercase seed deserves a second look, because to a person it's the same seed. To the library it isn't. The seed string is the HMAC key as text, so 5C1F… and 5c1f… are different keys, and the library refuses the capitalised one instead of guessing. Seeds from createServerSeed are always lowercase. If a seed reached you through a form or a spreadsheet, lowercase it yourself before calling.

So a caller has two jobs: wrap the call in try/catch, and decide what a throw means to the person looking at the screen. "This record is malformed" and "this record is false" are different messages. inspectRecord draws the same line with wording meant for a form, and it's the better choice whenever the JSON came from outside your own database.

Checks verifyRecord Does Not Make

ok: true means the record agrees with itself. That's a smaller claim than it sounds.

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

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

const params = { houseEdge: -3 };
const { result, cursor } = await play({ game: 'limbo', params, ...seeds });

const outcome = await verifyRecord({
  spec: 'GFS/1.0', profile: 'not-a-profile', game: 'limbo', params,
  ...seeds, commitment, cursor, result, at: 99999999999999,
  beacon: { source: 'drand', ref: 'not-a-round', value: 'zz' },
});

console.log(result, outcome.ok, outcome.reasons);
Output
7.12 true []

That record has a house edge of minus three hundred percent, a profile that doesn't exist, a timestamp in the year 5138 and a beacon full of nonsense. It verifies, because none of those are what the function looks at. With the declared 1% edge the same seeds give 1.76.

The gap that matters most is timing. The function sees a hash and a seed that matches it. It can't know whether the player saw that hash before betting or whether it was written into the record afterwards, and no function could, working from the record alone. What Verification Proves is about that.

params go to the mapper as they are. Plinko, Mines, Keno and the card games throw on values outside their range, as the Mines case did above. Limbo's houseEdge and Wheel's segments aren't validated by play at all, which is how minus three got through. inspectRecord does validate them: 0 to 0.5, and 2 to 100.

profile and at are ignored. So are fields the format doesn't define, though they do change recordHash. beacon is carried and never read, since GFS 1.1 is planned and unwritten.

A valid signature says the holder of signer signed this payload. Whether that key belongs to the casino is a question for wherever the casino publishes its key. And nothing here is about money: stake, target, payout and balance aren't in a GFS record.

When signatureOk Is Null

null means the record has neither a signature nor a signer, so there was nothing to check and an unsigned record is not held against anyone. It is the only one of the checks that can be skipped without failing.

If either field is present the check runs. One without the other is false. So is a bet-time signature on a record that has since gained its serverSeed:

signature-after-reveal.mjs
import { readFile } from 'node:fs/promises';
import { commit, signRecord, verifyRecord } from '@galabet/fair';

const { testSecretKey } = JSON.parse(await readFile('vectors/gfs-1.0-sign.json', 'utf8'));
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);

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

const revealed = await verifyRecord({ ...atBetTime, serverSeed });
console.log(revealed.computed, revealed.commitmentOk, revealed.cursorOk, revealed.signatureOk);
console.log(revealed.ok, revealed.reasons);
Output
56.12 true true false
false [ 'signature does not verify under signer' ]

Every seed check passes and ok is still false. The roll is genuine. The signature was made over a payload that had no serverSeed in it, and verifyRecord checks the signature over exactly what it's given. It doesn't try the record with the seed removed. Signing Records has the two ways to handle this, and until you've picked one, don't treat ok as the whole answer for signed records. Look at the parts.

A Multi-Digest Record

Dice, Limbo and Roulette read one float, so their cursor is always 0 and the cursor check is a formality. Keno is a better test of it. A Keno round shuffles 40 numbers, which takes 39 floats, and at eight floats to a digest that's five digests: cursors 0, 1, 2, 3 and 4.

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

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

const round = await play({ game: 'keno', params: { draws: 10 }, ...seeds });
console.log(round.floats.length, round.cursor, round.result);

const record = {
  spec: 'GFS/1.0', profile: 'single-player', game: 'keno', params: { draws: 10 },
  ...seeds, commitment, cursor: round.cursor, result: round.result, at: 0,
};

console.log((await verifyRecord(record)).ok);
console.log((await verifyRecord({ ...record, cursor: 5 })).reasons);
console.log((await verifyRecord({ ...record, params: { draws: 9 } })).reasons);
Output
39 4 [
   7, 10, 11, 17, 22,
  26, 28, 32, 33, 36
]
true
[ 'cursor mismatch: computed 4, record 5' ]
[ 'result does not match seeds' ]

The second check is a mistake waiting for anyone writing records by hand: count the digests, get five, write 5. cursor is the highest cursor read, so the right value is 4. The drawn numbers in that record are correct and it fails anyway, on the cursor alone, because the comparison is strict equality and not "at least as far". Nonce and Cursor has the full rule.

The third check changes draws from 10 to 9 and leaves the ten recorded numbers alone. The cursor still agrees, because Keno always shuffles all 40, and the result doesn't. Parameters are part of the calculation. A record with the wrong ones is a wrong record.