DocsRecipes
React Verifier
A small React component that checks a pasted record with inspectRecord, shows the status, the checks and the first difference, and never shows a result for text that has since changed.
One textarea, one button, one report. The component below is under 80 lines, and most of what makes it correct is a single integer in a ref.
You need React 18, a bundler that handles TSX (Vite, Astro, Next, anything current) and @galabet/fair. The component code on this page isn't executed by the docs checker, which has no browser and no bundler. The two Node examples are.
1. Look at the Report
Before writing any JSX, see what you'll be rendering. This is the public Dice record with its result changed from 56.12 to 12.34, passed through the same two calls the component will make.
import { commit, inspectRecord, parseInspection } from '@galabet/fair';
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);
// A player pastes text, so start from text.
const pasted = JSON.stringify({
spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
serverSeed, commitment, clientSeed: 'galabet', nonce: 42, cursor: 0, result: 12.34, at: 0,
});
const report = await inspectRecord(parseInspection(pasted));
console.log(JSON.stringify(report, null, 2));
{
"kind": "dice",
"status": "mismatch",
"computed": 56.12,
"claimed": 12.34,
"checks": [
{
"name": "Commitment",
"state": "matches",
"detail": "Compared with the commitment supplied in this record. Publication timing is not checked."
},
{
"name": "Cursor",
"state": "matches",
"detail": "Calculated cursor: 0."
},
{
"name": "Signature",
"state": "not-provided",
"detail": "This record is unsigned."
},
{
"name": "Outcome",
"state": "mismatch",
"detail": "result: recorded 12.34, calculated 56.12."
}
],
"difference": "result: recorded 12.34, calculated 56.12.",
"recordHash": "8446fa855dcf1d7b942a74a81ef9a7e141015b4e07f50a08554b7a9e3b0eed18",
"note": "These checks do not establish when a commitment was published, guarantee a payout or certify an operator."
}
Everything the UI needs is in there, already worded. status is one of matches, mismatch and incomplete. Each entry in checks has a name, a state and a detail sentence. difference is null or a sentence that names the first place the recorded and calculated results part ways. note is the scope statement, and it belongs on screen with every result, including the good ones.
There are no seeds in it. A report can be logged, downloaded or sent to support without carrying the inputs along.
2. The Component
import { useEffect, useRef, useState } from 'react';
import { inspectRecord, parseInspection } from '@galabet/fair';
import type { Inspection } from '@galabet/fair';
const HEADLINES = {
matches: 'The checked values match.',
mismatch: 'A difference was found.',
incomplete: 'Some checks remain open.',
};
const STATES = {
matches: 'Matches',
mismatch: 'Mismatch',
'not-provided': 'Not provided',
unsupported: 'Unsupported',
};
export default function RecordChecker() {
const [text, setText] = useState('');
const [report, setReport] = useState<Inspection | null>(null);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const revision = useRef(0);
// Unmounting counts as a change too.
useEffect(() => () => { revision.current++; }, []);
function edit(next: string) {
revision.current++; // whatever is still running now belongs to old text
setText(next);
setReport(null);
setError('');
setBusy(false);
}
async function check() {
const token = ++revision.current;
setReport(null);
setError('');
setBusy(true);
try {
const next = await inspectRecord(parseInspection(text));
if (token === revision.current) setReport(next);
} catch (e) {
if (token === revision.current) setError(e instanceof Error ? e.message : 'The record could not be checked.');
} finally {
if (token === revision.current) setBusy(false);
}
}
return (
<form onSubmit={(event) => { event.preventDefault(); void check(); }}>
<label>
Record JSON
<textarea rows={12} value={text} onChange={(event) => edit(event.target.value)} spellCheck={false} autoComplete="off" />
</label>
<button disabled={busy || !text.trim()}>{busy ? 'Checking…' : 'Check record'}</button>
{error && <p role="alert">{error}</p>}
{report && (
<section aria-live="polite">
<h3>{HEADLINES[report.status]}</h3>
<dl>
{report.checks.map((item) => (
<div key={item.name}>
<dt>{item.name}: {STATES[item.state]}</dt>
<dd>{item.detail}</dd>
</div>
))}
</dl>
{report.difference && <p>First difference: {report.difference}</p>}
<p>{report.note}</p>
</section>
)}
</form>
);
}
The wording in HEADLINES and STATES is what Galabet's own verifier shows. Change it if you like. Keep three headlines, though. It's tempting to fold incomplete into one of the other two, and both folds are wrong: an unrevealed seed isn't a failure, and a record with nothing compared isn't a pass.
Errors and reports are separate state on purpose. parseInspection and inspectRecord throw for input they can't work with, such as half a record or a nonce in quotes, and return a report for everything else. Every thrown message was written to be read by the person who pasted the text, so it goes to the screen as it is.
3. The Revision Counter
inspectRecord is asynchronous, because Web Crypto is. Between the click and the result, the player can do things. They can paste a different record. They can click again. The component can unmount. In each case a result is on its way for text that's no longer the text, and if it's allowed to land, the screen says "The checked values match." above a record that was never checked.
The guard is the one Galabet's verifier uses. revision is a number in a ref. Every change that makes running work obsolete increments it. Every check takes a copy when it starts, and after its await compares the copy with the current value. Equal means nothing happened in between. Different means the result is for something that's gone, and it's dropped without touching state.
You can watch it work without React. The example delays the first check on purpose, so that it finishes after the second.
import { commit, inspectRecord } from '@galabet/fair';
const serverSeed = '5c1f7d3e8a2b4c6d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d';
const { commitment } = await commit(serverSeed);
const good = {
spec: 'GFS/1.0', profile: 'single-player', game: 'dice', params: {},
serverSeed, commitment, clientSeed: 'galabet', nonce: 42, cursor: 0, result: 56.12, at: 0,
};
const changed = { ...good, result: 12.34 };
const wait = (ms) => new Promise((done) => setTimeout(done, ms));
function makeScreen(guarded) {
const screen = { revision: 0, shown: null };
screen.check = async (label, record, ms) => {
const token = ++screen.revision;
await wait(ms);
const report = await inspectRecord(record);
if (!guarded || token === screen.revision) screen.shown = `${label}: ${report.status}`;
};
return screen;
}
for (const guarded of [false, true]) {
const screen = makeScreen(guarded);
// The player checks the changed record, then pastes the good one and checks again.
await Promise.all([screen.check('first paste', changed, 50), screen.check('second paste', good, 5)]);
console.log(guarded ? 'with the counter: ' : 'without the counter:', screen.shown);
}
without the counter: first paste: mismatch
with the counter: second paste: matches
Without the counter the screen ends on the verdict for the first paste, while the textarea holds the second. That's a mismatch reported against a genuine record. Swap the two and it's worse.
Four details in the component are worth copying exactly.
The counter lives in useRef, not useState. A state value read inside check is a snapshot from the render that created that closure, so after the await it still shows the old number and can't reveal that anything changed. A ref is one mutable box that every closure shares.
check uses ++revision.current and keeps the result, so starting a new check invalidates the previous one. edit increments without keeping anything, because it has no result to wait for.
All three branches are guarded: the report, the error and the finally. The last one is the one people leave out. An unguarded setBusy(false) from a stale check re-enables the button while the current check is still running.
edit clears the report as well as bumping the counter. The counter stops a late result from arriving. It does nothing about a result that's already on screen, and one keystroke in the textarea makes that result a statement about different text.
4. Mounting It
import { createRoot } from 'react-dom/client';
import RecordChecker from './RecordChecker';
createRoot(document.getElementById('verifier')!).render(<RecordChecker />);
Galabet's site is Astro, and mounts its verifier as <VerifyForm client:load />. Either way the component has to run on the client. Rendered on a server it produces an empty form, which is harmless, and the check itself only ever happens after a click.
Serve the page over HTTPS or from localhost. On any other origin crypto.subtle is missing and every check fails, for the reason given under Browsers.
What Was Left Out
The site's VerifyForm.tsx is this component plus a second input mode where the player types the seed, client seed, nonce and parameters into separate fields, a file picker that refuses anything over 64 KB before reading it, two example buttons, a report download, and the code that reads a record from a #record= link and then clears it. That last part is framework-free and is covered in Verifying in the browser.
If you add any of those, route every one of them through the counter. In VerifyForm each field's onChange, the mode switch, the file picker, the example loader and the "Clear inputs" button all call the same invalidate() before doing anything else. A new way to change the input that forgets to do so is how a stale report comes back.
What a match does and doesn't mean is the same here as anywhere else: What verification proves.
