Not yet certified. Pre-launch tests passed; final certification requires public launch and an independent recheck of live bets using anonymous, self-funded accounts.

Keno
Independent verification, backed by open-source code.
- Live Bets Verified
- 3,000
- Parity Rate
- 100%
- Simulated Rounds
- 10M
- Simulated RTP 99.0143% against 98.9850% exact on the 40 Classic–High settings
- Proven RTP (mean)
- 98.9867%
- 98.9031%–99.0689% per setting
Verification pipeline
Real bets. Independently reproduced.
Captured on LIQD’s pre-launch environment. We rebuilt the game logic from scratch and matched all 3,000 recorded outcomes.
The casino result, reproduced.
- Casino outcome
- 0 of 1 hit · 0×
- Our reproduction
- 0 of 1 hit · 0×
- Recorded payout
- $0.0000
- Seed Hash IntegrityPass
- Forward Commitment ChainPass
- Hash ConsistencyPass
- Nonce ContinuityPass
- Population & Epoch BindingPass
- Draw RecomputationPass
- Client-Seed InfluenceThese checks will be completed in production, with the elevated-stake rounds and player-set client-seed rounds run in full on LIQD’s production site before certification.
- Draw Bias BoundPass
- Draw Structure & Hit CountingPass
- Pick-Set IndependencePass
- Paytable ProvenancePass
- Payout ArithmeticPass
- Bet-Size InvarianceThese checks will be completed in production, with the elevated-stake rounds and player-set client-seed rounds run in full on LIQD’s production site before certification.
- Paytable Shape & BoundsPass
- Configuration & Cell CoveragePass
- Stake InvarianceThese checks will be completed in production, with the elevated-stake rounds and player-set client-seed rounds run in full on LIQD’s production site before certification.
- Anti-CircularityPass
- RTP by Exact EnumerationPass
- Simulation Pass 1Pass
- Cherry-Pick DetectionPass
- Live-Sample FitPass
- Dataset & Config PinsPass
- Artifacts of RecordPass
- LIQD Verifier ParityPass
The repo is the credential. You don’t have to trust us — every scored check ships as code. Run npm test to re-run the full audit with no dependency install: 24 unit tests, the 24 verification steps and 3,000 live rounds re-verified — or run the two bundles directly with node test.js && node verify.js.
This audit independently validates the Keno game operated by LIQD under the ProvablyFair.org Audit Framework 1.1. We placed 3,000 real rounds across 50 seed epochs — all five risk levels at every pick count from 1 to 10, 60 rounds on each of the 50 settings — and recomputed every draw, every hit count and every credited amount with our own implementation of the algorithm, written from a specification without LIQD’s code. The capture ran on LIQD’s pre-launch environment, so the certificate is provisional.
Audit Verdict
| Check | Result | Evidence |
|---|---|---|
| Overall Status | Pass | PROVABLY FAIR — Full Pass · Passed 21/24 · Hard fails 0 · Steps 7, 13 and 16 will be completed on LIQD’s production site before certification (subsections 1.4, 3.8 and 4.7). Provisional: captured on LIQD’s pre-launch environment, qa.liqd.com |
| RTP Verified | Pass | 98.9031%–99.0689% Proven RTP per setting (mean 98.9867%) · 99.0143% simulated across the 40 Classic, Low, Medium and High settings in the 10M-round run · RTP claim rule: LIQD’s published 1.00% edge holds on all 50 settings, worst gap −0.096947 pp (Low/5) against the ±0.1 pp band (RTP-CLAIM 1.2.0, Step 18) |
| Live ↔︎ Verifier Parity | Pass | 100% — 3,000 / 3,000 draws reproduced in served order, with every hit count, multiplier and credited amount (Steps 6, 9, 11, 12) · LIQD’s own verifier, as a cross-check, returns the served round on 1,144 / 1,144 replies — every WTF round and every observed Classic–High paytable cell, 174 / 174 (Step 24) |
| Commit-Reveal System | Pass | 51 / 51 revealed seeds hash to their commitments; the next-seed chain is consistent on 51 / 51 rotations, including the pre-capture link into epoch 0; 3,000 / 3,000 rounds carry their epoch’s commitment and client seed (Steps 1–3) |
| Client Seed | Pass | Player-controlled — each of the 50 epochs ran on a fresh client seed our capture set after LIQD had published the server commitment it pairs with (51 / 51 rotations); every draw recomputes with it, and a changed client seed changes the drawn numbers on 2,400 / 2,400 Classic–High rounds (Steps 2, 6; Step 7’s seed controls) |
| RNG Analysis | Pass | Our own implementation, written from the specification: HMAC-SHA256 keyed by the decoded server seed, one 32-bit value per cursor, a 10-step Fisher–Yates over 1–40 — worst-case modulo bias 2.070 × 10⁻¹⁰ per draw, and 0 of the 30,600 captured draw steps land where plain modulo and LIQD’s rejection rule could differ (Steps 6, 8) |
| Payout Logic | Pass | All 3,000 settlements verified — every served multiplier equals its declared paytable cell, ×10 on the 16 paying WTF rounds whose lightning number was drawn; every credit is floor(stake × cell) at the 8th decimal with zero tolerance, and 2,000 / 2,000 zero-cell rounds pay exactly 0 (Steps 11, 12, 14) |
| Anti-Circularity | Pass | Σ P(k hits) × cell, with P(k hits) = C(n,k)·C(40−n,10−k)/C(40,10) from the board alone, gives all 50 settings as exact fractions — the closed form equals a draw-by-draw derivation cell for cell, and no operator odds or RTP field is read (Steps 17, 18) |
| Fairness Integrity | Pass | 34 fairness integrity tests across 12 categories — 33 pass, 1 remediated (paytable return: corrected by LIQD; on the audited table all 50 settings meet the RTP claim rule, Step 18) |
| Determinism | Pass | Full reproducibility confirmed — 3,000 / 3,000 rounds recompute from the recorded inputs: the ten numbers from (server seed, client seed, nonce) — WTF one cursor later, after its lightning number — never from the picks or stake, and the payout from the draw, picks, risk and stake (Steps 6, 10, 12) |
Overall StatusPass
RTP VerifiedPass
Live ↔︎ Verifier ParityPass
Commit-Reveal SystemPass
Client SeedPass
RNG AnalysisPass
Payout LogicPass
Anti-CircularityPass
Fairness IntegrityPass
DeterminismPass
What Was Audited
✓ Audited
- The draw is deterministic: every round’s ten numbers reproduce, in order, from server seed, client seed and nonce
- Server seeds are committed by SHA-256 hash, and each rotation pre-commits the next seed’s hash
- The client seed is player-controlled and changes the draw
- Nonces run 0–59 in every seed epoch with no gaps or reuse
- The draw is a 10-step Fisher–Yates over the 1–40 board, one HMAC-SHA256 value per step
- Hits are recounted from the drawn numbers, and every served multiplier equals the declared paytable cell for its risk, picks and hits — 325 cells over 50 settings
- Payouts settle as
floor(stake × cell)at the 8th decimal; zero cells pay exactly 0 - Proven RTP is exact for every setting — 98.9031% to 99.0689%, mean 98.9867%
- LIQD’s published 1.00% edge holds on all 50 settings under the RTP claim rule (±0.1 pp)
- WTF mode: the lightning number drawn first, and the ×10 payout when it is among the ten drawn
- The numbers you pick and the amount you stake do not influence the draw
- Players can independently verify every round
What Audit Excludes
- Infrastructure or server security
- Wallet, payments, or operational systems outside game logic
- Cross-account sampling
What Audit Covers
| Area | Description |
|---|---|
| Commit-Reveal System | SHA-256 server seed hashing, pre-bet commitment, next-seed pre-commitment, reveal on rotation |
| Client Seed Origin | Player-controlled seed set after the server commitment — a fresh seed from our own CSPRNG in every one of the 50 epochs |
| Seed Handling | Client seed control, nonce lifecycle, seed epoch rotation |
| RNG Analysis | Our own HMAC-SHA256 implementation, written from the specification; LIQD’s verifier used as a cross-check; the 10-step Fisher–Yates draw, modulo-bias bound, the WTF lightning cursor |
| Payout Logic | Declared paytable cells (325 over 50 settings), served-vs-declared multipliers, the ×10 WTF lightning settlement, floor settlement at the 8th decimal, zero-cell rounds and wallet-field reconciliation |
| Live Parity | Independent draw, hit-count and payout recomputation vs live game results on all 3,000 rounds, in served draw order |
| RTP Validation | Exact hypergeometric RTP for all 50 settings, the RTP claim rule, simulated RTP (10M rounds), cherry-pick test on the served rounds’ return |
| Fairness Integrity | Integrity tests on LIQD’s running service — 34 across 12 categories: race conditions, input validation, atomic balance / idempotency, broken authorization, max-win bypass, nonce manipulation, RNG / PF verification, RNG / information leak, entropy / PF verification, precision / payout, event replay and adversarial seeds |
What Audit Guarantees
- Outcomes are deterministic and reproducible from the recorded inputs
- Live game results match independent recomputation for the verified sample (3,000 / 3,000)
- Draws are consistent with the uniform distribution expected from the HMAC-driven Fisher–Yates shuffle — 10,000,000 simulated rounds pass their uniformity screens, and the 3,000 captured draws fit the same law (informational)
- RTP is proven analytically: Σ P(k hits) × cell, with the exact hypergeometric P(k hits) = C(n,k)·C(40−n,10−k)/C(40,10), gives every one of the 50 settings as an exact fraction
- LIQD’s published 1.00% edge is met by every one of the 50 settings under the RTP claim rule — Proven RTP 98.9031% to 99.0689%, the closest at −0.096947 pp (Low/5)
- Simulated RTP of 99.0143% across the 40 Classic, Low, Medium and High settings in a 10,000,000-round run — all 50 paytable rows converge within 5 standard errors of their exact values (largest |z| 1.73)
- The draw is fixed by the seed pair and nonce before your picks are applied — every scored round’s ten numbers recompute from the seeds and nonce alone, with no pick or stake input, so the numbers you pick never change the numbers drawn
- Client seed is a genuine, player-controlled input that materially influences results (a changed client seed changes the drawn numbers on 2,400 / 2,400 Classic–High rounds)
- Every credit is exactly
floor(stake × cell)on the declared paytable — 3,000 / 3,000 rounds, with ×10 on the 16 paying WTF rounds whose lightning number was drawn - Every fairness integrity test in Section 5 passed or was remediated and re-verified — no test altered, replayed or injected an outcome
References
Keno — Game Rules7 sections▶
How LIQD Keno plays, exactly as verified by this audit — the interactive board below is wired to the audited paytable and hit odds.
Game Rules
Keno draws 10 different numbers from a board of 40. Before the draw you pick 1 to 10 numbers and choose one of five risk levels — Classic, Low, Medium, High or WTF. Your hits are the picks that appear among the ten drawn numbers, and the paytable cell for your risk, pick count and hits is the multiplier. The ten numbers come from HMAC-SHA256 seeded by (serverSeed, clientSeed, nonce) — a 10-step Fisher–Yates shuffle of the board, fixed before your picks and stake are applied; your picks and stake decide which cell pays, never which numbers are drawn. WTF first draws one extra number, the lightning number: a paying WTF round pays 10 × its cell when the lightning number is among the ten drawn.
How to Play
1. Pick your numbers — choose 1 to 10 numbers on the 1–40 board; the pick count selects the paytable row.
2. Choose a risk level — Classic, Low, Medium, High or WTF. Each has its own row for every pick count, so the risk sets how the return is spread across hit counts.
3. Set the stake and play — the platform derives ten numbers from the committed server seed, your client seed and the current nonce (WTF draws its lightning number first).
4. Settle — your hits select the cell; the round credits floor(stake × cell) at the 8th decimal, × 10 on WTF when the lightning number is drawn. A zero cell pays 0. The nonce advances by 1.
5. Verify — after rotating your seed pair, recompute any round from the revealed seed (Section 6 walks it).
Win Conditions
One rule for every risk level — hits = |picks ∩ drawn|, and the cell for (risk, picks, hits) is the multiplier. The chance of each hit count depends only on how many numbers you pick; for 3 picks, with the Classic row:
| Hits | Exact chance | Classic / 3 pays |
|---|---|---|
| 0 | 203/494 · 41.0931% | 0× |
| 1 | 435/988 · 44.0283% | 1× |
| 2 | 135/988 · 13.6640% | 3.1× |
| 3 | 3/247 · 1.2146% | 10.4× |
16.5× (bet E5tGJUmA…).Risk vs Reward
Every risk level returns close to the same 99% — the risk changes the shape of the payouts, never the fairness of the price:
- Classic — the gentlest spread: small multipliers from a single hit at low pick counts, top cell
100×(10 of 10). Proven RTP 98.9605%–99.0385% - Low — frequent small wins, a consolation cell on Low/1 (
0.7×on 0 hits), top cells1,000×at 9 and 10 picks. 98.9031%–99.0689% - Medium — fewer small wins and larger middle cells, a consolation cell on Medium/1 (
0.4×on 0 hits), top cells1,000×. 98.9236%–99.0316% - High — the return sits in the high-hit cells (3 picks pays only on 3 hits,
81.5×), top cells1,000×. 98.9058%–99.0083% - WTF — a leaner table plus the lightning number: a paying round pays × 10 when the lightning number is drawn, on average 1 round in 4. 98.9625%–99.0398%; the largest possible multiple is 10 ×
1,000×=10,000×(WTF/10) - Variance changes with the risk and pick count; the price does not: every one of the 50 settings returns between 98.9031% and 99.0689%
Parameters
| Parameter | Value | Notes |
|---|---|---|
| Board | 1 – 40 | 10 numbers drawn per round, all different, served in draw order |
| Picks | 1 – 10 | All ten pick counts captured — 300 rounds each |
| Risk levels | Classic · Low · Medium · High · WTF | All five captured — 600 rounds each |
| Settings | 50 | 5 risks × 10 pick counts, 60 rounds each in this capture; every one held to the RTP claim (S4.2) |
| Paytable | 325 cells | LIQD’s client table, read 16 October 2026 (E31); 218 cells reached in this capture |
| House edge | 0.9311% – 1.0969% | Per setting; LIQD states a flat 1.00% (E26), met within ±0.1 pp on all 50 |
| Seed epoch | 60 rounds | Nonces 0–59, then the pair rotates — the audit’s cadence, not a platform limit |
| Settlement | floor at 1e-8 | floor(stake × cell), × 10 on WTF lightning rounds — reproduced with zero tolerance |
| Stake captured | $0.10 | All 3,000 rounds, in USDC |
Seed Formats
The three verification inputs, exactly as they appear in LIQD’s panel and the round record:
| Seed Type | Format | Example | Purpose |
|---|---|---|---|
| Server seed | 32-char hex (16 bytes) | 92c36e93b39a5cde21d275e9747a5794 | HMAC key (decoded); committed via SHA-256 of the string before play |
| Client seed | free-form string | audit42a83c667988 | Player-editable entropy — mixed into every draw step |
| Nonce | integer from 0 (0–59 in this capture) | 14 | The round counter under the active pair |
Multiplier & Payout
The payout is a lookup — LIQD’s client paytable gives one multiplier per (risk, picks, hits) cell, and the chance of each hit count follows from the board alone:
P(k hits) = C(n, k) · C(40 − n, 10 − k) / C(40, 10)
m = table[risk][n][k] × 10 on WTF when the lightning number is drawn
payout = floor(stake × m) in 1e-8 units| Classic row | Hits → multiplier | Proven RTP |
|---|---|---|
| 1 pick | 1 → 3.96× | 99.0000% |
| 3 picks | 1 → 1× · 2 → 3.1× · 3 → 10.4× | 99.0182% |
| 5 picks (featured round) | 1 → 0.25× · 2 → 1.4× · 3 → 4.1× · 4 → 16.5× · 5 → 36× | 98.9858% |
| 10 picks | 3 → 1.4× · 4 → 2.25× · 5 → 4.5× · 6 → 8× · 7 → 17× · 8 → 50× · 9 → 80× · 10 → 100× | 99.0374% |
floor(stake × multiplier) in 1e-8 units with zero tolerance (Step 12) — the featured round paid $0.10 × 16.5 = $1.65000000.Why Provably Fair Matters▶
Traditional online casinos require players to trust that games are fair. Provably fair systems eliminate that requirement — three properties, all verified in this audit, make trust unnecessary:
- The casino commits to the inputs that determine a result before you play — the SHA-256 commitment locks the server seed while it is still secret
- A source of randomness exists that the casino cannot predict — your client seed, editable after the commitment is published
- Anyone can verify the outcome after the fact — the revealed seed plus public code reproduces every round exactly
High-Level Overview8 sections▶
The whole system in one pass — from commitment to payout, with the capture design that tested it.
Checklist Reference
Commit-Reveal
| Check | What it proves |
|---|---|
| Server seed hash shown before play | With the hash shown first, the casino cannot change the draw after you bet |
| SHA-256 of the revealed seed matches the commitment | The revealed seed is the one that was committed |
| Next epoch's hash pre-committed at rotation | The casino cannot mint a fresh seed in response to your play |
| One commitment per epoch, no mid-epoch switching | Every round is pinned to a single verified commitment |
Seed System
| Check | What it proves |
|---|---|
| Client seed is player-editable after the commitment exists | The operator cannot pre-evaluate seeds against yours |
| Client seed changes the draw when substituted | Your seed genuinely enters every derivation |
| Nonces run contiguously 0–59 in every epoch | No skipped round where a discarded outcome could hide |
RNG & Parity
| Check | What it proves |
|---|---|
Draw recomputes from (serverSeed, clientSeed, nonce) | No hidden inputs influence the numbers drawn |
| Ten distinct numbers, in served order, from a forward Fisher–Yates over 1–40 | Every captured draw is the shuffle the specification describes |
| Hits recounted from our own draw on 3,000 / 3,000 rounds | The game counts your hits the way its rules say |
Payout & RTP
| Check | What it proves |
|---|---|
| Every served multiplier equals its declared paytable cell | The table the game shows is the table that pays |
Σ P(k hits) × cell in exact fractions for all 50 settings | The RTP is an identity, not a sampled estimate |
| Closed-form hit law equals a draw-by-draw derivation, cell for cell | The probability side cannot share an arithmetic slip with the formula |
Integrity
| Check | What it proves |
|---|---|
| Zero cells pay exactly 0 | No round settles money against a losing draw |
| The draw takes no pick set or stake | Your choices cannot reach the numbers drawn |
| Dataset, journal and paytable SHA-256 pinned (a mismatch stops the suite); results pins scored in Step 23 | The evidence itself is tamper-evident |
High-Level Flow
One round of LIQD Keno, from commitment to settlement:
1. Commit — the server generates a 32-hex server seed and publishes SHA-256(utf8(seed)) as its commitment. You see only the hash.
2. Seed pair active — your client seed (platform default or your own) and a nonce starting at 0 pair with the committed seed.
3. Bet — you pick 1–10 numbers, a risk level and a stake. The ten numbers are already fixed by (serverSeed, clientSeed, nonce); WTF reads them one cursor later.
4. Draw — ten HMAC-SHA256 values drive a forward Fisher–Yates over 1–40; WTF first draws its lightning number on cursor 0 and takes the ten from cursors 1–10.
5. Settle — your hits select the paytable cell; the round credits floor(stake × cell), × 10 on WTF when the lightning number is drawn. The nonce increments by 1.
6. Rotate & verify — rotation reveals the outgoing seed (and pre-commits the next). Check the hash, recompute any round.
Provably Fair Model
The model splits control of the randomness. The operator controls the server seed but must commit to it before play; the player controls the client seed and can change it after seeing the commitment; the nonce is a public counter. Because the ten numbers are a pure function of those three inputs (WTF reads them one cursor later, after its lightning number) — and the picks and stake enter only afterwards, as the rule and the price — neither side can steer an outcome without the other side's input, and everything needed to check the result is disclosed by design.
Commit-Reveal Model
Three phases, each independently verified by this audit:
Commit phase
The server generates a 32-hex seed and publishes SHA-256 of that string (UTF-8) as its commitment. The raw seed stays secret. Verified for all 51 reveals — the 50 epochs and the pre-capture anchor (Step 1).
Bet phase
Rounds settle under the committed pair; the nonce increments from 0 (0–59 in every epoch of this capture). The draw is fixed per (seed, clientSeed, nonce) before the picks are applied. Verified by full recomputation (Step 6).
Reveal phase
Rotation reveals the outgoing seed and pre-commits the next epoch’s hash — the chain is consistent on 51 / 51 rotations, including the pre-capture link into epoch 0 (Step 2).
Verify phase
Anyone re-hashes the reveal against the commitment and recomputes every round it served — the panel, the ProvablyFair.org verifier, or the standalone scripts in S6 all work.
Player-Controlled Client Seed
The client seed is the mechanism that structurally contains server-seed cherry-picking — when it is unpredictable and set after the commitment — and this audit exercised the control live:
- Set by the player whenever the seed pair rotates — our capture submitted its own at all 51 rotations, each after the server commitment it pairs with was published
- Mixed into every HMAC message (
clientSeed:nonce:cursor) — it mathematically cannot be ignored - Every one of the 50 epochs ran on a fresh client seed from our own CSPRNG, set after LIQD had published the server commitment it pairs with — all 3,000 rounds recomputed exactly
- Sensitivity control: changing the client seed changed the drawn numbers on 2,400 / 2,400 recomputed Classic–High rounds (Step 7’s seed controls)
Nonce Lifecycle
The nonce is the round counter under an active pair. In this capture the audit rotated the pair after every 60 rounds: the nonce starts at 0 and increments by 1 per round. A gap or reuse would be the fingerprint of a discarded outcome — the audit found neither, anywhere.
- Contiguous 0–59 in every one of the 50 epochs
- 0 gaps, 0 duplicates, 0 resets across 3,000 rounds
- Every rotation serves nonce 0 for the new pair — 51 / 51 (Step 2)
"epoch": 7,"nonceStart": 0,"nonceEnd": 59,"hashedServerSeed": "5989c035b566e46540f217cd7aba6bf0…"Determinism Guarantee
Determinism is what turns a fairness claim into a checkable fact: the same inputs must always produce the same draw. LIQD's derivation is a pure function of three arguments — verified by reproducing every captured draw, number for number and in order, with an independent implementation.
computeDraw(serverSeed, clientSeed, nonce) → [33, 35, 8, 10, 36, 39, 30, 4, 26, 40] // ten numbers in draw order — nothing else in, nothing else outPhase Map
The capture is engineered — Phase A places 60 rounds on every one of the 50 settings, one risk level per epoch and the pick count cycling 1–10 six times in every epoch:
| Risk | Rounds | What it isolates |
|---|---|---|
| Classic | 600 | Epochs 0–9 — every pick count on the base table, top cell 100× |
| Low | 600 | Epochs 10–19 — the frequent-win table, including the 0.7× consolation cell |
| Medium | 600 | Epochs 20–29 — larger middle cells, including the 0.4× consolation cell |
| High | 600 | Epochs 30–39 — the return concentrated in the high-hit cells |
| WTF | 600 | Epochs 40–49 — the lightning number, drawn on 125 rounds, and its × 10 settlement |
Technical Glossary7 categories▶
Every term used in this report, defined the way the audit uses it.
Core Concepts
| Term | Definition |
|---|---|
| Provably Fair | A cryptographic scheme in which the game outcome can be independently verified by the player after the fact, using seeds committed to before the round. The player cannot alter the server seed; the server cannot alter it after committing to its hash; neither party can change the draw without detection. |
| Commit-Reveal | The mechanism underlying provably fair. The server publishes the hash of its seed (a commitment) before any round, then reveals the plaintext serverSeed after the seed rotates. The auditor checks SHA-256(utf8(serverSeed)) == commitment for every epoch. All 51 revealed seeds in this dataset — the 50 epochs and the pre-capture anchor — passed. |
| House Edge | The operator’s cut. LIQD’s Keno page states “Edge: 1.00%” (E26), a flat 99% RTP. The effective edge is derived independently for each of the 50 settings from the exact hit probabilities and the declared paytable: 0.9311% – 1.0969%, every setting within 0.1 pp of the stated 1.00%. |
| RTP (Return to Player) | 1 − houseEdge. For LIQD Keno the RTP depends on the setting: Proven RTP 98.9031% – 99.0689% across the 50 settings, mean 98.9867% — exact fractions from the hit law and the declared paytable (Step 18), checked against 10M simulated rounds (Step 19). Live-capture return (136.94%) is informational only. |
| Anti-Circularity | The requirement that the RTP derivation and the simulation expectations take the hit probabilities from the board alone — C(n,k)·C(40−n,10−k)/C(40,10) — never from an operator-supplied odds or RTP field, so the RTP claim cannot validate itself (Step 17). |
| RTP Claim Rule | RTP-CLAIM 1.2.0, part of the audit method: the RTP a casino publishes — on the game page, fairness page, help page, bet panel, rules or game settings — must hold at every setting to within ±0.1 percentage points. A house-side miss ends the verdict as RTP FIGURE OVERSTATED. LIQD’s published 1.00% edge holds on all 50 Keno settings; the closest is Low/5 at −0.096947 pp. |
Seed System
| Term | Definition |
|---|---|
| Server Seed | A 32-character (16-byte) hexadecimal string generated by LIQD. Kept secret during play, revealed after epoch rotation. Used as the HMAC key in its hex-decoded form: Buffer.from(serverSeed, 'hex'). |
| Commitment / Server Seed Hash | SHA-256(utf8(serverSeed hex string)) — the text-hash convention: LIQD hashes the UTF-8 bytes of the 32-character hex string, not the 16 decoded bytes. Published before any round under the pair. A revealed seed that fails this check would prove tampering. Distinct from the HMAC key, which uses the decoded bytes. |
| Client Seed | A string controlled by the player (or generated by the platform), mixed into the HMAC message as ${clientSeed}:${nonce}:${cursor}. Changing it changes every draw under the same server seed. When the player sets an unpredictable seed after the server-seed commitment is published, it is the primary structural defence against server-seed cherry-picking; a predictable seed does not deliver that protection. |
| Nonce | An incrementing integer identifying a round's position within an epoch, starting at 0. Part of the HMAC message. Every epoch in this dataset ran contiguous nonces 0–59 (60 rounds) — no gaps. |
| Epoch | The rounds played under one (serverSeed, clientSeed) pair. At epoch end the seed rotates and the previous server seed is revealed. This dataset covers 50 epochs of exactly 60 rounds (nonces 0–59), one risk level each. |
Cryptographic Functions
| Term | Definition |
|---|---|
| HMAC-SHA256 | Hash-based Message Authentication Code using SHA-256: a deterministic 256-bit (32-byte) output from a key and a message. Keno makes one call per draw step; the first 4 bytes of each digest, read as a big-endian uint32, drive that step. |
| Cursor | The third component of the HMAC message, one per draw step. Classic, Low, Medium and High draw on cursors 0–9; WTF spends cursor 0 on its lightning number and draws on cursors 1–10. |
| Fisher–Yates Draw | A forward partial Fisher–Yates over the board [1 … 40]: at step i (0–9), j = i + (value mod (40 − i)) and positions i and j swap; the first ten positions, in order, are the drawn numbers — ten different numbers, every ordered draw equally likely up to the modulo bias below. |
| Modulo Bias | value mod r for r = 40 … 31 favours the lowest 2³² mod r residues by at most 2.070 × 10⁻¹⁰ per draw. LIQD’s own code rejects that top tail instead (bias 0); the two rules can disagree only on a draw step that lands in the tail — 0 of the 30,600 draw steps in the scored rounds (Step 8). |
Verification Terms
| Term | Definition |
|---|---|
| Paytable Cell | table[risk][picks][hits] — the multiplier for one combination. The declared table has 325 cells across the 50 settings; the scored rounds reached 218 of them (Step 15). |
| Declared Paytable | LIQD’s own client paytable, read from the Keno page code of qa.liqd.com on 16 October 2026, saved byte for byte and pinned by SHA-256 (E31). The scored capture names it, and every served multiplier equals its cell (Step 11). |
| Draw Order | The order in which the ten numbers leave the shuffle. LIQD serves the drawn list in that order, and every comparison of a recomputed draw with a served round is order-sensitive — a stricter test than comparing sets. |
| Floor Settlement | floor(stake × multiplier) in exact integer arithmetic on 1e-8 units. Reproduces all 3,000 credited amounts with zero tolerance (Step 12). |
Game Mechanics
| Term | Definition |
|---|---|
| Picks | The 1 to 10 numbers the player selects on the 1–40 board. The pick count and the risk choose the paytable row; the pick set itself never enters the draw — 58–60 distinct pick sets per epoch all recompute under one seed pair (Step 10). |
| Hits | |picks ∩ drawn| — how many picks are among the ten drawn numbers. The served count equals the recount from our own draw on 3,000 / 3,000 rounds (Step 9). |
Risk Level (mode) | One of five paytables — Classic, Low, Medium, High and WTF — each with a row for every pick count. All five were captured at volume, 600 rounds each. |
| Setting | A (risk, picks) pair: 5 × 10 = 50 settings, each with its own exact RTP. The capture placed 60 rounds on every setting (Step 15). |
| WTF & Lightning Number | WTF spends HMAC cursor 0 on a lightning number in 1–40, then draws the ten numbers from cursors 1–10. A paying WTF round pays 10 × its cell when the lightning number is among the ten drawn — on average 1 round in 4. In this capture it was drawn on 125 of the 600 WTF rounds; 16 of the 44 paying rounds paid × 10. |
| Consolation Cell | A cell that pays on 0 hits: Low/1 pays 0.7× and Medium/1 pays 0.4× when the single pick is not drawn (Step 14). |
Audit Terms
| Term | Definition |
|---|---|
| Board Uniformity | A simulation test: how often each of the 40 numbers is drawn in a Pass 1 stream of 1,000,000 rounds, against an equal share, with a statistic corrected for drawing ten numbers without replacement (df 39). All 10 streams pass; the 3,000 captured draws give p = 0.7249 on the same test (Step 21). |
| Hit Law | The exact hypergeometric distribution of hits for a pick count, C(n,k)·C(40−n,10−k)/C(40,10). Each Pass 1 stream’s hit histogram is tested against it with χ² (smallest p = 0.0841); the captured rounds are fitted to it per pick count too (Step 21, informational). |
| Bonferroni Correction | The 30 Pass 1 screens — board, hit law and lag-1 on each of the 10 streams — each run at α/30 = 3.333 × 10⁻⁵, so the family has level α = 0.001. The smallest p-value of the 30 is 0.0288. |
| Served-Row Return | The Pass 2 cherry-pick statistic: each of the 40 Classic, Low, Medium and High epoch seeds is replayed for 10,000 nonces with its recorded client seed, and the return of the rounds LIQD actually served (nonces 0–59) is compared with a null that moves each window to later, never-served nonces (10,000 replicates). One-sided, failing below p = 0.001: p = 0.8754 on a fixed Classic/3 bet and p = 0.0711 on the served rows. |
| Cherry-Picking (Server-Seed) | The residual risk in any beacon-free commit-reveal scheme: an operator with foreknowledge of the upcoming client seed could pre-evaluate candidate server seeds and commit to an unfavourable one. The structural defence is a client seed set after the commitment; the statistical check is the served-row return in Pass 2, which detects an 11.43 pp house-favouring shift on a fixed Classic/3 bet at 80% power. |
| Selective Orphaning | A distinct residual channel: an operator observing a round server-side and orphaning or re-issuing an unfavourable result rather than surfacing it. In this capture every one of the 3,000 requests settled, with contiguous nonces in every epoch (Steps 4, 5); a signed server-side audit log closes the channel in general. |
| Hard Fail | Any FAIL on a scored step — a broken commitment or chain link, a draw, hit, multiplier or credit mismatch, an operator-side nonce gap, a population or artifact-integrity fault. A hard fail ends the verdict as NOT PROVABLY FAIR. None triggered: Passed 21/24, Hard fails 0. |
Data Formats
| Term | Definition |
|---|---|
drawn | The ten drawn numbers in draw order, from the settle response (outcome.drawn). Compared as an ordered list with our recomputation on all 3,000 rounds (Step 6). |
lightning | The WTF lightning number, 1–40, served with the round’s outcome; null on every Classic, Low, Medium and High round. Recomputed from cursor 0 on 600 / 600 WTF rounds. |
hits, multiplier, winningAmount | The served hit count, multiplier and credited amount. hits is recounted from our draw; multiplier equals the declared cell (× 10 on lightning rounds); winningAmount is floor(stake × multiplier) in 1e-8 units, 0 on a zero cell, and equals the wallet-denominated winningAmountWallet on all 3,000 rounds (Step 12). |
| Phase A | The scored capture: 3,000 rounds at $0.10 in 50 epochs of 60, one risk per epoch (Classic, Low, Medium, High and WTF, 10 epochs each), the pick count cycling 1–10 six times per epoch and each pick set chosen by our own CSPRNG. |
Seed, Nonce & Determinism
Can the casino change your outcome after you bet?
Every Keno round on LIQD is generated from three inputs: server seed, client seed, and nonce. LIQD commits to its server seed by publishing a SHA-256 hash before the seed is used — and pre-commits the next epoch’s hash at every rotation. After you rotate your seed, the server reveals the actual seed, and anyone can check it against the hash. A matching hash shows which seed goes with which rounds and in what order, and our capture records show each hash was published before its rounds — so the casino cannot change the draw after you bet.
What we verified
- Every revealed server seed hashes to the commitment in force before its reveal — 51 / 51, the 50 epochs and the pre-capture anchor
- The next server seed’s hash is pre-committed at every rotation — the chain is consistent on 51 / 51 rotations, including the pre-capture link into epoch 0
- Client seed is player-controlled and set after the server commitment
- Every epoch ran on a fresh client seed our capture generated and submitted at rotation — LIQD made the submitted seed active on 51 / 51 rotations
- Nonce increments by 1 per round with no gaps or duplicates across 50 seed epochs — nonces 0–59 in every one
- The ten numbers are fully determined by the seed inputs before your picks or stake are applied
- Identical inputs always produce the same draw — confirmed across all 3,000 rounds, every number in served order
- Your client seed is a genuine input — changing it changes the draw
What this means for you
- With the commitment shown first, the casino cannot change the draw after you bet
- You contribute randomness the server cannot predict or pre-select against
- Every round has its own derivation — the nonce guarantees no two rounds share the same inputs
- Any result can be independently verified using the public tools and repo
- Outcomes stay verifiable months later — the revealed seeds and the algorithm are all you need
- Server-seed cherry-picking is structurally contained — you can set or rotate your client seed after the commitment is published, so the operator cannot pre-evaluate seeds against it
- Every revealed server seed hashes to the commitment in force before its reveal — 51 / 51, the 50 epochs and the pre-capture anchor
- The next server seed’s hash is pre-committed at every rotation — the chain is consistent on 51 / 51 rotations, including the pre-capture link into epoch 0
- Client seed is player-controlled and set after the server commitment
- Every epoch ran on a fresh client seed our capture generated and submitted at rotation — LIQD made the submitted seed active on 51 / 51 rotations
- Nonce increments by 1 per round with no gaps or duplicates across 50 seed epochs — nonces 0–59 in every one
- The ten numbers are fully determined by the seed inputs before your picks or stake are applied
- Identical inputs always produce the same draw — confirmed across all 3,000 rounds, every number in served order
- Your client seed is a genuine input — changing it changes the draw
- With the commitment shown first, the casino cannot change the draw after you bet
- You contribute randomness the server cannot predict or pre-select against
- Every round has its own derivation — the nonce guarantees no two rounds share the same inputs
- Any result can be independently verified using the public tools and repo
- Outcomes stay verifiable months later — the revealed seeds and the algorithm are all you need
- Server-seed cherry-picking is structurally contained — you can set or rotate your client seed after the commitment is published, so the operator cannot pre-evaluate seeds against it
Seed hash integrityPass
Next-seed chainPass
One commitment per epochPass
Nonce sequencingPass
consumedNonce, the round record and the provablyFair block agree on 3,000 / 3,000 (Step 4)Client seed originPass
Client seed influencePass
clientSeed + "x") or an empty one changes the drawn numbers on 2,400 / 2,400 Classic–High rounds — Step 7’s seed controlsDeterministic outputPass
Draw and hit reproductionPass
All 51 revealed seeds match their commitments, and the next-seed chain is consistent across all 51 rotations, including the pre-capture link into epoch 0. Nonces run 0–59 in every epoch with no gaps. Outcomes are fully deterministic — the same server seed, client seed and nonce always produce the same ten numbers, in the same order. With each commitment shown before its rounds, as our capture records show, the casino cannot change your result after you bet.
| Check | Result | Evidence |
|---|---|---|
| Seed hash integrity | Pass | 51 / 51 revealed seeds hash to their commitments — SHA-256 of the seed text, rebuilt from the raw rotation responses, 0 mismatches (Step 1) |
| Next-seed chain | Pass | Consistent on 51 / 51 rotations, including the pre-capture commitment that links into epoch 0 (Step 2) |
| One commitment per epoch | Pass | All 60 rounds in each of 50 epochs carry their epoch’s commitment and client seed — 3,000 / 3,000, no mid-epoch switching (Step 3) |
| Nonce sequencing | Pass | Nonces 0–59 in every epoch: 0 gaps, 0 duplicates; consumedNonce, the round record and the provablyFair block agree on 3,000 / 3,000 (Step 4) |
| Client seed origin | Pass | Set after the server commitment — a fresh “audit”-prefixed seed from our own CSPRNG in each of the 50 epochs, all 50 distinct, submitted after LIQD had published the server commitment it pairs with (51 / 51 rotations) |
| Client seed influence | Pass | A wrong client seed (clientSeed + "x") or an empty one changes the drawn numbers on 2,400 / 2,400 Classic–High rounds — Step 7’s seed controls |
| Deterministic output | Pass | Same (serverSeed, clientSeed, nonce) always produces the same ten numbers — 3,000 / 3,000 drawn lists reproduced in served order (Step 6) |
| Draw and hit reproduction | Pass | 3,000 / 3,000 served drawn lists hold ten distinct numbers from 1 to 40, and the hits counted from our own draw equal the served hits on every round (Steps 6, 9) |
Seed hash integrityPass
Next-seed chainPass
One commitment per epochPass
Nonce sequencingPass
consumedNonce, the round record and the provablyFair block agree on 3,000 / 3,000 (Step 4)Client seed originPass
Client seed influencePass
clientSeed + "x") or an empty one changes the drawn numbers on 2,400 / 2,400 Classic–High rounds — Step 7’s seed controlsDeterministic outputPass
Draw and hit reproductionPass
All 51 revealed seeds match their commitments, and the next-seed chain is consistent across all 51 rotations, including the pre-capture link into epoch 0. Nonces run 0–59 in every epoch with no gaps. Outcomes are fully deterministic — the same server seed, client seed and nonce always produce the same ten numbers, in the same order. With each commitment shown before its rounds, as our capture records show, the casino cannot change your result after you bet.
How It Works — Seed, Nonce & Determinism7 sections
1.1Server Seed Commitment
Before any round is placed under a seed pair, LIQD shows the SHA-256 hash of a secret 32-character hex server seed as the active commitment (activeServerSeedHash; every round carries it as serverSeedHash). The commitment hashes the seed text as a UTF-8 string, not its 16 decoded bytes: SHA-256(utf8(serverSeed)). When the pair rotates, the server seed is revealed and anyone can recompute the hash. A match shows the revealed seed is the committed one: a substitute would need a second preimage of SHA-256, and would fail every recomputation that follows. Step 1 rebuilds every check from the raw rotation responses in the request journal, never from the verification fields the capture wrote itself.
/** * The published commitment for a server seed (spec §1.2): SHA-256 over the * seed's ASCII text (the 32 hex characters themselves), NOT over the 16 bytes * the hex encodes. Lowercase hex output. */export function commitHash(serverSeedHexString: string): string { return sha256Buffer(Buffer.from(serverSeedHexString, "ascii"));}Real Example from Live Data:
{ "epoch": 7, "phase": "A", "clientSeed": "audit42a83c667988", "hashedServerSeed": "5989c035b566e46540f217cd7aba6bf049c9dd393d1a1d1ceb7f0e5431a46793", "nextHashedServerSeed": "56bcd099abb57c68a09d75124dca3b7c48616e4b1009752fa48e722e5b73e2c5", "serverSeed": "92c36e93b39a5cde21d275e9747a5794", "nonceStart": 0, "nonceEnd": 59}Verification:
// verify-seed.js — check any revealed LIQD server seed against its commitmentconst { createHash } = require('node:crypto');const serverSeed = '92c36e93b39a5cde21d275e9747a5794'; // revealed 32-hex seed (epoch 7)const committed = '5989c035b566e46540f217cd7aba6bf049c9dd393d1a1d1ceb7f0e5431a46793';const recomputed = createHash('sha256').update(serverSeed, 'utf8').digest('hex');console.log(recomputed === committed ? 'MATCH — seed is the committed one' : 'MISMATCH');1.2Next-Seed Pre-Commitment Chain
Every rotation response publishes two hashes: the active commitment and nextServerSeedHash, the commitment of the seed that will follow. The nextServerSeedHash published before a rotation must equal the activeServerSeedHash after it, so the successor seed is fixed before the previous epoch ends. Step 2 rebuilds the chain from the request journal, in the order the responses arrived, and checks all 51 links — the 50 epoch rotations and the pre-capture anchor. The anchor: the active read at 13:04:04 UTC (commitment 98239c20…, next 6b556dcf…) and the rotation that revealed its seed both came before the first round at 13:04:18 UTC; the revealed seed hashes to that commitment, and its pre-committed next hash became epoch 0’s commitment. Every rotation response also serves nonce 0 for the new pair (51 / 51). A hash chain shows the values are consistent and fixes their order; it does not carry a date. Our capture records — the request journal, in the order the responses arrived — show each commitment was published before the rounds it covers.
export function p2One(v: V2Context, s: CaptureSpec): Part { const L = chain(v), ds = v.ds, J = v.journal; let linked = 0; const bad: string[] = []; for (const l of L) { if (l.nextBefore !== null && l.nextBefore === l.after.active) linked++; else bad.push(`rotation ${l.k}`); } const nonceZero = L.filter(l => l.after.nonce === s.nonceBase).length; // The anchor: an active read BEFORE any bet, then a rotation that reveals the seed behind the commitment that read showed. const firstBetAt = ds.bets.map(b => b.at).sort()[0]; const pre = J.pre; const anchorRot = L[0]; const anchorBeforeBets = !!pre && pre.at < firstBetAt && !!anchorRot && anchorRot.at < firstBetAt && anchorRot.closes === 'pre'; const anchorCommit = !!pre && !!anchorRot && commitHash(anchorRot.revealed) === pre.raw.activeServerSeedHash; const anchorLink = !!pre && !!anchorRot && pre.raw.nextServerSeedHash === anchorRot.after.active; // …1.3Hash Consistency Within an Epoch
Each round carries the commitment of the pair it was played under (serverSeedHash) and its client seed. Step 3 does not trust the capture’s own epoch tag: it places every round in the rotation interval its timestamp falls in, taken from the journal, and then checks that the round carries that interval’s commitment and client seed, that the raw provablyFair block of LIQD’s response says the same, and that the capture’s epoch tag agrees — 3,000 / 3,000 on every count. A second commitment inside one epoch would indicate a mid-epoch seed substitution — a critical commit-reveal violation. The capture never rotated while a round was in flight. What binds each round to its seed is the recomputation: the round’s commitment matches the reveal (Step 1), and the revealed seed reproduces every draw in the epoch (Step 6).
export function p3One(v: V2Context, s: CaptureSpec): Part { const E = epochsFromChain(v), ds = v.ds; let inEpoch = 0, csOk = 0, tagOk = 0, pfOk = 0; const bad: string[] = []; for (const b of ds.bets) { const e = E.find(x => x.openedAt <= b.at && (x.closedAt === null || b.at <= x.closedAt)); if (!e) { bad.push(`${b.betId} outside every rotation interval`); continue; } if (b.serverSeedHash === e.active) inEpoch++; else bad.push(`${b.betId} hash ${b.serverSeedHash.slice(0, 8)} ≠ epoch ${e.index} ${e.active.slice(0, 8)}`); if (b.clientSeed === e.clientSeed && b.roundClientSeed === e.clientSeed) csOk++; else bad.push(`${b.betId} client seed`); if (b.epoch === e.index) tagOk++; else bad.push(`${b.betId} tagged epoch ${b.epoch}, timestamp says ${e.index}`); if (b.raw?.provablyFair?.serverSeedHash === b.serverSeedHash && b.raw?.provablyFair?.clientSeed === b.clientSeed) pfOk++; else bad.push(`${b.betId} raw provablyFair`); } // …1.4Client Seed Origin & Control
Players control their client seed: it is set when the seed pair rotates, after the server-seed commitment for the coming epoch is already published, and it enters every HMAC-SHA256 computation — changing it produces a completely different draw. The server commits to its seed before the client seed for that epoch is set, which structurally contains cherry-picking when that seed is unpredictable: an operator that cannot know the upcoming client seed cannot pre-evaluate candidate server seeds against it. In this capture every one of the 50 epochs used our own “audit”-prefixed seed — "audit" + crypto.randomBytes(6).toString("hex"), 48 bits of fresh CSPRNG material each — submitted in the rotation that opened the epoch, and LIQD made the submitted seed active on all 51 rotations (the pre-capture anchor and the one closing each epoch; Step 7’s seed controls). One example per risk level:
audit30c06ab27bf2— epoch 0, Classicaudit974fa5e0e780— epoch 10, Lowaudit6a3912c79520— epoch 20, Mediumaudit332d56ae3677— epoch 30, Highauditaee3840b3554— epoch 40, WTF
audit42a83c667988 (epoch 7). Evidence: liqd-keno-capture-v2-2026-10-20T13-04-03-496Z.json1.5Nonce Incrementation
The nonce begins at 0 and increments by 1 for each round under the same seed pair — one nonce per round. The nonce resets to 0 when the player rotates their seed, starting a new epoch. In this audit we rotated every 60 rounds as our chosen sampling cadence (nonces 0–59 per epoch, 50 epochs); epoch length is not enforced by the casino — players can rotate at any time. Step 4 audits every epoch for gaps, reuse, and resets: a skipped nonce is where a discarded unfavourable outcome would hide, so the sequence must be contiguous. The epoch size is bound to the constant in src/pins.ts rather than read from the dataset header, so a withheld round cannot be absorbed by shrinking the declared epoch. The round’s nonce must also equal the consumedNonce LIQD reports, the round record’s own nonce and the nonce in the provablyFair block — 3,000 / 3,000 — and every rotation serves nonce 0 for the new pair (Step 2).
export function p4One(v: V2Context, s: CaptureSpec): Part { const ds = v.ds; const byHash = new Map<string, V2Bet[]>(); for (const b of ds.bets) (byHash.get(b.serverSeedHash) ?? byHash.set(b.serverSeedHash, []).get(b.serverSeedHash)!).push(b); let exactWindows = 0, fieldsAgree = 0; const bad: string[] = []; for (const [h, arr] of byHash) { const ns = arr.map(b => b.nonce).sort((a, b) => a - b); const want = Array.from({ length: s.epochSize }, (_, i) => s.nonceBase + i); if (JSON.stringify(ns) === JSON.stringify(want)) exactWindows++; else bad.push(`${h.slice(0, 8)} nonces ${ns[0]}…${ns[ns.length - 1]} (${ns.length})`); } for (const b of ds.bets) { if (b.nonce === b.consumedNonce && b.nonce === b.roundNonce && b.nonce === b.raw?.provablyFair?.nonce && b.nonce === b.raw?.consumedNonce) fieldsAgree++; else bad.push(`${b.betId} nonce fields disagree`); } // …1.6Deterministic Mapping
The RNG algorithm is fully deterministic: given the same server seed, client seed and nonce, computeDraw always produces the exact same ten numbers in the same order. It is a forward Fisher–Yates over the board 1–40, stopped after ten swaps: at step i (0–9) the HMAC-SHA256 value of cursor i, taken modulo 40 − i, picks which of the remaining numbers moves into position i, and the first ten positions are the draw (WTF reads cursors 1–10, after its lightning number on cursor 0 — see 2.7). No timing, no picks, no stake, no UI state enters the derivation. Our implementation, written from the specification, reproduced all 3,000 captured drawn lists number for number, in served order. The round below is the featured round used throughout this report — recompute it yourself with the snippet.
/** * Forward partial Fisher-Yates over 1..40 (spec §2.3), draw cursors starting * at c0: step i uses r = 40 - i and swaps a[i] with a[i + integer(c0+i, r)]. * Returns a[0..9] in that order (the served order). */function partialFisherYates(serverSeed: string, clientSeed: string, nonce: number, c0: number): number[] { const a: number[] = Array.from({ length: GRID }, (_, k) => k + 1); for (let i = 0; i < DRAWN; i++) { const r = DRAW_RANGES[i]; const j = i + generateProvablyFairNumber(serverSeed, clientSeed, nonce, c0 + i, r); const t = a[i]; a[i] = a[j]; a[j] = t; } return a.slice(0, DRAWN);} /** * Normal modes (classic/low/medium/high): no lightning, draw cursors start * at 0 (spec §2.2 else-branch, §2.3). Picks and stake never enter (spec §1.1). */export function computeDraw(serverSeed: string, clientSeed: string, nonce: number): number[] { return partialFisherYates(serverSeed, clientSeed, nonce, 0);}computeDraw and 600 / 600 WTF rounds under wtfDraw (Step 6). Zero mismatches, zero skipped. No external entropy is mixed into the Keno RNG.Real Round Verified:
{ "betId": "E5tGJUmA4gmbhcUreVar7", "phase": "A", "epoch": 7, "nonce": 14, "clientSeed": "audit42a83c667988", "mode": "classic", "picks": [5, 40, 4, 33, 10], "stake": "0.10000000", "drawn": [33, 35, 8, 10, 36, 39, 30, 4, 26, 40], "hits": 4, "multiplier": 16.5, "winningAmount": "1.65000000"}Verification:
// verify-keno.js — recompute a LIQD Keno draw from its seedsconst { createHmac } = require('node:crypto');function draw(seed, client, nonce, wtf = false) { const value = cursor => createHmac('sha256', Buffer.from(seed, 'hex')) .update(`${client}:${nonce}:${cursor}`).digest().readUInt32BE(0); const lightning = wtf ? value(0) % 40 + 1 : null; // WTF only: cursor 0 const board = Array.from({ length: 40 }, (_, k) => k + 1); for (let i = 0; i < 10; i++) { // forward Fisher–Yates const j = i + value((wtf ? 1 : 0) + i) % (40 - i); [board[i], board[j]] = [board[j], board[i]]; } return { drawn: board.slice(0, 10), lightning };}const { drawn } = draw('92c36e93b39a5cde21d275e9747a5794', 'audit42a83c667988', 14);console.log(drawn.join(', ')); // → 33, 35, 8, 10, 36, 39, 30, 4, 26, 40console.log([5, 40, 4, 33, 10].filter(p => drawn.includes(p)).length); // → 4 hits1.7Draw Reproduction, Hit Count & Client Seed Influence
Determinism covers the numbers; Steps 6 and 9 cover what LIQD did with them. Step 6 compares every served drawn list with our recomputation as an ordered list — 2,400 / 2,400 Classic, Low, Medium and High rounds under computeDraw, and 600 / 600 WTF rounds under the WTF rule, drawn list and lightning number together. Step 9 then checks each round itself: 3,000 / 3,000 served lists hold ten distinct numbers from 1 to 40, every pick set is legal, the served hits value equals |picks ∩ drawn|, and the hits counted from our own draw equal the served hits on every round; lightning is a number from 1 to 40 on every WTF round and null on every other. To confirm the client seed is a genuine input, Step 7 re-derives every Classic–High round with a wrong client seed (clientSeed + "x") and with an empty one. An unrelated draw repeats the same ten numbers about once in C(40,10) = 847,660,528 rounds, so each control’s gate is E + 5σ = 8.416 × 10⁻³ unchanged rounds; both controls leave 0 of 2,400 unchanged. The seed is demonstrably load-bearing.
// The controls run on the scored classic / low / medium / high rounds: their draw is the established function. const bets = S.bets.filter(b => !isWtf(b)); let wrongUnchanged = 0, emptyUnchanged = 0, reproduces = 0; for (const b of bets) { const ss = S.seedMap.get(b.serverSeedHash)!; const own = computeDraw(ss, b.clientSeed, b.nonce); if (sameDraw(own, b.drawn)) reproduces++; if (sameSet(computeDraw(ss, b.clientSeed + 'x', b.nonce), own)) wrongUnchanged++; if (sameSet(computeDraw(ss, '', b.nonce), own)) emptyUnchanged++; } const n = bets.length, E = n / Number(comb(GRID, DRAWN)), gate = E + 5 * Math.sqrt(E);Technical Evidence & Verification5 sections
1.8Evidence Coverage Summary7 checks
| Verification Area | Coverage | Result |
|---|---|---|
| Seed hash integrity (Step 1) | 51 / 51 revealed seeds hash-verified — 50 epochs and the pre-capture anchor | Pass |
| Next-seed pre-commitment chain (Step 2) | 51 / 51 rotations, including the pre-capture link into epoch 0 | Pass |
| Hash consistency (Step 3) | 50 / 50 epochs (60 rounds each); 3,000 / 3,000 rounds placed by timestamp | Pass |
| Nonce continuity (Step 4) | 0 gaps, 0 duplicates across 50 epochs; nonce fields agree on 3,000 / 3,000 | Pass |
| Draw recomputation (Step 6) | 3,000 / 3,000 drawn lists in served order, with our own implementation | Pass |
| Draw structure & hit counting (Step 9) | 3,000 / 3,000 — ten distinct numbers from 1 to 40; hits recounted from our own draw | Pass |
| Client seed influence (Step 7’s seed controls) | Wrong and empty client seeds change the draw on 2,400 / 2,400 rounds | Pass |
1.9Code References7 source files
| File | Purpose |
|---|---|
| tests/verify.ts | 24-step verification pipeline (Steps 1–4, 6 and 9, with Step 7’s seed controls, cover S1) |
| tests/steps/commitment.ts | Steps 1–5: commit-reveal integrity, nonce continuity and population binding |
| tests/steps/determinism.ts | Steps 6–10: draw recomputation, client-seed controls, bias bound, structure & hits, pick-set independence |
| tests/steps/primary.ts | The capture-scoring part of each step — the chain rebuilt from the journal’s raw rotation responses |
| src/rng.ts | HMAC-SHA256 draw (commitHash, computeDraw, wtfDraw, countHits) — written from docs/ALGORITHM.md |
| src/loader.ts | Dataset, journal and paytable loading + SHA-256 hash guard |
| capture/capture-keno-v2.mjs | The capture script: pre-capture anchor, 60-round epochs, CSPRNG client seeds, a journal of every raw response; the session cookie is never written |
1.10Datasets UsedSource artifacts
| Property | Value |
|---|---|
| Source | Live Keno game data from LIQD’s pre-launch environment (qa.liqd.com) — 3,000 real rounds, all five risk levels, USDC |
| Capture environment | Pre-launch host qa.liqd.com · one authenticated account · one session |
| Total Records | 3,000 rounds across 50 epochs (60 rounds each) |
| Captured | 20 October 2026 (13:04–13:32 UTC) |
| SHA-256 | 7744010540dedbc9fe996a5cf9a75d4ee4a6007b08e56c2e7b9b9053b9831b1e |
| Request journal | 6,103 records — a header record, the pre-capture read, 51 raw rotation responses, 50 epoch opens, 3,000 intents and 3,000 settlements · SHA-256 8f52aceba87bba7a86e9d3651e9f3a96b6597736eb3294f69cb44aaa9f30db76 |
Fields used: serverSeed, hashedServerSeed, nextHashedServerSeed, serverSeedHash, clientSeed, nonce, consumedNonce, mode, picks, stake, drawn, hits, multiplier, winningAmount, winningAmountWallet, and from each raw response the provablyFair block and outcome.lightning; from the journal, every rotation’s activeServerSeedHash, nextServerSeedHash, revealedServerSeed, clientSeed and nonce
src/loader.ts recomputes the SHA-256 of the capture, its journal and the declared paytable, and aborts the suite before any step runs on a mismatch (Step 22). Every computational claim downstream of the dataset is reproducible from the file itself.
1.11Verified Invariants10 invariants
| Invariant | Result |
|---|---|
| SHA-256(utf8(serverSeed)) = the commitment in force before its reveal, for all 51 revealed seeds | Pass |
| Next-seed pre-commitment chain intact on all 51 rotations, anchored before the first round | Pass |
| Every round carries its epoch’s commitment and client seed — 3,000 / 3,000, placed by timestamp | Pass |
| Zero nonce gaps within any epoch (contiguous 0–59) | Pass |
| Zero nonce duplicates within any epoch | Pass |
| Every epoch contains exactly 60 rounds (50 / 50), bound to the code constant, not the dataset header | Pass |
| Every rotation serves nonce 0 for the new pair (51 / 51) | Pass |
| Same inputs produce the same ten numbers in the same order for all 3,000 rounds | Pass |
| Hits counted from our own draw equal the served hits on all 3,000 rounds | Pass |
| A wrong or an empty client seed changes the drawn numbers on 2,400 / 2,400 Classic–High rounds | Pass |
1.12Reproduction InstructionsRun locally
Clone the repository, install dependencies, and run the verification suite:
git clone https://github.com/ProvablyFair-org/liqd-keno.gitcd liqd-keno && git checkout v1.1.0npm cinpm run verify# Expected output: Steps 1–4, 6 and 9 PASSSteps 1–4, 6 and 9 cover the Seed, Nonce & Determinism checks. Expected output:
[PASS] Step 1: Seed hash integrity
[PASS] Step 2: Forward commitment chain
[PASS] Step 3: Hash consistency within epoch
[PASS] Step 4: Nonce continuity
[PASS] Step 6: Outcome recomputation (drawn list)
[PASS] Step 9: Draw structure & hit countingRNG & Entropy Model
Is the randomness genuinely random, or could it be rigged?
This section verifies that LIQD’s Keno random number generation produces cryptographically sound, unbiased outputs using only the disclosed inputs. Each round makes ten HMAC-SHA256 draws — one per cursor, the first 4 bytes of each digest taken modulo the count of numbers still on the board — and a forward Fisher–Yates shuffle turns them into ten distinct numbers from 1 to 40; WTF adds one more draw, its lightning number. The implementation is our own, written from a specification without LIQD’s code; it reproduces all 3,000 live draws, and LIQD’s own verifier serves only as a cross-check.
What we verified
- HMAC-SHA256 produces cryptographically sound, unpredictable output for each draw step
- Only disclosed inputs affect outcomes — no timestamps, no server-side state, no hidden entropy
- The modulo step’s bias is at most 2.070 × 10⁻¹⁰ per draw, and no captured round came near the values where it could matter
- The RNG is our own implementation, written from the specification — it reproduces all 3,000 captured draws, in order
- Board uniformity, the hit law and serial independence pass their screens across 10M simulated rounds
- A changed client seed changes the draw — 2,400 / 2,400 rounds under both seed controls
What this means for you
- Each draw is generated fairly and cannot be skewed
- All 40 numbers are equally likely to be drawn — no number is favoured
- No hidden randomness or server-side tricks influence the draw
- Consecutive rounds are not correlated — past results don’t affect future outcomes
- The algorithm depends only on seeds you can verify
- HMAC-SHA256 produces cryptographically sound, unpredictable output for each draw step
- Only disclosed inputs affect outcomes — no timestamps, no server-side state, no hidden entropy
- The modulo step’s bias is at most 2.070 × 10⁻¹⁰ per draw, and no captured round came near the values where it could matter
- The RNG is our own implementation, written from the specification — it reproduces all 3,000 captured draws, in order
- Board uniformity, the hit law and serial independence pass their screens across 10M simulated rounds
- A changed client seed changes the draw — 2,400 / 2,400 rounds under both seed controls
- Each draw is generated fairly and cannot be skewed
- All 40 numbers are equally likely to be drawn — no number is favoured
- No hidden randomness or server-side tricks influence the draw
- Consecutive rounds are not correlated — past results don’t affect future outcomes
- The algorithm depends only on seeds you can verify
RNG derived only from disclosed inputsPass
Entropy purityPass
Our own implementationPass
docs/ALGORITHM.md) by an implementer who never opened LIQD’s code; its 96 specification checks pass and it reproduces all 3,000 draws in served order (Step 6)LIQD verifier cross-checkPass
Draw bias boundPass
Key encoding verifiedPass
Serial independencePass
Client seed influencePass
The Keno RNG uses only the disclosed inputs, and our own implementation — written from the specification — reproduces every captured draw, number for number and in order. The simulated draws are consistent with a uniform board and the exact hit law across 10,000,000 rounds, and the serial screen finds no sign of dependence in any stream. The client seed is a genuine input — a changed seed alters the draw on every control round.
| Check | Result | Evidence |
|---|---|---|
| RNG derived only from disclosed inputs | Pass | HMAC-SHA256(serverSeed, clientSeed:nonce:cursor) — one value per draw step, ten per round (eleven on WTF), no hidden entropy |
| Entropy purity | Pass | No timestamps, external APIs, Math.random, or server-side state |
| Our own implementation | Pass | Written from the specification (docs/ALGORITHM.md) by an implementer who never opened LIQD’s code; its 96 specification checks pass and it reproduces all 3,000 draws in served order (Step 6) |
| LIQD verifier cross-check | Pass | LIQD’s verify endpoint, queried as-is, returns the served drawn list in order on 1,144 / 1,144 scored rounds — every WTF round and every observed Classic–High paytable cell (Step 24) — a cross-check, not the source |
| Draw bias bound | Pass | Plain modulo on ranges 40…31: worst bias 2.070 × 10⁻¹⁰ per draw, below 2⁻³²; 0 of the 30,600 captured draw steps fall where LIQD’s rejection rule would draw differently (Step 8) |
| Key encoding verified | Pass | Server seed hex-decoded to bytes for the HMAC key, confirmed by 3,000 / 3,000 recomputed rounds (Step 6); the commitment hashes the seed text, confirmed by 51 / 51 reveals (Step 1) |
| Serial independence | Pass | Lag-1 correlation of hit counts in each 1,000,000-round stream: |ρ| ≤ 0.0018, smallest p = 0.0724 — clear of the 3.333 × 10⁻⁵ gate on all 10 streams (Step 19) |
| Client seed influence | Pass | A wrong client seed or an empty one changes the drawn numbers on 2,400 / 2,400 Classic–High rounds — Step 7’s seed controls |
RNG derived only from disclosed inputsPass
Entropy purityPass
Our own implementationPass
docs/ALGORITHM.md) by an implementer who never opened LIQD’s code; its 96 specification checks pass and it reproduces all 3,000 draws in served order (Step 6)LIQD verifier cross-checkPass
Draw bias boundPass
Key encoding verifiedPass
Serial independencePass
Client seed influencePass
The Keno RNG uses only the disclosed inputs, and our own implementation — written from the specification — reproduces every captured draw, number for number and in order. The simulated draws are consistent with a uniform board and the exact hit law across 10,000,000 rounds, and the serial screen finds no sign of dependence in any stream. The client seed is a genuine input — a changed seed alters the draw on every control round.
How It Works — RNG & Entropy Model8 sections
2.1RNG Function Implementation
Each Keno round makes ten draws, one per HMAC cursor. An HMAC-SHA256 digest is computed with the hex-decoded server seed as key and clientSeed:nonce:cursor as the message; its first 4 bytes, read as a big-endian unsigned integer, give value(cursor), and value mod r picks a position among the r numbers still on the board. Cursor i (0–9) draws with r = 40 − i, so the ranges run 40, 39, … 31, and a forward Fisher–Yates swap moves the chosen number into position i; the first ten positions, in order, are the drawn list. WTF spends cursor 0 on its lightning number and draws on cursors 1–10 (2.7). The picks and stake never enter this function — they are applied to the drawn list afterwards.
| Component | Detail |
|---|---|
| Hash function | HMAC-SHA256 (32-byte digest) |
| Key | hex_decode(serverSeed) — 16 raw bytes (distinct from the commitment domain, which hashes the seed text) |
| Message | clientSeed:nonce:cursor — UTF-8, literal colons, no padding |
| Cursor schedule | Cursors 0–9 on Classic, Low, Medium and High; on WTF cursor 0 is the lightning number and the draw uses cursors 1–10 |
| Extraction | First 4 digest bytes → readUInt32BE → uint32; the specification reads no further bytes, and LIQD’s code takes the next 4 only on a rejection (2.3) |
| Reduction | uint32 mod r with r = 40 − i — ranges 40, 39, … 31 (and 40 for the lightning number) |
| Board | [1 … 40]; step i swaps position i with position i + (uint32 mod r) |
| Output order | Positions 0–9 after the ten swaps, in that order — the served drawn list, compared as an ordered list |
| Output | Ten distinct numbers per round (plus the lightning number on WTF) — the picks are compared with them afterwards |
const U32_SPACE = 0x1_0000_0000; // 2^32// …/** * value(cursor) (spec §2.1): HMAC-SHA256 keyed by the decoded seed over the * UTF-8 message, first 4 digest bytes read as an unsigned big-endian 32-bit * integer. The remaining 28 bytes are unused. */function cursorValue(serverSeed: string, clientSeed: string, nonce: number, cursor: number): number { const digest = createHmac("sha256", hmacKey(serverSeed)) .update(Buffer.from(hmacMessage(clientSeed, nonce, cursor), "utf8")) .digest(); return digest.readUInt32BE(0);}// …export function maxFairFor(range: number): number { assertRange(range); return U32_SPACE;} /** * integer(cursor, range) (spec §2.1): value(cursor) mod range, in [0, range). * One chunk per cursor; under the spec's rule it is always accepted, so the * "throw if none accepted" path is unreachable but kept as a guard. */export function generateProvablyFairNumber( serverSeed: string, clientSeed: string, nonce: number, cursor: number, range: number,): number { assertRange(range); const v = cursorValue(serverSeed, clientSeed, nonce, cursor); if (v >= maxFairFor(range)) { throw new Error(`no accepted value for cursor ${cursor}, range ${range}`); } return v % range;}docs/ALGORITHM.md) by an implementer who never opened LIQD’s code, its client bundle or any port of it, and the specification’s 96 vector and property checks pass (tests/keno/rngSpecTests.ts). LIQD’s own verify endpoint is queried as-is and used only as a cross-check — it returns the served drawn list on 1,144 / 1,144 scored rounds (Step 24).2.2Entropy Sources
All randomness derives exclusively from the deterministic HMAC-SHA256 function combining cleanly separated inputs — each visible in the fairness panel or the round record:
| Source | Controlled By | Purpose |
|---|---|---|
| Server Seed | Casino | Base randomness — the HMAC key, committed via SHA-256 hash of the seed text before play |
| Client Seed | Player | Player-contributed entropy — mixed into every HMAC message |
| Nonce | System | Uniqueness per round (0–59 within each epoch, audited gap-free) |
| Cursor | System | The draw step: 0–9, or 0 for the lightning number and 1–10 for the draw on WTF. An index, not an entropy source |
How we know: 3,000 / 3,000 rounds were recomputed using only the declared inputs. If any hidden entropy source existed, recomputation would fail. It does not.
Verified absent: No timestamps, no Math.random(), no external APIs, no server-side mutable state, and no external beacon (no drand round, block hash, or VRF). Only: HMAC-SHA256(hexDecode(serverSeed), clientSeed:nonce:cursor). Because no external entropy is used, cherry-pick containment is structural rather than cryptographic — the commitment plus the player-controlled client seed carry the fairness; S4 covers exactly what that means and how it is checked.
2.3Modulo Bias Analysis
Each draw step takes uint32 mod r for r from 40 down to 31. Modulo bias occurs because 2^32 is not evenly divisible by most of these ranges — for r = 40 the 32-bit space holds 107,374,182 complete cycles plus a remainder of 16 values, so the residues 0–15 are each reachable one extra time. Our specification takes plain modulo, which leaves a bias far below any measurable level; LIQD’s own code instead rejects any value at or above floor(2^32 / r) × r and takes the next 4 bytes, which makes the bias exactly 0. The two rules can draw differently only when a step’s value lands in that top zone:
range r 2^32 mod r worst bias per draw (plain mod)
40 16 1.397e-10
39 22 1.313e-10
38 6 1.961e-10
37 7 1.888e-10
36 4 2.070e-10 ← worst
35 11 1.597e-10
34 18 1.233e-10
33 4 2.046e-10
32 0 0 (exact)
31 4 2.028e-10
40 16 1.397e-10 ← WTF lightning, cursor 0
2^-32 for comparison 2.328e-10
expected zone steps = 92 / 2^32 ≈ 2.142e-8 per round (108 / 2^32 ≈ 2.515e-8 on WTF)
a different round ≈ 1 in 4.8 × 10^7 (1 in 4.1 × 10^7 on WTF)
zone steps observed = 0 of 30,600 (24,000 Classic–High + 6,600 WTF incl. the lightning step)The bound is computed in exact integer arithmetic from the ranges the draw uses (src/bias.ts), so the figures describe the code that produced the draws: every range’s worst bias stays below 2⁻³², and Step 8 fails the audit if one does not. Step 8 also counts, over every scored round, the steps whose value falls in the zone where plain modulo and LIQD’s rejection rule part ways: 0 of 24,000 Classic–High steps (expected 5.141 × 10⁻⁵) and 0 of 6,600 WTF steps including the lightning step (expected 1.509 × 10⁻⁵). With no zone step in the capture, the two rules give the same draw on every scored round.
2.4RNG Isolation & Pick-Set Independence
Each round uses a unique nonce in the HMAC message (clientSeed:nonce:cursor), ensuring per-round outputs are cryptographically independent. HMAC-SHA256 is a pseudorandom function — knowing one round’s draw gives no usable information about any other round under the same seed pair. There is no shared state between rounds or between epochs, and the picks never meet the RNG: the ten numbers are produced first, in isolation, and the picks are compared with them afterwards. WTF is the one risk level that changes the cursor schedule — it spends cursor 0 on its lightning number, so a WTF round draws from cursors 1–10 — but even there the pick set and the stake never enter.
computeDraw takes three arguments and names no pick, risk or stake field; 40 / 40 Classic–High epochs changed the pick set from round to round under one seed pair (58–60 distinct pick sets per epoch), and all 2,400 of their rounds recompute by the same picks-free function (Step 10).Evidence: The computeDraw implementation in 1.6 confirms this — it is a pure function with no class state, no external calls, and no cross-round memory. It takes exactly three arguments (serverSeed, clientSeed, nonce) — no picks, no risk, no stake, no timestamp, no account identifier — and returns ten numbers. Same inputs always produce the same output; if any hidden input influenced the draw, the reference implementation (which lacks that input) could not reproduce 3,000 / 3,000 rounds. The strongest in-capture evidence: every epoch changes the pick count and the pick set from round to round — 58–60 distinct pick sets per epoch under one seed pair — and every draw still recomputes.
2.5Monte Carlo Simulation (10M Rounds)
A 10,000,000-round Monte Carlo simulation checks the algorithm’s distribution at scale. It runs ten streams of 1,000,000 rounds, one per pick count (pick set 1…n), on seed pairs derived from a pinned master seed — independent of LIQD’s seeds — so the whole experiment replays byte for byte: npm run test:full regenerates the committed artifact and compares it field by field, and every verify replays the first 50,000 rounds of each stream exactly. computeDraw takes no pick, risk or stake argument (Step 10), so the four standard risk levels of a pick count share one stream’s draws, and WTF’s draw — the same shuffle, one cursor later — follows the same hit law: each stream prices every risk level’s row, WTF’s before its × 10. Each stream records integer counts only — how often each of the 40 numbers was drawn, the hit histogram and the sums for the serial screen — and Step 19 re-derives every statistic from those integers.
| Metric | Value |
|---|---|
| Rounds | 10,000,000 — 10 streams × 1,000,000, one per pick count |
| Seeds | Our own, from the pinned master seed b5f1c0de6b656e6f2d706173732d3120 — replayable byte for byte |
| Replay on every verify | The first 50,000 rounds of all 10 streams reproduce the recorded census exactly |
| Board uniformity (df 39) | 10 / 10 streams pass; smallest p = 0.0288 (1 pick) |
| Hit law (χ², exact probabilities) | 10 / 10 streams pass; smallest p = 0.0841 (1 pick) |
| Serial screen (lag-1 of hit counts) | 10 / 10 streams pass; |ρ| ≤ 0.0018, smallest p = 0.0724 |
| Picks | Board uniformity p | Hit law χ² p | Lag-1 ρ (p) |
|---|---|---|---|
| 1 | 0.0288 | 0.0841 | −0.00180 (0.0724) |
| 2 | 0.6442 | 0.8531 | 0.00103 (0.3009) |
| 3 | 0.2241 | 0.9181 | −0.00067 (0.5042) |
| 4 | 0.6905 | 0.2310 | 0.00035 (0.7275) |
| 5 | 0.3409 | 0.2175 | 0.00064 (0.5221) |
| 6 | 0.2944 | 0.7791 | −0.00118 (0.2382) |
| 7 | 0.6026 | 0.9334 | 0.00005 (0.9564) |
| 8 | 0.4811 | 0.5592 | −0.00065 (0.5129) |
| 9 | 0.6677 | 0.5674 | −0.00099 (0.3202) |
| 10 | 0.0911 | 0.9388 | 0.00075 (0.4508) |
export const PASS1_MASTER_SEED = 'b5f1c0de6b656e6f2d706173732d3120';export const PASS1_ROUNDS = 1_000_000;export const PASS1_PREFIX = 50_000;export const PASS1_PICK_COUNTS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];// …export function pass1Seeds(picks: number): { serverSeed: string; clientSeed: string } { return { serverSeed: deriveSeedHex(PASS1_MASTER_SEED, 'pass1-server', picks), clientSeed: deriveSeedHex(PASS1_MASTER_SEED, 'pass1-client', picks) };}All thirty screens pass. Each stream gets three tests — board uniformity (the 40 number counts against an equal share, with a statistic corrected for drawing ten numbers without replacement, df 39), the hit law (the hit histogram against the exact hypergeometric probabilities, χ²) and the lag-1 correlation of consecutive hit counts — each judged against α / 30 = 3.333 × 10⁻⁵, so the family of 30 has level α = 0.001. The smallest of the 30 p-values is 0.0288.
Methodology: a Keno round draws ten numbers without replacement, so the 40 counts are negatively correlated and the plain χ² has mean 40 − 10 = 30 rather than 39; multiplying it by (40 − 1) / (40 − 10) restores the χ²(39) reference. The hit law is the direct test of what the player experiences — how many of their picks come up — and it is checked against exact probabilities, never against an estimate. The RTP convergence of every paytable row, priced from the same streams, is reported in S4.5.
2.6Serial Independence
Serial independence ensures consecutive rounds are not correlated — a round with many hits does not make the next one more or less likely to hit. The screen runs on every Pass 1 stream, and the 3,000 captured rounds are fitted as well, informationally:
Lag-1 autocorrelation: The correlation between consecutive hit counts in each 1,000,000-round stream, computed from the integer sums the stream records (Σh, Σh², Σh_t·h_{t+1}), standardised as z = ρ · √n and judged two-sided at α / 30. All ten streams clear it: |ρ| ≤ 0.0018, smallest p = 0.0724 (Step 19).
Live-sample fit (informational): The 3,000 captured draws against a uniform board give corrected T = 33.346, df 39, p = 0.7249; the served hit counts fit the exact law at every pick count (p from 0.2100 to 0.9034, 300 rounds each); the WTF lightning number is uniform over 1–40 (χ² = 42.933, df 39, p = 0.3064) and lands in the drawn list on 125 of 600 rounds, against 1 in 4 expected (Step 21). These fits are reported for information rather than scored; the draw itself is proven round by round in Step 6.
/** Lag-1 correlation from integer sums: n, Σx, Σx², Σ x_t·x_{t+1} (n−1 pairs). */export function lag1(n: number, sx: number, sxx: number, sxy: number): number { const mean = sx / n, v = sxx / n - mean * mean; if (v <= 0 || n < 3) return 0; return (sxy / (n - 1) - mean * mean) / v;}2.7WTF Draw — the Lightning Number
WTF is the one risk level with an extra draw. Before the ten numbers, cursor 0 draws the lightning number from all 40: lightning = value(0) mod 40 + 1. The ten numbers then come from the same forward Fisher–Yates on cursors 1–10. Because the lightning number has its own cursor, it is independent of the draw and lands among the ten drawn numbers with probability 10 / 40 — the basis of WTF’s × 10 settlement in S4.8. LIQD publishes no written WTF specification, so our rule is checked two ways: it reproduces the served drawn list in order and the served lightning number on 600 / 600 scored WTF rounds, where the Classic schedule (cursors 0–9) reproduces none of them, and LIQD’s own verifier, asked about every one of the 600 WTF rounds, returns the same drawn list and lightning number on all 600 (Step 24). The capture’s largest win, traced:
bet f2t0JeOrZXSsgu0xyMvoQ · WTF · epoch 46, nonce 6
message audit14226566ad31:6:0
cursor 0 first 4 bytes d5d73553 → uint32 3,587,650,899
3,587,650,899 mod 40 = 19 → lightning = 20
cursors 1–10 drawn 13, 28, 12, 22, 11, 21, 7, 20, 4, 18
lightning 20 is drawn (position 8 of 10)
served drawn 13, 28, 12, 22, 11, 21, 7, 20, 4, 18 · lightning 20
LIQD verifier drawn 13, 28, 12, 22, 11, 21, 7, 20, 4, 18 · lightning 20/** * WTF mode (spec §2.2): lightning = integer(0, 40) + 1 from cursor 0, then the * same draw as §2.3 with cursors starting at 1. Lightning is independent of * the draw and may or may not land among the drawn numbers. */export function wtfDraw(serverSeed: string, clientSeed: string, nonce: number): { lightning: number; drawn: number[] } { const lightning = generateProvablyFairNumber(serverSeed, clientSeed, nonce, 0, GRID) + 1; const drawn = partialFisherYates(serverSeed, clientSeed, nonce, 1); return { lightning, drawn };}2.8Worked Example — Full Draw Trace
Real round from the dataset — bet E5tGJUmA4gmbhcUreVar7 (epoch 7, nonce 14), Classic, 5 picks. Verified from the scored capture. Ten HMAC digests, one per cursor; each step picks a position among the numbers still on the board and swaps it into place:
serverSeed = 92c36e93b39a5cde21d275e9747a5794 (HMAC key: its 16 decoded bytes)
clientSeed = audit42a83c667988
nonce = 14
risk = Classic → draw cursors 0–9
picks = 5, 40, 4, 33, 10
message = audit42a83c667988:14:<cursor>
digest (cursor 0) = d60f3c302a85be89f1e68b1e2c31b9a06cdd93178f3a3671e98ae74a31847824
board = [1, 2, …, 40]; step i takes the first 4 bytes of cursor i mod (40 − i)| cursor | range | first 4 bytes | uint32 | j′ = uint32 mod r | swap a[i] ↔ a[i + j′] | drawn[i] |
|---|---|---|---|---|---|---|
| 0 | 40 | d60f3c30 | 3,591,322,672 | 32 | 1 ↔ 33 | 33 |
| 1 | 39 | f7e85731 | 4,159,199,025 | 33 | 2 ↔ 35 | 35 |
| 2 | 38 | 92045e37 | 2,449,759,799 | 5 | 3 ↔ 8 | 8 |
| 3 | 37 | 75eb6d9c | 1,978,363,292 | 6 | 4 ↔ 10 | 10 |
| 4 | 36 | 3d5efad3 | 1,029,634,771 | 31 | 5 ↔ 36 | 36 |
| 5 | 35 | 3788994a | 931,699,018 | 33 | 6 ↔ 39 | 39 |
| 6 | 34 | 473b71cd | 1,195,078,093 | 23 | 7 ↔ 30 | 30 |
| 7 | 33 | 8726d4f1 | 2,267,469,041 | 2 | 3 ↔ 4 | 4 |
| 8 | 32 | cf341cf1 | 3,476,298,993 | 17 | 9 ↔ 26 | 26 |
| 9 | 31 | d28e55c9 | 3,532,543,433 | 30 | 3 ↔ 40 | 40 |
Technical Evidence & Verification5 sections
2.9Evidence Coverage Summary12 checks
| Verification Area | Coverage | Result |
|---|---|---|
| Our implementation (Step 6) | 3,000 / 3,000 rounds in served order — written from the specification; its 96 specification checks pass | Pass |
| LIQD verifier cross-check (Step 24) | 1,144 / 1,144 replies return the served drawn list — every WTF round and every observed Classic–High cell | Pass |
| Key encoding (hex vs UTF-8) | HMAC keys the decoded bytes (3,000 / 3,000 recomputed, Step 6); the commitment hashes the text (51 / 51 reveals, Step 1) | Pass |
| Draw bias bound (Step 8) | Worst 2.070 × 10⁻¹⁰ per draw, below 2⁻³² on every range; 0 of 30,600 captured steps in the rejection zone | Pass |
| External entropy non-participation (Step 6) | 3,000 / 3,000 reproduced from disclosed inputs alone | Pass |
| Pick-set independence (Step 10) | No pick, risk or stake argument; 58–60 pick sets per epoch, 2,400 / 2,400 recompute | Pass |
| Board uniformity (Step 19) | 10 / 10 streams of 1,000,000 rounds; smallest p = 0.0288 against 3.333 × 10⁻⁵ | Pass |
| Hit law (Step 19) | 10 / 10 streams fit the exact hypergeometric probabilities; smallest p = 0.0841 | Pass |
| Serial independence (Step 19) | Lag-1 of hit counts on 10 / 10 streams; |ρ| ≤ 0.0018, smallest p = 0.0724 | Pass |
| Live-sample fit (Step 21, informational) | The 3,000 captured draws: board p = 0.7249; hit law p 0.2100–0.9034; lightning p = 0.3064 | Pass |
| WTF lightning draw (Steps 6, 24) | 600 / 600 drawn lists and lightning numbers reproduced; LIQD’s verifier agrees on 600 / 600 | Pass |
| Client seed influence (Step 7’s seed controls) | Wrong and empty client seeds change the draw on 2,400 / 2,400 rounds | Pass |
2.10Code References10 source files
| File | Purpose |
|---|---|
| src/rng.ts | HMAC-SHA256 draw (commitHash, generateProvablyFairNumber, computeDraw, wtfDraw) — written from docs/ALGORITHM.md |
| docs/ALGORITHM.md | The specification the RNG was written from — inputs, commitment, derivation, the WTF lightning number, hits, payout and their sources |
| src/bias.ts | The draw-bias bound and the rejection-zone census over every scored round (Step 8) |
| src/sim.ts | Simulation kernels shared by the producer and the verifier — Pass 1 streams from the pinned master seed |
| src/simulate.ts | npm run simulate — produces the simulation artifact; --compare regenerates it and diffs it field by field |
| src/stats.ts | Board uniformity corrected for drawing without replacement, χ² fit (regularized incomplete gamma), lag-1 from integer sums |
| tests/steps/determinism.ts | Steps 6–10: draw recomputation, client-seed controls, bias bound, structure & hits, pick-set independence |
| tests/steps/simulation.ts | Steps 19–20: Pass 1 statistics re-derived from the artifact’s integer rows and the 50,000-round replay; Pass 2 |
| tests/keno/rngSpecTests.ts | The specification’s 96 vector and property checks |
| tests/keno/rngTests.ts | 12 unit tests — the commitment convention, draw order, client-seed sensitivity, the bias bound, hit counting and the WTF rule |
2.11Verified Invariants12 invariants
| Invariant | Result |
|---|---|
| HMAC-SHA256 output matches the live game for all 3,000 rounds — drawn list in order | Pass |
| Key is hex-decoded (16 bytes, not UTF-8) — wrong encoding produces wrong draws | Pass |
| Message format is clientSeed:nonce:cursor — literal colons, no padding | Pass |
| First 4 digest bytes, big-endian — one value per cursor | Pass |
| Ranges 40 … 31 in step order; forward Fisher–Yates over 1–40; output order = served order | Pass |
| Plain-modulo bias below 2⁻³² on every range (worst 2.070 × 10⁻¹⁰ per draw) | Pass |
| Zero rejection-zone steps across all 30,600 captured draw steps (expected 6.650 × 10⁻⁵) | Pass |
| No external entropy sources required for draw computation | Pass |
computeDraw takes no pick, risk or stake argument — 2,400 / 2,400 Classic–High rounds recompute by it | Pass |
| WTF: lightning from cursor 0, the draw from cursors 1–10 — 600 / 600 rounds reproduce | Pass |
| Board uniformity, hit law and lag-1 pass on all 10 simulated streams at α / 30 | Pass |
| A client seed change produces a different draw on 2,400 / 2,400 rounds under both controls | Pass |
2.12Datasets UsedSource artifacts
2.13Reproduction InstructionsRun locally
Clone the repository, install dependencies, and regenerate the simulation before verifying:
git clone https://github.com/ProvablyFair-org/liqd-keno.gitcd liqd-keno && git checkout v1.1.0npm cinpm run test:full # regenerates the 10M-round simulation from its pinned master seed, compares it, then verifiescat outputs/simulation-results.json | head -40S2-related steps:
[PASS] Step 6: Outcome recomputation (drawn list)
[PASS] Step 8: Draw bias bound
[PASS] Step 10: Pick-set independence
[PASS] Step 19: Simulation Pass 1 (fresh seeds)
[PASS] Step 21: Live-sample fit (informational)
[PASS] Step 24: Operator-verifier parity
The regenerated outputs/run/simulation-results.json matches the committed outputs/simulation-results.json field for field (SHA-256 a4a2b111…): REPRODUCED, with no difference even within tolerance.Verifier Parity
Does the live game actually follow its own rules?
This section validates that our implementation produces the exact same draw as the live game for every round — all ten numbers, in the order they were drawn. It also recounts every hit from the recomputed draw, matches every multiplier to its cell in LIQD’s declared paytable (× 10 when a WTF round’s lightning number is drawn), and confirms every credited amount to the last of its 8 decimals. Any mismatch would invalidate the fairness guarantee.
What we verified
- Every round independently recomputed from seeds — all ten drawn numbers in order, not just the payout
- Hits recounted from our own draw on every round; every multiplier equals its declared paytable cell (× 10 on the 16 paying WTF rounds whose lightning number was drawn)
- Payout correctness: all 3,000 credits equal floor(stake × multiplier) in integer 1e-8 units with zero tolerance; the 2,000 zero-cell rounds pay exactly 0
- LIQD’s own verifier agrees: 1,144 / 1,144 replies equal the served round, covering every WTF round and every observed paytable cell
- All 50 settings covered — 5 risk levels × 1–10 picks, 60 rounds each
What this means for you
- The verifier isn’t a simulation — it produces the exact same draw as the live game
- Every round you play can be independently recomputed by anyone
- No hidden logic alters the draw based on the numbers you pick
- The game LIQD served in the capture follows the specified algorithm and its published paytable exactly
- Every round independently recomputed from seeds — all ten drawn numbers in order, not just the payout
- Hits recounted from our own draw on every round; every multiplier equals its declared paytable cell (× 10 on the 16 paying WTF rounds whose lightning number was drawn)
- Payout correctness: all 3,000 credits equal floor(stake × multiplier) in integer 1e-8 units with zero tolerance; the 2,000 zero-cell rounds pay exactly 0
- LIQD’s own verifier agrees: 1,144 / 1,144 replies equal the served round, covering every WTF round and every observed paytable cell
- All 50 settings covered — 5 risk levels × 1–10 picks, 60 rounds each
- The verifier isn’t a simulation — it produces the exact same draw as the live game
- Every round you play can be independently recomputed by anyone
- No hidden logic alters the draw based on the numbers you pick
- The game LIQD served in the capture follows the specified algorithm and its published paytable exactly
Draw recomputationPass
computeDraw, 600 WTF rounds by wtfDraw with their lightning number (Step 6)Hit countingPass
hits equal |picks ∩ drawn| and the hits counted from our own draw; ten distinct numbers from 1 to 40 on every round (Step 9)Paytable cell matchPass
Payout arithmeticPass
winningAmount == floor(stake × cell) in integer 1e-8 units, × 10 on the 16 paying lightning rounds, with zero tolerance; 2,000 / 2,000 zero-cell rounds pay exactly 0 (Step 12)Wallet fieldsPass
stakeWallet and winningAmountWallet equal the game fields, and result follows the multiplier, on 3,000 / 3,000 rounds (Step 12)LIQD verifier parityPass
Configuration coveragePass
All 3,000 rounds matched our recomputation exactly — drawn numbers in order, lightning numbers, hits, multipliers and credited amounts, across all 50 settings. Every multiplier is the declared paytable cell — × 10 on the paying WTF rounds whose lightning number was drawn — and LIQD’s own verifier returns the same result on all 1,144 rounds it was asked about.
| Check | Result | Evidence |
|---|---|---|
| Draw recomputation | Pass | 3,000 / 3,000 drawn lists reproduced in served order — 2,400 Classic–High rounds by computeDraw, 600 WTF rounds by wtfDraw with their lightning number (Step 6) |
| Hit counting | Pass | 3,000 / 3,000 served hits equal |picks ∩ drawn| and the hits counted from our own draw; ten distinct numbers from 1 to 40 on every round (Step 9) |
| Paytable cell match | Pass | 2,400 / 2,400 Classic–High multipliers equal the declared cell exactly; on WTF 584 equal the cell and 16 exactly 10 × the cell, each of the 16 with its lightning number drawn (Step 11) |
| Payout arithmetic | Pass | All 3,000 credits: winningAmount == floor(stake × cell) in integer 1e-8 units, × 10 on the 16 paying lightning rounds, with zero tolerance; 2,000 / 2,000 zero-cell rounds pay exactly 0 (Step 12) |
| Wallet fields | Pass | stakeWallet and winningAmountWallet equal the game fields, and result follows the multiplier, on 3,000 / 3,000 rounds (Step 12) |
| LIQD verifier parity | Pass | 1,144 / 1,144 replies from LIQD’s own verifier equal the served round — drawn order, lightning, hits and multiplier — covering all 600 WTF rounds and all 174 observed Classic–High cells (Step 24) |
| Configuration coverage | Pass | 50 / 50 settings at exactly 60 rounds; 218 of the 325 paytable cells reached (Step 15) |
Draw recomputationPass
computeDraw, 600 WTF rounds by wtfDraw with their lightning number (Step 6)Hit countingPass
hits equal |picks ∩ drawn| and the hits counted from our own draw; ten distinct numbers from 1 to 40 on every round (Step 9)Paytable cell matchPass
Payout arithmeticPass
winningAmount == floor(stake × cell) in integer 1e-8 units, × 10 on the 16 paying lightning rounds, with zero tolerance; 2,000 / 2,000 zero-cell rounds pay exactly 0 (Step 12)Wallet fieldsPass
stakeWallet and winningAmountWallet equal the game fields, and result follows the multiplier, on 3,000 / 3,000 rounds (Step 12)LIQD verifier parityPass
Configuration coveragePass
All 3,000 rounds matched our recomputation exactly — drawn numbers in order, lightning numbers, hits, multipliers and credited amounts, across all 50 settings. Every multiplier is the declared paytable cell — × 10 on the paying WTF rounds whose lightning number was drawn — and LIQD’s own verifier returns the same result on all 1,144 rounds it was asked about.
How It Works — Verifier Parity9 sections
3.1Why Parity Matters
If the verifier produces results that differ from the live game, players cannot trust the verification — the entire provably fair system becomes meaningless. Commit-reveal shows the seed didn’t change; parity shows the game the player experienced is the game the seed dictates. For every captured round the audit rebuilds the drawn list from raw inputs with our own code, recounts the hits, looks up the multiplier in LIQD’s declared paytable and recomputes the credit — then compares all of it against what LIQD actually served and credited. 100% parity is required: a single mismatch anywhere in 3,000 rounds would indicate a bug in the verification logic, manipulation in the live game, or an inconsistent RNG implementation.
3.2Collection Design
The scored capture is one structured phase built for coverage: every risk level and every pick count, played at the same stake. Each risk level has its own ten seed epochs, and inside every epoch the pick count cycles 1–10 six times, so each of the 50 settings gets exactly 60 rounds. Pick sets are drawn at random for every round (58–60 distinct sets per epoch), and every epoch runs on a fresh client seed of our own.
| Phase | Rounds | Config | Bet Amount | Purpose |
|---|---|---|---|---|
| A — Classic | 600 | Classic, picks 1–10 × 60 rounds each (epochs 0–9) | $0.10 | Draw, hits, cell and payout on the 10 Classic settings |
| A — Low | 600 | Low, picks 1–10 × 60 rounds each (epochs 10–19) | $0.10 | The Low settings, including the Low / 1 consolation cell (0.7× on 0 hits) |
| A — Medium | 600 | Medium, picks 1–10 × 60 rounds each (epochs 20–29) | $0.10 | The Medium settings, including the Medium / 1 consolation cell (0.4× on 0 hits) |
| A — High | 600 | High, picks 1–10 × 60 rounds each (epochs 30–39) | $0.10 | The most volatile standard risk level — per-round variance up to 270.75 (High / 6) |
| A — WTF | 600 | WTF, picks 1–10 × 60 rounds each (epochs 40–49) | $0.10 | The lightning draw and its × 10 settlement on all 10 WTF settings |
3.3Draw Recomputation (Step 6)
For every round, the audit computes the drawn list as computeDraw(serverSeed, clientSeed, nonce) with the epoch’s revealed server seed — ten HMAC-SHA256 steps of a forward Fisher–Yates shuffle over 1–40 — and compares it with the served drawn as an ordered list: the same ten numbers in the same order, no tolerance. WTF rounds use wtfDraw, which spends cursor 0 on the lightning number and draws the ten numbers at cursors 1–10; there the served lightning number must match as well. Every revealed seed is used and every round is compared — coverage equals the population.
for (const b of ds.bets) { const ss = seedMap.get(b.serverSeedHash); if (ss === undefined) continue; recomputed.set(b.betId, computeDraw(ss, b.clientSeed, b.nonce)); if (b.mode === 'wtf') wtfRecomputed.set(b.betId, wtfDraw(ss, b.clientSeed, b.nonce)); }// … const w = wtfRecomputed.get(b.betId); if (w && sameDraw(w.drawn, b.drawn) && w.lightning === lightningOf(b)) variant++;// … const bets = S.bets.filter(b => !isWtf(b)); let match = 0; const bad: string[] = []; for (const b of bets) { const r = S.recomputed.get(b.betId); if (r && sameDraw(r, b.drawn)) match++; else bad.push(`${b.betId} (${b.mode}/${b.picks.length} epoch ${b.epoch} nonce ${b.nonce})`); }computeDraw and 600 / 600 WTF rounds under wtfDraw, lightning number included. Every computed list matches the dataset’s drawn field.3.4Paytable Cell & Multiplier (Step 11)
Keno has no multiplier formula: each (risk, picks, hits) cell of LIQD’s paytable is its own multiplier, so the table itself is the payout rule. The audit’s declared table is LIQD’s client paytable — the object literal shipped in the Keno page code of qa.liqd.com, read on 16 October 2026 and saved byte for byte (E31, SHA-256 767b7230…). It is parsed as data and never evaluated: 5 risk levels × 1–10 picks = 50 rows, 325 cells. The scored capture names the same file and hash in its own header as the table it was priced by, and that table prices 3,000 / 3,000 scored rounds. Step 11 looks up every round’s cell from our own hit count — never the served one — and compares the served multiplier with it as an exact rational: 2,400 / 2,400 Classic, Low, Medium and High rounds equal their cell exactly. On WTF, 584 / 600 equal the cell; the other 16 are paying rounds paid 10 × the cell — compared as served, within 1e-9 of 10 × the cell, and all 16 are exact — and each of them has its lightning number among the drawn, while none of the 28 other paying WTF rounds does. The × 10 factor is the settlement LIQD’s own verifier applies: its verify endpoint returns 10 × the cell on all 16 paying lightning rounds and the plain cell on the other 584 of the 600 scored WTF rounds (Step 24). No operator odds or probability field is read.
| Metric | Count |
|---|---|
Classic–High rounds where multiplier equals the declared cell, exactly | 2,400 / 2,400 |
WTF rounds where multiplier equals the declared cell | 584 / 600 |
| Paying WTF rounds paid 10 × the cell | 16 — lightning number drawn on 16 / 16 |
| Other paying WTF rounds with the lightning number drawn | 0 of 28 |
| Scored rounds priced by the declared table | 3,000 / 3,000 |
| Paytable cells reached by the capture | 218 of 325 (174 Classic–High, 44 WTF) |
export function priceWith(table: ClientTable, recomputed: Map<string, number[]>, wtfRecomputed: Map<string, { lightning: number; drawn: number[] }>, b: V2Bet) { if (isWtf(b)) { const w = wtfRecomputed.get(b.betId); const hits = w ? countHits(b.picks, w.drawn) : countHits(b.picks, b.drawn); const cell = clientCell(table, b.mode, b.picks.length, hits); const inDraw = !!w && w.drawn.includes(w.lightning); const factor = cell && cell.q.n > 0n && inDraw ? WTF_LIGHTNING_FACTOR : 1; return { hits, cell, factor, inDraw }; } const r = recomputed.get(b.betId); const hits = countHits(b.picks, r ?? b.drawn); return { hits, cell: clientCell(table, b.mode, b.picks.length, hits), factor: 1, inDraw: false };}// … if (!isWtf(b)) { if (eq(p.cell.q, decimalToQ(b.multiplier))) std++; else bad.push(`${tag}${b.betId} ${b.mode}/${b.picks.length}/${p.hits} served ${b.multiplier} declared ${p.cell.text}`); continue; } if (p.cell.q.n > 0n) paying++; if (eq(p.cell.q, decimalToQ(b.multiplier))) { plain++; if (p.cell.q.n > 0n && p.inDraw) plainPayingInDraw++; continue; } // … const ten = toNumber(mul(p.cell.q, q(BigInt(WTF_LIGHTNING_FACTOR)))); if (p.cell.q.n > 0n && Math.abs(b.multiplier - ten) <= 1e-9 * ten) { boosted++; if (!p.inDraw) boostedNotInDraw++; }3.5Payout Arithmetic & Wallet Fields (Step 12)
Step 12 replays the money in exact integer arithmetic on 1e-8 units: winningAmount must equal floor(stake × cell) — × 10 on a WTF lightning round — with zero tolerance, and a zero cell must pay exactly 0. At the $0.10 stake every product falls exactly on the 8-decimal grid (3,000 / 3,000), so no round needed rounding at all. The wallet-denominated settle fields stakeWallet and winningAmountWallet must equal the game fields, and result must read won exactly when the multiplier is above 0. Step 14 adds a bound on every round: no payout may exceed the stake times its row’s top cell (× 10 on WTF).
| Scope | Coverage | Verified |
|---|---|---|
Credit — winningAmount == floor(stake × cell), × 10 on lightning rounds, in 1e-8 units | 3,000 rounds | 3,000 / 3,000 |
Zero cells — winningAmount == 0 | 2,000 rounds | 2,000 / 2,000 |
Wallet fields — stakeWallet / winningAmountWallet equal the game fields | 3,000 rounds | 3,000 / 3,000 |
Label — result is won exactly when the multiplier is above 0 | 3,000 rounds | 3,000 / 3,000 |
| Bound — payout ≤ stake × the row’s top cell (× 10 on WTF), Step 14 | 3,000 rounds | 3,000 / 3,000 |
const p = priceWith(table, recomputed, wtfRecomputed, b); if (!p.cell) { bad.push(`${tag}no cell ${b.betId}`); continue; } const m = mul(p.cell.q, q(BigInt(p.factor))); const prod = mul(q(units8(b.stake)), m); // exact, in 1e-8 units const floor = prod.n / prod.d, rem = prod.n % prod.d; const round = rem * 2n >= prod.d ? floor + 1n : floor; const win = units8(b.winningAmount); if (rem === 0n) onGrid++; else { offGrid++; if (floor !== round) { decisive++; if (win === floor) floorOnDecisive++; } } if (win === floor) paid++; else bad.push(`${tag}${b.betId} stake ${b.stake} × ${p.cell.text}${p.factor > 1 ? ` × ${p.factor}` : ''} paid ${b.winningAmount}`); if (m.n === 0n) { zeroN++; if (win === 0n) zeroOk++; } if (b.stakeWallet === b.stake && b.winningAmountWallet === b.winningAmount) walletSame++; if (b.result === (m.n > 0n ? 'won' : 'lost')) labelOk++; else bad.push(`${tag}label ${b.betId} ${b.result}`);3.6Hit Counting & Draw Structure (Step 9)
Before a hit can be counted, the round itself must be well formed. Step 9 checks every served round: the drawn list holds ten distinct whole numbers from 1 to 40; the picks are a legal set of 1–10 distinct numbers from 1 to 40; the served hits value equals |picks ∩ drawn|; and the recorded fields equal the raw settlement response. It then counts the hits again from our own recomputed draw and requires it to equal the served count — 2,400 / 2,400 Classic–High rounds and 600 / 600 WTF rounds. lightning must be a whole number from 1 to 40 on every WTF round and null on every other round.
const legalDraw = (d: unknown) => Array.isArray(d) && d.length === DRAWN && new Set(d).size === DRAWN && d.every(x => Number.isInteger(x) && x >= INDEX_BASE && x < INDEX_BASE + GRID); const legalPicks = (p: unknown) => Array.isArray(p) && p.length >= PICKS_MIN && p.length <= PICKS_MAX && new Set(p).size === p.length && p.every(x => Number.isInteger(x) && x >= INDEX_BASE && x < INDEX_BASE + GRID);// …/** hits = |set(picks) intersect set(drawn)| (spec §2.4). */export function countHits(picks: readonly number[], drawn: readonly number[]): number { const d = new Set(drawn); let hits = 0; for (const p of new Set(picks)) if (d.has(p)) hits++; return hits;}lightning legal on 600 / 600 WTF rounds and null on the other 2,400 (Step 9).3.7LIQD Verifier Cross-Check (Step 24)
LIQD publishes its own verifier — the Verify tab inside the Keno game, backed by POST /api/v1/originals/keno/verify. On 21 October 2026 we called that endpoint for 1,144 scored rounds, with no bet placed, and stored every reply as received (E34). The rounds were chosen by a fixed rule: all 600 WTF rounds; every Classic–High round in the ten cells LIQD corrected (4.2; 74 rounds); the first, middle and last round of every other observed cell; and the first round of any epoch not yet covered — 544 Classic–High rounds in all (Classic 127, Low 137, Medium 151, High 129), covering all 174 observed Classic–High cells and all 40 Classic–High epochs. Each reply must carry the round’s own seeds, nonce, risk and picks and return the served drawn list in order, the lightning number, the hits and the multiplier. LIQD’s verifier is a cross-check, not the source: all 3,000 rounds are recomputed by our own implementation (Step 6).
| Check | Result |
|---|---|
| Replies stored as received, HTTP 200 — 1,144 distinct scored rounds | 1,144 / 1,144 |
| Request = the round’s revealed seeds, nonce, risk and picks | 1,144 / 1,144 |
| Drawn list, in order = served = our recomputation | 1,144 / 1,144 |
| Lightning number (WTF) · none on other rounds | 1,144 / 1,144 |
| Hits | 1,144 / 1,144 |
| Multiplier = served = the declared cell (× 10 on the 16 paying lightning rounds) | 1,144 / 1,144 |
| WTF rounds covered | 600 / 600 |
| Observed Classic–High cells covered | 174 / 174 |
| Classic–High epochs covered | 40 / 40 |
const rule = ss ? (isWtf ? wtfDraw(ss, b.clientSeed, b.nonce) : { lightning: null, drawn: computeDraw(ss, b.clientSeed, b.nonce) }) : null; const drawn = !!rule && sameList(o?.drawn, b.drawn) && sameList(o?.drawn, served?.drawn) && sameDraw(rule.drawn, o?.drawn ?? []); const lt = lightningOf(b); const lightning = isWtf ? (lt !== null && o?.lightning === lt && rule?.lightning === lt) : ((o?.lightning ?? null) === null && lt === null); const hits = !!row && !!o?.drawn && row.hits === b.hits && row.hits === served?.hits && countHits(b.picks, o.drawn) === row.hits; const multiplier = !!row && row.multiplier === b.multiplier && row.multiplier === served?.multiplier;3.8Bet-Size Invariance
In the specification, Keno’s draw cannot see your stake: computeDraw(serverSeed, clientSeed, nonce) takes three arguments and its body names no pick, risk or stake field (Step 10), and wtfDraw adds the lightning cursor and nothing else. LIQD’s declared paytable is keyed by risk, picks and hits only — it has no stake dimension, so it specifies the same multiplier at every stake — and in the specification the stake enters once, at settlement, as floor(stake × multiplier).
3.9Worked Example — Full Parity Verification
Real round from the scored capture — bet E5tGJUmA4gmbhcUreVar7 (epoch 7, nonce 14), Classic, 5 picks at $0.10 — the capture’s largest Classic payout. Verified from liqd-keno-capture-v2-2026-10-20T13-04-03-496Z.json — the same round LIQD’s own verifier reproduced in the Step 24 cross-check, traced cursor by cursor in S2.8:
serverSeed = 92c36e93b39a5cde21d275e9747a5794
clientSeed = audit42a83c667988
nonce = 14
risk = Classic · picks 5, 40, 4, 33, 10
betAmount = $0.10
recorded = drawn 33, 35, 8, 10, 36, 39, 30, 4, 26, 40
hits 4 · multiplier 16.5 · winningAmount 1.65000000
commitment = 5989c035b566e465…| Step | Process | Output |
|---|---|---|
| 1 | Recompute the commitment — SHA-256(utf8(serverSeed)) | Matches the pre-published hash |
| 2 | Run computeDraw(serverSeed, clientSeed, 14) | 33, 35, 8, 10, 36, 39, 30, 4, 26, 40 — the served list, in order |
| 3 | Count hits — picks ∩ drawn | 40, 4, 33, 10 → 4 hits — the served hits |
| 4 | Look up the declared cell — Classic, 5 picks, 4 hits | 16.5× — the served multiplier |
| 5 | Settle floor(0.10 × 16.5) in 1e-8 units | 1.65000000 — the credited winningAmount and its wallet field, verbatim |
sha256(utf8(serverSeed)) → 5989c035b566e465… = the published commitment ✓computeDraw(serverSeed, clientSeed, 14) → 33, 35, 8, 10, 36, 39, 30, 4, 26, 40 = the served list, same order ✓picks ∩ drawn = {40, 4, 33, 10} → 4 hits = served hits ✓table.classic[5][4] → 16.5 = the served multiplier ✓floor(10,000,000 × 16.5), 1e-8 units → 165,000,000 = 1.65000000 = winningAmount ✓Technical Evidence & Verification5 sections
3.10Evidence Coverage Summary9 checks
| Verification Area | Coverage | Result |
|---|---|---|
| Draw recomputation (Step 6) | 3,000 / 3,000 drawn lists in served order — 2,400 by computeDraw, 600 by wtfDraw with the lightning number | Pass |
| Draw structure & hit counting (Step 9) | 3,000 / 3,000 rounds well formed; hits recounted from our own draw | Pass |
| Pick-set independence (Step 10) | computeDraw takes (serverSeed, clientSeed, nonce) only — no pick, risk or stake field; 2,400 / 2,400 recompute | Pass |
| Paytable provenance & cell match (Step 11) | Declared table pinned by SHA-256 and named by the capture; 2,400 / 2,400 equal the cell; WTF 584 the cell + 16 × 10 with lightning drawn | Pass |
| Payout arithmetic & wallet fields (Step 12) | 3,000 / 3,000 credits = floor(stake × cell [× 10]) in 1e-8 units; 2,000 zero-cell rounds pay 0; wallet fields equal | Pass |
| Payout bounds (Step 14) | 3,000 / 3,000 payouts within stake × the row’s top cell (× 10 on WTF) | Pass |
| Configuration & cell coverage (Step 15) | 50 / 50 settings at exactly 60 rounds; 218 / 325 cells reached | Pass |
| Dataset & paytable pins (Step 22) | Capture, journal and declared table match their SHA-256 pins; the journal’s 3,000 settled bets equal the dataset’s, in order | Pass |
| LIQD verifier parity (Step 24) | 1,144 / 1,144 replies equal the served round; 600 / 600 WTF rounds; 174 / 174 observed Classic–High cells | Pass |
3.11Code References8 source files
| File | Purpose |
|---|---|
| tests/steps/determinism.ts | Steps 6–10: draw recomputation, client-seed controls, bias bound, structure & hits, pick-set independence |
| tests/steps/payouts.ts | Steps 11–16: paytable provenance, payout arithmetic, bet-size checks, payout bounds and coverage |
| tests/steps/primary.ts | The capture-scoring part of each step — cell lookup and classification (priceWith, classify) and the settlement in 1e-8 units (payouts) |
| tests/steps/standardization.ts | Steps 22–24: dataset and paytable pins, artifacts of record, operator-verifier parity |
| src/operator-verify-2026-10-21.ts | Step 24: every stored E34 reply checked against its served round — request, drawn order, lightning, hits, multiplier and the declared cell |
| src/client-paytable.ts | Parses LIQD’s client paytable literal as data, never evaluated (clientCell, clientRowQ) |
| src/rng.ts | computeDraw, wtfDraw, countHits and sameDraw — written from docs/ALGORITHM.md |
| src/v2.ts | Loads the scored capture and recomputes every round from its epoch’s revealed seed (deriveV2) |
3.12Datasets UsedSource artifacts
3.13Verified Invariants12 invariants
| Invariant | Result |
|---|---|
| Computed drawn list equals the served list, in order, for all 3,000 rounds | Pass |
| Computed lightning number equals the served one on all 600 WTF rounds; null on all 2,400 others | Pass |
| hits == |picks ∩ drawn|, counted from our own draw, for all 3,000 rounds | Pass |
| multiplier == the declared cell for all 2,400 Classic–High rounds; == the cell or exactly 10 × it on all 600 WTF rounds | Pass |
| × 10 paid exactly on the paying WTF rounds whose lightning number is drawn — 16 / 16, and on none of the 28 other paying WTF rounds | Pass |
| winningAmount == floor(stake × cell [× 10]) in integer 1e-8 units for all 3,000 rounds, zero tolerance | Pass |
| winningAmount == 0 for all 2,000 zero-cell rounds | Pass |
| Wallet settle fields equal the game fields on all 3,000 rounds | Pass |
| Every payout ≤ stake × its row’s top cell (× 10 on WTF) | Pass |
| No hidden inputs: the draw takes (serverSeed, clientSeed, nonce) only — no picks, no stake | Pass |
| LIQD’s verifier returns the served round on 1,144 / 1,144 calls | Pass |
| All 50 settings present at exactly 60 rounds | Pass |
3.14Reproduction InstructionsRun locally
Clone the repository, install dependencies, and run the verification suite:
git clone https://github.com/ProvablyFair-org/liqd-keno.gitcd liqd-keno && git checkout v1.1.0npm cinpm run verify# Expected output: Steps 6, 9, 10, 11, 12, 14, 15, 22 and 24 all PASSS3-related steps:
[PASS] Step 6: Outcome recomputation (drawn list)
[PASS] Step 9: Draw structure & hit counting
[PASS] Step 10: Pick-set independence
[PASS] Step 11: Paytable provenance & served-vs-declared
[PASS] Step 12: Payout arithmetic
[PASS] Step 14: Paytable shape & bounds
[PASS] Step 15: Configuration & cell coverage
[PASS] Step 22: Dataset & config pins
[PASS] Step 24: Operator-verifier parityRTP & Payout Logic
Is the house edge what the casino claims?
This section verifies that LIQD’s advertised 1.00% house edge holds on every one of Keno’s 50 settings — 5 risk levels × 1–10 picks, each with its own row of the paytable. The key test is anti-circularity: we derive every RTP from first principles, not from any casino-supplied figure — the chance of k hits is the hypergeometric C(n,k)·C(40−n,10−k)/C(40,10), counted from the 40-number board, and a setting’s RTP is the exact sum of those chances times LIQD’s declared cells. We hold LIQD’s 99% to all 50 settings under the RTP claim rule, compare them with 10 million simulated rounds, and test whether the casino pre-selected seeds against the player.
What we verified
- LIQD’s advertised 1.00% edge holds on all 50 settings — every exact RTP within ±0.1 pp of 99%, from 98.9031% (Low / 5) to 99.0689% (Low / 9)
- RTP proven from first principles: hypergeometric hit odds from the 40-number board × LIQD’s declared cells, in exact rationals — no casino probability used
- The hit law equals an independent draw-by-draw derivation, cell for cell, for every pick count (Step 17)
- WTF’s × 10 lightning settlement priced exactly: the lightning number lands among the ten drawn with probability 10/40, so every WTF row returns its table RTP × 13/4
- 10M simulated rounds: all 50 paytable rows within ±5 SE of their exact RTP, WTF’s before its × 10 (largest |z| 1.73)
- Cherry-pick test: the returns on the 40 Classic–High seeds LIQD served are consistent with chance — p = 0.8754 and 0.0711 against a 0.001 rule, no sign of seed selection
- Every payout is the stake times its declared cell — × 10 on a paying WTF round whose lightning number is drawn — settled to 8 decimals: 3,000 / 3,000 rounds
What this means for you
- The house edge on Keno is about 1%: between 0.93% and 1.10% depending on risk level and pick count
- The 99% LIQD advertises is what each setting returns, within a tenth of a point
- The RTP proof is derived independently — it doesn’t rely on trusting the casino
- The casino’s seeds show no sign of being chosen against the player
- You are paid exactly the paytable multiplier for your hits — × 10 in WTF when the lightning number is drawn
- WTF pays rarely but big — the × 10 lightning boost is built into its 99%
- LIQD’s advertised 1.00% edge holds on all 50 settings — every exact RTP within ±0.1 pp of 99%, from 98.9031% (Low / 5) to 99.0689% (Low / 9)
- RTP proven from first principles: hypergeometric hit odds from the 40-number board × LIQD’s declared cells, in exact rationals — no casino probability used
- The hit law equals an independent draw-by-draw derivation, cell for cell, for every pick count (Step 17)
- WTF’s × 10 lightning settlement priced exactly: the lightning number lands among the ten drawn with probability 10/40, so every WTF row returns its table RTP × 13/4
- 10M simulated rounds: all 50 paytable rows within ±5 SE of their exact RTP, WTF’s before its × 10 (largest |z| 1.73)
- Cherry-pick test: the returns on the 40 Classic–High seeds LIQD served are consistent with chance — p = 0.8754 and 0.0711 against a 0.001 rule, no sign of seed selection
- Every payout is the stake times its declared cell — × 10 on a paying WTF round whose lightning number is drawn — settled to 8 decimals: 3,000 / 3,000 rounds
- The house edge on Keno is about 1%: between 0.93% and 1.10% depending on risk level and pick count
- The 99% LIQD advertises is what each setting returns, within a tenth of a point
- The RTP proof is derived independently — it doesn’t rely on trusting the casino
- The casino’s seeds show no sign of being chosen against the player
- You are paid exactly the paytable multiplier for your hits — × 10 in WTF when the lightning number is drawn
- WTF pays rarely but big — the × 10 lightning boost is built into its 99%
Anti-circularityPass
RTP claim (RTP-CLAIM 1.2.0)Pass
Paytable provenancePass
Simulated RTP (Pass 1)Pass
Cherry-pick test (Pass 2)Pass
WTF lightning settlementPass
Paytable shape & coveragePass
Every one of Keno’s 50 settings returns between 98.903053% and 99.068864%, proven exactly from the hypergeometric hit law and LIQD’s declared paytable — mean 98.9867%, all 50 within ±0.1 pp of the advertised 99%. This is a first-principles proof, not a statistical estimate; 10M simulated rounds agree with it, the cherry-pick test finds no sign of seed selection, and every scored payout is the stake times its declared cell, × 10 on the paying WTF rounds whose lightning number was drawn. WTF’s × 10 lightning boost is priced into its 99% exactly.
| Check | Result | Evidence |
|---|---|---|
| Anti-circularity | Pass | P(k hits) = C(n,k)·C(40−n,10−k)/C(40,10) equals an independent draw-by-draw derivation cell for cell for n = 1–10, sums to exactly 1 with mean n·10/40; no casino probability exists or is read; both negative controls rejected (Step 17) |
| RTP claim (RTP-CLAIM 1.2.0) | Pass | LIQD shows “Edge: 1.00%” on the Keno game page; all 50 settings within ±0.1 pp of 99% — lowest Low / 5 at 98.903053% (−0.096947 pp), highest Low / 9 at 99.068864% (Step 18) |
| Paytable provenance | Pass | The declared table is LIQD’s client paytable, saved byte for byte and pinned by SHA-256 (325 cells); the capture names it as its own, and it prices 3,000 / 3,000 scored rounds (Step 11) |
| Simulated RTP (Pass 1) | Pass | 10 × 1M rounds — board uniformity, hit law and lag-1 clear on all 10 streams (smallest p 0.0288 against 3.333 × 10⁻⁵); 50 / 50 paytable rows within ±5 SE of their exact RTP (WTF’s before its × 10), largest |z| 1.73 (Step 19) |
| Cherry-pick test (Pass 2) | Pass | 40 Classic–High casino seeds × 10,000 nonces against 10,000 relocations: Classic / 3 p = 0.8754, every round as played p = 0.0711 — no significant return deficit (the rule fails below 0.001) (Step 20) |
| WTF lightning settlement | Pass | Lightning from its own HMAC cursor, drawn with probability 10/40 → every WTF row returns its table RTP × 13/4 (98.9625%–99.0398%); 16 / 16 × 10 rounds had the lightning drawn; LIQD’s verifier agrees on 600 / 600 (Steps 6, 11, 18, 24) |
| Paytable shape & coverage | Pass | Top cell 1,000× (10,000× with lightning); every payout within stake × its row’s top cell (3,000 / 3,000); two consolation cells and no other irregular cell; 50 / 50 settings at 60 rounds, 218 / 325 cells reached (Steps 14, 15) |
Anti-circularityPass
RTP claim (RTP-CLAIM 1.2.0)Pass
Paytable provenancePass
Simulated RTP (Pass 1)Pass
Cherry-pick test (Pass 2)Pass
WTF lightning settlementPass
Paytable shape & coveragePass
Every one of Keno’s 50 settings returns between 98.903053% and 99.068864%, proven exactly from the hypergeometric hit law and LIQD’s declared paytable — mean 98.9867%, all 50 within ±0.1 pp of the advertised 99%. This is a first-principles proof, not a statistical estimate; 10M simulated rounds agree with it, the cherry-pick test finds no sign of seed selection, and every scored payout is the stake times its declared cell, × 10 on the paying WTF rounds whose lightning number was drawn. WTF’s × 10 lightning boost is priced into its 99% exactly.
How It Works — RTP & Payout Logic11 sections
4.1Anti-Circularity Proof — Hypergeometric Hit Probability (Step 17)
The anti-circularity proof establishes each setting’s RTP from first principles without using any casino-supplied probability. In Keno the probability side is pure combinatorics: ten numbers are drawn from 40, so the chance that exactly k of your n picks are among them is the hypergeometric C(n,k)·C(40−n,10−k)/C(40,10) — computed from the board alone. Step 17 checks that closed form against an independent derivation that plays the ten draws out one number at a time (sequentialDraw), cell for cell in exact BigInt rationals, for every pick count from 1 to 10; each distribution must also sum to exactly 1 and have mean exactly n·10/40. Two negative controls prove the check can fail: moving 1/C(40,10) of probability from 0 hits to 1 hit is rejected, and nudging one declared cell by 0.01 changes the exact RTP. A setting’s RTP is then Σ P(k) × cell(k) over its row of LIQD’s declared paytable — the casino supplies the multipliers it pays, never a probability:
| Component | Formula | Source |
|---|---|---|
| Hit probability | P(k | n) = C(n,k)·C(40−n,10−k) / C(40,10) | Board arithmetic — computed by the audit |
| Independent check | sequentialDraw: ten draws without replacement, one number at a time | Equal to the closed form cell for cell for n = 1–10; sums to 1, mean n·10/40 (Step 17) |
| Multiplier | cell(k) — the declared paytable row for (risk, n) | LIQD’s client paytable, pinned by SHA-256 (Step 11) |
| RTP | Σ P(k | n) × cell(k), an exact rational | 50 / 50 settings equal outputs/rtp-claim-table.json (Step 18) |
| WTF | row RTP × 13/4 — × 10 when the lightning number is drawn, P = 10/40 | The lightning settlement (4.8) |
| Hits (Classic / 3 picks) | Exact P(k) | Cell | P(k) × cell |
|---|---|---|---|
| 0 | 203/494 = 41.0931% | 0× | 0 |
| 1 | 435/988 = 44.0283% | 1× | 435/988 |
| 2 | 135/988 = 13.6640% | 3.1× | 837/1976 |
| 3 | 3/247 = 1.2146% | 10.4× | 12/95 |
| Total | 1 | — | 9783/9880 = 99.018219% |
Anti-Circularity Verification:
/** Independent derivation: draw d numbers one at a time without replacement, tracking hits. */export function sequentialDraw(n: number, N = GRID, d = DRAWN): Q[] { let dist: Q[] = [q(1n)]; // dist[h] after 0 draws for (let step = 0; step < d; step++) { const remaining = BigInt(N - step); const next: Q[] = Array.from({ length: Math.min(n, step + 1) + 1 }, () => q(0n)); dist.forEach((p, h) => { if (p.n === 0n) return; const picksLeft = BigInt(n - h); const pHit = q(picksLeft, remaining), pMiss = q(remaining - picksLeft, remaining); if (picksLeft > 0n) next[h + 1] = add(next[h + 1], mul(p, pHit)); next[h] = add(next[h], mul(p, pMiss)); }); dist = next; } while (dist.length < n + 1) dist.push(q(0n)); return dist.slice(0, n + 1);}// …/** * The model check, as a function so the negative control can feed it a corrupted vector and show it * is rejected: a distribution must equal the independent sequential derivation cell for cell, sum to * exactly 1, and have mean exactly n·d/N. */export function modelHolds(n: number, probs: Q[]): boolean { const seq = sequentialDraw(n); if (probs.length !== seq.length || !probs.every((p, k) => eq(p, seq[k]))) return false; const total = probs.reduce((a, p) => add(a, p), q(0n)); const mean = probs.reduce((a, p, k) => add(a, mul(p, q(BigInt(k)))), q(0n)); return eq(total, q(1n)) && eq(mean, q(BigInt(n * DRAWN), BigInt(GRID)));}Why this proof is non-circular: P(k | n) comes from counting subsets of the 40-number board, and the cells are the multipliers LIQD’s client ships and the game pays — nothing in the sum is a casino-supplied probability, return figure or edge. What the RTP depends on is that every one of the C(40,10) = 847,660,528 possible draws is equally likely: Section 2 shows the draw is a uniform shuffle reproduced from the seeds, and Section 3 shows the multipliers paid are the declared cells. What the audit adds is evidence that LIQD’s implementation honours the table on every captured round (Steps 11, 12) and that the probability side is derived without touching a single operator-supplied number.
4.2RTP Claim Rule (Step 18)
The RTP claim rule (RTP-CLAIM 1.2.0) is part of the audit method: the RTP a casino publishes must hold at every setting the game offers. A published figure counts wherever a player can find it — on the game page, the fairness page, the help page, the bet panel, the rules, or in the game settings the site loads. Each setting’s RTP is computed exactly, as a rational number, from the hit law and its row of the declared paytable — never from the casino’s figure — and must sit within ±0.1 percentage points of the claim. A setting below that band on the house side is a miss: the verdict then reads RTP FIGURE OVERSTATED, even when every draw check passes. A setting above the claim never fails. Keno’s settings do not all land on exactly 99% — a row’s return weights its cells (3.1×, 1.86×, 16.5×…) by exact hit odds, and those sums rarely come to 99% exactly — so the exact returns spread from 98.903053% to 99.068864%. The rule is scored on Step 18, the step that prices every setting.
| Item | Detail |
|---|---|
| Claim | 99% RTP — a flat 1.00% house edge |
| Where LIQD publishes it | The Keno game page (qa.liqd.com/originals/keno), in the Description box under the game: “Released: September 2026 | Edge: 1.00%” — read logged in on 3 October 2026, no bet placed (evidence/page-read-2026-10-03/liqd-keno-page-description-edge-2026-10-03.png, E26); the page’s meta description also says “1% house edge” |
| Settings tested | 50 — every risk level (Classic, Low, Medium, High, WTF) × 1–10 picks, every row of the declared table (325 cells) |
| Method | Exact rational: Σ P(k hits) × cell; WTF rows × 13/4 for the lightning settlement |
| Band | ±0.1 percentage points (house side fails, player side never fails) |
| True minimum · maximum | 98.903053% (Low / 5) · 99.068864% (Low / 9) |
| Worst gap | −0.096947 pp (Low / 5), inside the band |
| Settings beyond the band | 0 on the house side · 0 on the player side |
| Scored on | Step 18 (profile check KENO-19), rule enforced |
| Result | Pass — the claim holds at every setting |
| Setting | Exact RTP | RTP | Gap | Result |
|---|---|---|---|---|
| Low / 5 | 36155/36556 | 98.903053% | −0.096947 pp | Pass |
| High / 4 | 9039/9139 | 98.905788% | −0.094212 pp | Pass |
| Low / 4 | 18081/18278 | 98.922202% | −0.077798 pp | Pass |
| Medium / 8 | 130046/131461 | 98.923635% | −0.076365 pp | Pass |
| Low / 9 | 33861599/34179860 | 99.068864% | +0.068864 pp | Pass |
| WTF / 6 | 2785/2812 | 99.039829% | +0.039829 pp | Pass |
| Classic / 2 | 103/104 | 99.038462% | +0.038462 pp | Pass |
| All 50 settings | Σ P(k) × cell | 98.903053%–99.068864% | −0.096947 pp to +0.068864 pp | Pass — 50 / 50 |
outputs/rtp-claim-table.json, pinned by SHA-256 and re-derived on every run.4.3Full RTP Table — All 50 Settings
Every setting LIQD offers, priced exactly from the hit law and its row of the declared table, beside its simulated return from Pass 1 (4.5). The gap is the RTP claim rule’s: each setting’s distance from LIQD’s 99%. The theoretical and gap columns are outputs/rtp-claim-table.json, row for row; the simulated column is priced from outputs/simulation-results.json.
| Setting | Theoretical RTP | Simulated RTP (Pass 1) | Gap to 99% |
|---|---|---|---|
| Classic / 1 | 99.000000% | 99.2962% (+1.73 SE) | 0.000000 pp |
| Classic / 2 | 99.038462% | 99.1051% (+0.53 SE) | +0.038462 pp |
| Classic / 3 | 99.018219% | 99.0606% (+0.29 SE) | +0.018219 pp |
| Classic / 4 | 98.960499% | 99.0762% (+0.79 SE) | −0.039501 pp |
| Classic / 5 | 98.985803% | 98.9703% (−0.08 SE) | −0.014197 pp |
| Classic / 6 | 98.966517% | 98.9676% (+0.01 SE) | −0.033483 pp |
| Classic / 7 | 98.981535% | 98.8902% (−0.52 SE) | −0.018465 pp |
| Classic / 8 | 99.022828% | 98.9679% (−0.27 SE) | +0.022828 pp |
| Classic / 9 | 98.975340% | 98.9545% (−0.11 SE) | −0.024660 pp |
| Classic / 10 | 99.037352% | 99.1769% (+1.03 SE) | +0.037352 pp |
| Low / 1 | 99.000000% | 99.0868% (+1.73 SE) | 0.000000 pp |
| Low / 2 | 99.019231% | 99.0845% (+0.55 SE) | +0.019231 pp |
| Low / 3 | 99.003036% | 98.9685% (−0.12 SE) | +0.003036 pp |
| Low / 4 | 98.922202% | 99.4538% (+1.16 SE) | −0.077798 pp |
| Low / 5 | 98.903053% | 98.0552% (−1.39 SE) | −0.096947 pp |
| Low / 6 | 99.008371% | 99.2005% (+0.28 SE) | +0.008371 pp |
| Low / 7 | 98.938760% | 98.8901% (−0.10 SE) | −0.061240 pp |
| Low / 8 | 99.004180% | 98.9587% (−0.25 SE) | +0.004180 pp |
| Low / 9 | 99.068864% | 99.1389% (+0.61 SE) | +0.068864 pp |
| Low / 10 | 99.031310% | 99.0610% (+0.43 SE) | +0.031310 pp |
| Medium / 1 | 99.000000% | 99.1765% (+1.73 SE) | 0.000000 pp |
| Medium / 2 | 99.000000% | 99.0680% (+0.51 SE) | 0.000000 pp |
| Medium / 3 | 98.987854% | 98.9336% (−0.10 SE) | −0.012146 pp |
| Medium / 4 | 98.996881% | 99.6318% (+1.23 SE) | −0.003119 pp |
| Medium / 5 | 98.944086% | 97.8075% (−1.46 SE) | −0.055914 pp |
| Medium / 6 | 99.031623% | 99.1454% (+0.12 SE) | +0.031623 pp |
| Medium / 7 | 98.961786% | 98.7404% (−0.27 SE) | −0.038214 pp |
| Medium / 8 | 98.923635% | 98.7840% (−0.35 SE) | −0.076365 pp |
| Medium / 9 | 98.942020% | 99.1716% (+1.00 SE) | −0.057980 pp |
| Medium / 10 | 98.974315% | 99.1147% (+0.98 SE) | −0.025685 pp |
| High / 1 | 99.000000% | 99.2962% (+1.73 SE) | 0.000000 pp |
| High / 2 | 99.000000% | 99.0921% (+0.23 SE) | 0.000000 pp |
| High / 3 | 98.987854% | 98.7943% (−0.22 SE) | −0.012146 pp |
| High / 4 | 98.905788% | 100.2619% (+1.08 SE) | −0.094212 pp |
| High / 5 | 98.968706% | 97.7545% (−1.21 SE) | −0.031294 pp |
| High / 6 | 98.998796% | 99.1324% (+0.08 SE) | −0.001204 pp |
| High / 7 | 98.961786% | 98.9204% (−0.04 SE) | −0.038214 pp |
| High / 8 | 98.957105% | 99.3305% (+0.36 SE) | −0.042895 pp |
| High / 9 | 98.964456% | 98.7135% (−0.31 SE) | −0.035544 pp |
| High / 10 | 99.008323% | 99.3412% (+0.92 SE) | +0.008323 pp |
| WTF / 1 | 98.962500% | Table row 30.5411% (+1.73 SE) | −0.037500 pp |
| WTF / 2 | 99.000000% | Table row 30.4899% (+0.23 SE) | 0.000000 pp |
| WTF / 3 | 98.980263% | Table row 30.4626% (+0.06 SE) | −0.019737 pp |
| WTF / 4 | 99.004267% | Table row 30.8344% (+1.18 SE) | +0.004267 pp |
| WTF / 5 | 98.986486% | Table row 30.1103% (−1.25 SE) | −0.013514 pp |
| WTF / 6 | 99.039829% | Table row 30.4887% (+0.04 SE) | +0.039829 pp |
| WTF / 7 | 99.009497% | Table row 30.5032% (+0.10 SE) | +0.009497 pp |
| WTF / 8 | 98.981827% | Table row 30.3330% (−0.32 SE) | −0.018173 pp |
| WTF / 9 | 98.992073% | Table row 30.3783% (−0.26 SE) | −0.007927 pp |
| WTF / 10 | 98.976463% | Table row 30.6035% (+0.37 SE) | −0.023537 pp |
4.4House Edge & Settlement (Step 12)
The house edge is what a setting keeps: 100% minus its RTP — from 0.9311% (Low / 9) to 1.0969% (Low / 5) against the 1.00% LIQD advertises. Settlement turns a cell into money: winningAmount = floor(stake × cell) in units of 1e-8, × 10 on a WTF lightning round, so a payout never exceeds the exact product. Cells are written with at most two decimals on Classic, Low, Medium and High and at most three on WTF (260 whole, 47 with one decimal, 17 with two, 1 with three), so at the $0.10 stake every product in the capture lands exactly on the 8-decimal grid — 3,000 / 3,000 rounds settle with no rounding at all (Step 12). Two cells pay on zero hits — Low / 1 pays 0.7× and Medium / 1 pays 0.4× — which is how those 1-pick rows return 99% while paying back part of the stake on a miss.
4.5Simulation Pass 1 — Our Own Seeds (Step 19)
Section 4.1 proves each setting’s RTP mathematically; the simulation checks it at scale. We simulated 10 million rounds with our own implementation — the one that reproduces all 3,000 captured rounds — and our own seeds: one stream of 1,000,000 rounds for each pick count, from seeds derived from a pinned master seed, replayable byte for byte. Classic, Low, Medium and High share one draw function with no pick, risk or stake input (Step 10), so those four risk levels of a pick count share that stream’s draws and each setting’s simulated RTP is its row priced on the stream’s hit counts; the WTF row is priced on the same hit counts, since its draw follows the same hit law. Every statistic is judged against a gate of 3.333 × 10⁻⁵ (α = 0.001 over 10 streams × 3 tests); the RTP of every paytable row must sit within ±5 SE of its exact value:
Board uniformity: Each stream’s 10,000,000 drawn numbers spread over the 40 values, corrected for drawing without replacement — 10 / 10 streams clear, smallest p 0.0288.
Hit law: Each stream’s hit counts against the exact hypergeometric probabilities — 10 / 10 clear, smallest p 0.0841.
Serial screen: Lag-1 correlation of consecutive hit counts — |ρ| ≤ 0.0018 on 10 / 10 streams, smallest p 0.0724.
RTP convergence: Every paytable row’s simulated RTP against its exact RTP (WTF’s before its × 10) — 50 / 50 within ±5 SE, largest |z| 1.73 (the 1-pick rows, which share one stream). The 40 Classic–High settings average 99.0143% simulated against 98.9850% exact.
| Stream (1,000,000 rounds) | Largest |z| of its five rows | Setting |
|---|---|---|
| 1 pick | 1.73 | all five rows (one shared stream) |
| 2 picks | 0.55 | Low / 2 |
| 3 picks | 0.29 | Classic / 3 |
| 4 picks | 1.23 | Medium / 4 |
| 5 picks | 1.46 | Medium / 5 |
| 6 picks | 0.28 | Low / 6 |
| 7 picks | 0.52 | Classic / 7 |
| 8 picks | 0.36 | High / 8 |
| 9 picks | 1.00 | Medium / 9 |
| 10 picks | 1.03 | Classic / 10 |
const streams = PASS1_PICK_COUNTS.map(n => { const s = runPass1Stream(n, rounds); process.stderr.write(` pass1 picks=${n} done\n`); return s; });// … // RTP convergence on EVERY configuration of the declared table: the stream of that pick count, priced by that risk's row. let converged = 0, worstZ = 0, worstCfg = ''; const rtpParts: string[] = []; for (const c of ALL_CONFIGS) { const s = streams.find(x => x.picks === c.picks); if (!s) { problems.push(`no stream for ${c.mode}/${c.picks}`); continue; } const row = clientRowQ(ctx.clientTable, c.mode, c.picks), P = hypergeometric(c.picks); const exact = toNumber(rtpExact(P, row)), v = toNumber(varianceExact(P, row)); const simR = s.hits.reduce((a, n, k) => a + n * toNumber(row[k]), 0) / s.rounds; const z = (simR - exact) / Math.sqrt(v / s.rounds); if (Math.abs(z) > 5) problems.push(`${c.mode}/${c.picks} RTP ${simR} is ${z.toFixed(2)} SE from exact ${exact}`); else converged++; if (Math.abs(z) > Math.abs(worstZ)) { worstZ = z; worstCfg = `${c.mode}/${c.picks}`; } if (c.mode === 'classic' && c.picks === 3 || Math.abs(z) > 3) rtpParts.push(`${c.mode}/${c.picks} simulated ${(100 * simR).toFixed(4)}% vs exact ${(100 * exact).toFixed(4)}% (${z.toFixed(2)} SE)`); }npm run test:full regenerated the whole artifact for this report — REPRODUCED. The simulated payouts are consistent with the exact RTPs (Step 19).What is a Monte Carlo simulation? Instead of proving fairness with algebra alone, we simulate millions of game rounds. Each round derives a draw from scratch using the same HMAC-SHA256 pipeline as the live game, counts the hits on a fixed pick set and records what each risk level’s row would pay. We then compare the aggregate return to the exact RTP. If they match within tolerance, the math holds in practice — not just on paper.
How the artifact is scored, not believed: Step 19 does not read the simulation’s own pass/fail fields. It re-derives every statistic from the artifact’s integer rows — number counts, hit histograms and serial sums — checks that every histogram adds up to its stream, prices every setting from the hit counts against the declared table, and replays the first 50,000 rounds of every stream from the derived seeds. Because the seeds come from a pinned master seed, npm run test:full regenerates the whole 10M-round experiment and compares it field by field with the committed artifact.
4.6Cherry-Pick Test — Pass 2 (Step 20)
Could the casino have chosen server seeds that pay players less? Pass 2 takes the Classic, Low, Medium and High server seeds LIQD actually used — all 40 revealed epoch seeds — and replays each one for 10,000 nonces under the epoch’s recorded client seed. The window the player saw is the epoch itself, nonces 0–59; everything after it is reference. Both statistics were fixed before the test ran. Each compares the return in the played window with the same window moved to later nonces the casino never served — 10,000 relocations — so a seed set chosen against the player would show up as a low return in the window that was actually played. The test is one-sided and fails below p = 0.001. Step 20 recomputes every seed row from the revealed seeds in every run.
Primary defence — structural: The client seed is player-editable and is set in the rotation after the server-seed commitment is published (1.4). An operator that cannot predict your seed cannot pre-evaluate candidates against it — the foreknowledge channel is removed by design.
Statistical check: The two relocation statistics above — a direct check on the money, backing the structural defence.
| Test | Detail |
|---|---|
| Seeds tested | 40 — every revealed Classic–High epoch seed, keyed by its own epoch |
| Replay depth | 10,000 nonces per seed — the played window is nonces 0–59 |
| Statistic 1 — Classic / 3 | Picks 1, 2, 3 at every window nonce: S = 82.86 stakes over 2,400 rounds against a relocation null of 2.52 ± 69.79 — p = 0.8754; detects an 11.43 pp house-favouring shift at 80% power |
| Statistic 2 — rounds as played | Every recorded round by its own pick set and row: S = −347.61 stakes over 2,400 rounds against 12.77 ± 297.68 — p = 0.0711 (exact-variance z −1.201); detects 48.77 pp at 80% power |
| Rule | Fails if either p < 0.001 — both clear |
| Re-derivation | 40 / 40 recorded seed rows reproduced in this run |
let S = 0, N = 0; for (const w of windows) { const len = w.end - w.start + 1; S += sumWin(w.hashPrefix, w.start, len) - muTenths * len; N += len; } const rnd = prng(PASS2_BOOTSTRAP_SEED); let atOrBelow = 0; let sumNull = 0, sumNull2 = 0; for (let b = 0; b < PASS2_BOOTSTRAP_REPS; b++) { let s = 0; for (const w of windows) { const len = w.end - w.start + 1; const lo = len, hi = PASS2_NONCES - len; if (hi < lo) throw new Error(`early window of ${w.hashPrefix} (${len} nonces) leaves no later window to relocate to`); const a = lo + Math.floor(rnd() * (hi - lo + 1)); s += sumWin(w.hashPrefix, a, len) - muTenths * len; } if (s <= S) atOrBelow++; sumNull += s; sumNull2 += s * s; } const nullMean = sumNull / PASS2_BOOTSTRAP_REPS, nullSd = Math.sqrt(Math.max(sumNull2 / PASS2_BOOTSTRAP_REPS - nullMean ** 2, 0)); return { S, N, p: (atOrBelow + 1) / (PASS2_BOOTSTRAP_REPS + 1), nullMean, nullSd };4.7Bet-Size Invariance
A setting’s RTP is a property of its row: Σ P(k) × cell(k) contains no stake. In the specification nothing gives the stake a way in before settlement: the draw takes (serverSeed, clientSeed, nonce) only (Step 10), the hits come from the draw and the picks, and LIQD’s declared paytable is keyed by risk, picks and hits only — no stake dimension, so the same multiplier at every stake. The stake enters once, at settlement, as floor(stake × multiplier): it scales the payout, floored at the 8th decimal (4.4).
| Stage | Computed from | Stake input in the specification |
|---|---|---|
| Draw (Step 6) | computeDraw(serverSeed, clientSeed, nonce); WTF adds the lightning cursor | None — three arguments, no stake field (Step 10) |
| Hits (Step 9) | |picks ∩ drawn| | None |
| Multiplier (Step 11) | The declared cell for (risk, picks, hits); × 10 on a paying WTF lightning round | None — the declared table is keyed by risk, picks and hits only |
| Payout (Step 12) | floor(stake × multiplier) in 1e-8 units | Its only appearance — it scales the payout, floored at the 8th decimal |
4.8WTF Mode — the Lightning Multiplier
WTF is Keno’s fifth risk level. Before the draw, HMAC cursor 0 picks a lightning number from 1 to 40 (uint32 mod 40 + 1); the ten numbers are then drawn at cursors 1–10 by the same shuffle as every other risk level. A paying WTF round pays 10 × its cell when the lightning number is among the ten drawn, and its cell otherwise. The lightning number comes from its own cursor, so it is independent of the draw: whatever ten numbers are drawn, it lands among them with probability exactly 10/40. Every WTF row therefore returns row RTP × (30/40 × 1 + 10/40 × 10) = row RTP × 13/4. LIQD’s WTF rows are priced for exactly that: by the table alone they return 30.450000%–30.473794%, and × 13/4 that is 98.962500%–99.039829%, all 10 within ±0.1 pp of 99% (Step 18).
LIQD publishes WTF’s rule through its own verifier — the in-game Verify tab and its endpoint — rather than in writing, and our written specification (docs/ALGORITHM.md §2.2) reproduces it exactly: 600 / 600 scored WTF rounds by wtfDraw, drawn list and lightning number (Step 6), with LIQD’s verifier returning the same draw, lightning number and multiplier on all 600 (Step 24). In the capture the lightning number was among the drawn on 125 of the 600 rounds, and 16 of the 44 paying WTF rounds were paid × 10 (4.10). One WTF row, in full:
| Hits (WTF / 7 picks) | Exact P(k) | Cell — lightning not drawn (30/40) | Lightning drawn (10/40) |
|---|---|---|---|
| 0 | 1305/11951 = 10.9196% | 0× | 0× |
| 1 | 15225/47804 = 31.8488% | 0× | 0× |
| 2 | 16443/47804 = 34.3967% | 0× | 0× |
| 3 | 27405/155363 = 17.6393% | 0× | 0× |
| 4 | 7105/155363 = 4.5732% | 3× | 30× |
| 5 | 1827/310726 = 0.5880% | 18× | 180× |
| 6 | 105/310726 = 0.0338% | 169× | 1,690× |
| 7 | 1/155363 = 0.0006% | 700× | 7,000× |
| Row RTP | Σ P × cell | 30.464461% | × 13/4 → 99.009497% |
4.9Paytable Shape, Coverage & Bounds (Steps 14, 15)
LIQD’s declared table has 325 cells: 5 risk levels × 1–10 picks, each row running from 0 hits to n hits. Its top cell is 1,000× — on Low, Medium and High at 9 and 10 picks and on WTF at 10 picks — and 10,000× with WTF lightning. Every scored payout sits at or below the stake times its row’s top cell (× 10 on WTF): 3,000 / 3,000 (Step 14). The table’s shape is regular: no row has a zero between two paying cells and no cell pays less than the one below it; two cells pay on zero hits — Low / 1 at 0.7× and Medium / 1 at 0.4× — the consolation cells. The capture placed 60 rounds on every one of the 50 settings (Step 15) and reached 218 of the 325 cells, every non-paying cell among them; the 107 cells it did not reach are all paying cells — the rare high-hit prizes, which together carry 28.0355% of the theoretical return. That is why Keno’s RTP is proven by exact enumeration (Step 18), not estimated from the capture. LIQD’s stake limits are covered by the max-win tests in Section 5.
| Item | Value |
|---|---|
| Cells | 325 — 50 rows of 2 to 11 cells |
| Top cell | 1,000× (Low, Medium and High at 9 and 10 picks; WTF at 10) · 10,000× with lightning |
| Consolation cells (pay on 0 hits) | Low / 1 0.7× · Medium / 1 0.4× |
| A zero between paying cells · less pay for more hits | None · none |
| Payouts within stake × the row’s top cell (× 10 on WTF) | 3,000 / 3,000 |
| Settings at 60 rounds | 50 / 50 |
| Cells reached · paying cells reached | 218 / 325 · 95 / 202 |
| Share of the theoretical return in never-reached cells | 28.0355% (pooled, WTF × 13/4) |
4.10Informational Items (Not Scored)
These items are reported for transparency but are not scored audit steps. They provide context on the returns observed during data collection. At 60 rounds per setting, the capture’s own return is not a measure of fairness — the exact RTPs in 4.1–4.3 are the authoritative evidence.
| Item | Value | Context |
|---|---|---|
| Live capture RTP | 136.94% | $410.83 returned on $300.00 wagered — variance illustration only; the single 1,690× WTF win paid $169.00 of it (80.61% without it) |
| Live return by risk level | Classic 103.23% · Low 103.35% · Medium 77.62% · High 53.82% · WTF 346.71% | $60.00 staked on each; High paid on 50 of 600 rounds, WTF on 44 |
| Win rate | 33.33% (1,000 / 3,000) | Rounds paying more than 0, all settings — informational |
| Largest win | $169.00 — 1,690× on a $0.10 stake | WTF / 7 picks, 6 hits, lightning drawn (4.11) |
| Lightning number in the draw | 125 / 600 (20.8%) | The rule’s exact rate is 1 in 4 (150 rounds). The 600 lightning numbers are uniform over 1–40 (p = 0.3064, Step 21) and each is reproduced from its own HMAC cursor (Step 6) |
| WTF × 10 payouts | 16 of 44 paying WTF rounds | Each with its lightning number drawn (Step 11) |
| Rounds per setting | 60 on each of the 50 | The pick count cycles 1–10 six times in every epoch |
| Live-sample fit (Step 21) | Board p = 0.7249 · hit law p 0.2100–0.9034 | The 3,000 captured draws against the uniform board and the exact hit law — reported, never scored |
4.11Worked Example — Payout Verification
Real round from the scored capture — bet f2t0JeOrZXSsgu0xyMvoQ (epoch 46, nonce 6), WTF, 7 picks at $0.10 — the capture’s largest win. Verified from liqd-keno-capture-v2-2026-10-20T13-04-03-496Z.json — the round whose lightning draw is traced in S2.7, and a round LIQD’s own verifier reproduced in the Step 24 cross-check:
serverSeed = 430373a8b48a104773e948c25dd0b8f7
clientSeed = audit14226566ad31
nonce = 6
risk = WTF · picks 28, 7, 4, 13, 22, 2, 21
betAmount = $0.10
recorded = lightning 20 · drawn 13, 28, 12, 22, 11, 21, 7, 20, 4, 18
hits 6 · multiplier 1690 · winningAmount 169.00000000
commitment = 98cf35e524d387ed…Step 1 — Lightning number: HMAC-SHA256(seed, "audit14226566ad31:6:0") → first 4 bytes d5d73553 = 3,587,650,899 → mod 40 = 19 → lightning 20 — matches the record.
Step 2 — Draw: Cursors 1–10 through the same shuffle → 13, 28, 12, 22, 11, 21, 7, 20, 4, 18 — the served list, in order; the lightning number 20 is the 8th number drawn.
Step 3 — Hits and cell: Picks ∩ drawn = 28, 7, 4, 13, 22, 21 → 6 hits → the declared WTF / 7 cell for 6 hits is 169.
Step 4 — Payout: Lightning drawn → 169 × 10 = 1690 → floor(0.10 × 1690) = $169.00000000, the credited winningAmount and its wallet field, verbatim.
| Step | Process | Output |
|---|---|---|
| 1 | Recompute the lightning number — cursor 0 | 20 — matches the record |
| 2 | Recompute the draw — cursors 1–10 | The served list, in order |
| 3 | Count hits and look up the declared cell | 6 hits → 169× |
| 4 | Apply the lightning factor | Lightning 20 is drawn → × 10 = 1,690× |
| 5 | Settle floor(0.10 × 1,690) | $169.00 credited |
P(k | 7) = C(7,k)·C(33,10−k) / C(40,10) k = 0…7row WTF/7 = 0, 0, 0, 0, 3, 18, 169, 700 (cells, 0–7 hits)row RTP = 7105/155363 × 3 + 1827/310726 × 18 + 105/310726 × 169 + 1/155363 × 700 = 94661/310726 = 30.464461%RTP = row RTP × (30/40 × 1 + 10/40 × 10) = row RTP × 13/4 = 94661/95608 = 99.009497% (gap +0.009497 pp, inside ±0.1 pp)Technical Evidence & Verification5 sections
4.12Evidence Coverage Summary10 checks
| Verification Area | Coverage | Result |
|---|---|---|
| Anti-circularity (Step 17) | n = 1–10: closed form = independent draw-by-draw derivation, sums to 1, mean n·10/40; both negative controls rejected | Pass |
| Exact RTP & claim rule (Step 18) | 50 / 50 settings within ±0.1 pp of 99% — 98.903053%–99.068864%; outputs/rtp-claim-table.json re-derived and pinned | Pass |
| Paytable provenance (Step 11) | Declared table pinned by SHA-256 and named by the capture; prices 3,000 / 3,000 rounds | Pass |
| Settlement (Step 12) | 3,000 / 3,000 credits = floor(stake × cell [× 10]) in 1e-8 units; 2,000 zero-cell rounds pay 0 | Pass |
| Paytable shape & bounds (Step 14) | 3,000 / 3,000 payouts within stake × the row’s top cell; two consolation cells, no other irregular cell | Pass |
| Configuration & cell coverage (Step 15) | 50 / 50 settings at 60 rounds; 218 / 325 cells reached | Pass |
| Simulation Pass 1 (Step 19) | 10 × 1M rounds — uniformity, hit law and serial clear; 50 / 50 paytable rows within ±5 SE | Pass |
| Simulation Pass 2 (Step 20) | 40 seeds × 10,000 nonces: p = 0.8754 and 0.0711 against 0.001 | Pass |
| WTF lightning settlement (Steps 6, 11, 18, 24) | Row × 13/4 on 10 / 10 WTF rows; 16 / 16 × 10 rounds with lightning drawn; LIQD’s verifier 600 / 600 | Pass |
| Artifacts of record (Step 23) | Simulation and verification outputs match their pins; 1,665 numeric values inside their declared domains | Pass |
4.13Code References12 source files
| File | Purpose |
|---|---|
| tests/steps/rtp.ts | Steps 17, 18, 21: the hit-law model and its negative controls, the exact RTP of every setting against the claim, the live-sample fit |
| src/exact.ts | hypergeometric, sequentialDraw, rtpExact, varianceExact — BigInt rationals, no floating point |
| src/rtp-claim.ts | The RTP claim rule, RTP-CLAIM 1.2.0 — a byte-identical copy of the framework’s rule file |
| src/rtp-settings.ts | The claim (LIQD’s flat 99%) and the 50-setting exact RTP table it is held to |
| src/client-paytable.ts | Parses LIQD’s client paytable literal as data (clientRowQ) — the cells every RTP is priced from |
| tests/steps/payouts.ts | Steps 11–16: paytable provenance, payout arithmetic, bet-size checks, payout bounds and coverage |
| tests/steps/simulation.ts | Steps 19–20: Pass 1 statistics and RTP convergence re-derived from the artifact’s integer rows; Pass 2 recomputed in every run |
| src/sim.ts | Pass 1 streams from the pinned master seed; the Pass 2 relocation statistics |
| src/sim-inputs.ts | Pass 2 inputs from the scored capture — the 40 Classic–High epoch seeds, their client seeds and every played round’s row |
| src/simulate.ts | npm run simulate — produces the simulation artifact; --compare regenerates it and diffs it field by field |
| src/stats.ts | Board uniformity corrected for drawing without replacement, χ² fits, lag-1 from integer sums |
| src/domains.ts | Declared value domains for every numeric field of the artifacts of record (Step 23) |
4.14Datasets UsedSource artifacts
4.15Verified Invariants14 invariants
| Invariant | Result |
|---|---|
| P(k | n) = C(n,k)·C(40−n,10−k)/C(40,10) equals the independent draw-by-draw derivation, cell for cell, for n = 1–10 (non-circular) | Pass |
| Each hit distribution sums to exactly 1 and has mean exactly n·10/40 | Pass |
| Negative controls: a shifted distribution is rejected; a nudged cell changes the exact RTP | Pass |
The exact RTP of all 50 settings equals outputs/rtp-claim-table.json | Pass |
| LIQD’s advertised 1.00% edge holds at all 50 settings — worst gap −0.096947 pp against ±0.1 pp | Pass |
| Every WTF row returns its table RTP × 13/4 under the lightning settlement — 10 / 10 within the band | Pass |
| The served multiplier is the declared cell on 3,000 / 3,000 rounds (× 10 on the 16 paying lightning rounds) | Pass |
| winningAmount == floor(stake × cell [× 10]) in 1e-8 units on 3,000 / 3,000; every product exact on the money grid | Pass |
| Every payout ≤ stake × its row’s top cell (× 10 on WTF) | Pass |
| 10 / 10 Pass 1 streams clear board uniformity, the hit law and the lag-1 screen at 3.333 × 10⁻⁵ | Pass |
| 50 / 50 paytable rows’ simulated RTP within ±5 SE of the exact RTP (largest |z| 1.73) | Pass |
| The first 50,000 rounds of 10 / 10 streams replay exactly; the full artifact regenerates field for field (REPRODUCED) | Pass |
| No significant return deficit on the 40 Classic–High casino seeds (Pass 2 — p = 0.8754 and 0.0711 against 0.001; 40 / 40 seed rows reproduced) | Pass |
The draw takes no stake argument — computeDraw(serverSeed, clientSeed, nonce) (Step 10) | Pass |
4.16Reproduction InstructionsRun locally
Clone the repository, install dependencies, and regenerate the simulation before verifying:
git clone https://github.com/ProvablyFair-org/liqd-keno.gitcd liqd-keno && git checkout v1.1.0npm cinpm run test:full # regenerates the 10M-round simulation from its pinned master seed, compares it, then verifieshead -40 outputs/rtp-claim-table.jsoncat outputs/simulation-results.json | head -40The RTP claim rule and the S4-related steps, as printed, without their detail lines:
rtp-claim: ENFORCED — PASS
rtp-claim: band ±0.10 pp · 50 settings · true min 98.903053% (risk=low picks=5) · true max 99.068864% (risk=low picks=9)
rtp-claim: house side beyond band: 0 · player-favourable beyond band: 0 · worst risk=low picks=5 at -0.096947 pp
rtp-claim: outcome PASS · minimal fix: none — the claim holds for every setting it states a figure for
rtp-claim: committed table outputs/rtp-claim-table.json: equals this run; sha256 5025110bbc6ba4d6… matches the pin
[PASS] Step 11: Paytable provenance & served-vs-declared
[PASS] Step 12: Payout arithmetic
[PASS] Step 14: Paytable shape & bounds
[PASS] Step 15: Configuration & cell coverage
[PASS] Step 17: Anti-circularity (hit probability)
[PASS] Step 18: Per-config RTP by exact enumeration vs claim
[PASS] Step 19: Simulation Pass 1 (fresh seeds)
[PASS] Step 20: Simulation Pass 2 (cherry-pick)
[PASS] Step 21: Live-sample fit (informational)
[PASS] Step 23: Artifacts of record
The simulation is deterministic: npm run test:full regenerates outputs/simulation-results.json and compares it field by field with the committed artifact — REPRODUCED on 22 October 2026 (Node 22.22.0) — and the verdict is scored against the committed, SHA-256-pinned artifact. The chart above is drawn from that artifact and the exact RTP table.Fairness Integrity Testing
Does the implementation maintain fairness under non-standard conditions?
Sections 1–4 establish that the game is mathematically fair; Section 5 tests whether the live implementation keeps its integrity under non-standard conditions. It covers 34 integrity tests run against LIQD’s running service across 12 categories: race conditions, input validation, idempotency, authorization, outcome and payout fields, nonce and seed handling, information disclosure, and the provably fair surface itself. 33 passed. One was remediated: the paytable in place at testing missed the displayed 1.00% edge by more than 0.1 percentage points on 10 settings; LIQD corrected it, and the audited table returns within 0.1 percentage points of 99% on all 50 settings. No test altered, replayed or injected an outcome.
What we verified
- Race conditions — can parallel bets, a high-stake over-commit, extreme bursts, or a seed rotation racing a live bet change an outcome or double-settle?
- Nonce tampering — can the sequence be forced, replayed, or skipped?
- Seed handling — can unusual client seeds, or the active server seed, be exposed or overridden?
- Outcome replay — can an identical bet be settled twice, or a timed-out bet settle twice?
- Cross-player isolation — can one player act on another’s account, or after logging out?
- Payout tampering — can drawn numbers, hits, multiplier or payout be injected?
- Parameter limits — can invalid picks, risk modes, stakes or currencies be submitted?
What this means for you
- Across the 34 tests, no request path let anyone alter, replay or inject an outcome
- Once a bet is placed, its draw cannot be changed or replayed
- Each bet gets its own server-assigned nonce under a committed seed
- Your bets are bound to your session — no other account can act on them
- LIQD corrected the paytable finding: the audited table returns within 0.1 percentage points of 99% on all 50 settings
- Race conditions — can parallel bets, a high-stake over-commit, extreme bursts, or a seed rotation racing a live bet change an outcome or double-settle?
- Nonce tampering — can the sequence be forced, replayed, or skipped?
- Seed handling — can unusual client seeds, or the active server seed, be exposed or overridden?
- Outcome replay — can an identical bet be settled twice, or a timed-out bet settle twice?
- Cross-player isolation — can one player act on another’s account, or after logging out?
- Payout tampering — can drawn numbers, hits, multiplier or payout be injected?
- Parameter limits — can invalid picks, risk modes, stakes or currencies be submitted?
- Across the 34 tests, no request path let anyone alter, replay or inject an outcome
- Once a bet is placed, its draw cannot be changed or replayed
- Each bet gets its own server-assigned nonce under a committed seed
- Your bets are bound to your session — no other account can act on them
- LIQD corrected the paytable finding: the audited table returns within 0.1 percentage points of 99% on all 50 settings
Nonce integrityPass
Seed integrityPass
Outcome determinismPass
Round & player isolationPass
Payout integrityPass
Paytable returnRemediated
34 integrity tests: 33 pass and 1 remediated (paytable return — corrected by LIQD; the audited table returns within 0.1 percentage points of 99% on all 50 settings). No test altered, replayed or injected an outcome. Test procedures and payloads are withheld by policy — see 5.5.
Category Coverage
| Check | Result | Evidence |
|---|---|---|
| Nonce integrity | Pass | Server-assigned and sequential: injected reused, skipped, zero or negative and overflow-sized nonces are all refused (NONCE-001 … NONCE-004); the nonce advances by one per bet (PF-002) and restarts at 0 on rotation (ENTROPY-004) |
| Seed integrity | Pass | The active seed is never disclosed before rotation (DISCLOSE-001); 70 / 70 rotations racing live bets revealed the committed seed and never reused one, and every raced round settled on the seed it reported (EXTRA-001); unusual and Unicode client seeds handled (SEED-001, SEED-002), and each bet uses the seed set just before it (SEED-003) |
| Outcome determinism | Pass | All 2,770 rounds captured during the integrity testing with a revealed seed recompute in draw order, with LIQD’s verifier agreeing on every live round checked against it (EXTRA-002, PF-001, PARAM-003) |
| Round & player isolation | Pass | 13 / 13 foreign and malformed identifiers rejected; post-logout reuse rejected; parallel identical and high-stake bets settled once each and reconciled to the wallet |
| Payout integrity | Pass | Injected draw, hits, payout, status and multiplier fields rejected; 200 consecutive rounds settled with no payout mismatch and reconciled to the balance |
| Paytable return | Remediated | The paytable in place at testing missed the displayed 1.00% edge by more than 0.1 percentage points on 10 settings (Medium); LIQD corrected it, and the audited table returns within 0.1 percentage points of 99% on all 50 settings (FAIR-001; Section 4) |
Nonce integrityPass
Seed integrityPass
Outcome determinismPass
Round & player isolationPass
Payout integrityPass
Paytable returnRemediated
34 integrity tests: 33 pass and 1 remediated (paytable return — corrected by LIQD; the audited table returns within 0.1 percentage points of 99% on all 50 settings). No test altered, replayed or injected an outcome. Test procedures and payloads are withheld by policy — see 5.5.
How It Works — Fairness Integrity Testing2 sections
5.1Framework Overview
Testing follows the ProvablyFair.org Fairness Integrity Framework — a structured methodology derived from real, historically observed failures in provably fair systems. Cryptographic verification (S1–S3) shows the game computed every captured draw from its disclosed inputs; fairness-integrity testing asks whether the surface around that computation holds up under non-standard use — seeds swapped, rounds replayed, parameters injected, settlements raced. These tests ran against LIQD’s running service, not against a dataset. This section covers 34 tests across 12 categories. Each card below gives the control assessed, the broad testing approach, the observed result, its impact and any limitation.
| Category | Tests | What It Catches |
|---|---|---|
| Race Conditions | 5 | Parallel bets racing placement, settlement or seed rotation, and how the service handles extreme bursts |
| Input Validation | 5 | Malformed, wrong-type or out-of-range picks, risk modes, stakes, content types and currencies |
| Atomic Balance / Idempotency | 3 | Double debits, orphaned debits and unsafe retry behaviour after a timeout or disconnect |
| Broken Authorization | 2 | Acting on another account, or after the session has ended |
| Max-win Bypass | 4 | Client-supplied outcome or pick-selection fields, and stake limits |
| Nonce Manipulation | 4 | Reused, skipped, zero, negative or oversized nonces supplied by the client |
| RNG / PF Verification | 4 | Draw distribution, return against the displayed edge, commitment and reveal, and independent recomputation of live rounds |
| RNG / Information Leak | 1 | Disclosure of the active server seed, or of hidden game state, before it is due |
| Entropy / PF Verification | 1 | Server-seed rotation behaviour: fresh seeds, a new commitment and the nonce reset |
| Precision / Payout | 1 | Settlement arithmetic and wallet reconciliation over many rounds |
| Event Replay | 1 | Duplicate or replayed requests settling more or less than once |
| Adversarial Seeds | 3 | Unusual client seeds: length, character sets, Unicode forms and rapid rotation |
5.2Severity Framework & Hard Fail Criteria
Two scales apply in this section. The audit framework decides whether a result can pass at all — any test that lets money settle against a tampered or unverifiable outcome is an automatic hard fail. The integrity matrix records each test’s status — Pass, Remediated, Warning or Fail — and each finding is graded Critical, High, Medium or Low. On this page: one Remediated test, graded Medium — the paytable’s return against the displayed edge, a disclosure finding rather than a draw issue, corrected by LIQD and re-verified.
| Status | Meaning | Action |
|---|---|---|
| Pass | The service handled the scenario correctly: the control held | None — recorded with its result |
| Remediated | A failed control that LIQD has since fixed, re-tested by ProvablyFair.org on the current build | Recorded with the fix and its re-test |
| Warning | A real defect, or a property not yet proven, without a direct effect on funds or fairness | Recorded with its impact; open until a re-test resolves it |
| Fail | A defect affecting funds, fairness or security | Remediation and a re-test required |
| Condition | Consequence |
|---|---|
| A substituted server seed is accepted against a published commitment | Hard fail |
| A revealed seed fails its commitment and play continues | Hard fail |
| Any settled round’s drawn numbers cannot be reproduced from disclosed inputs | Hard fail |
| A credited payout deviates from the declared paytable cell and the settlement rule | Hard fail |
| Nonce reuse or gaps that could conceal discarded outcomes | Hard fail |
34 tests · 33 pass · 1 remediated
Race Conditions5/5
LIQD-KENO-EXTRA-001PassSeed rotation racing live bets
- Control assessed
- A rotation racing bets cannot reveal a seed early or inconsistently, or bring a revealed seed back into use, and each raced round settles on the seed it reports.
- Testing approach
- Seventy rotations fired against bets in flight; each revealed seed checked against its pre-race commitment and each raced round recomputed.
- Observed result
- All 70 rotations succeeded. Each revealed seed matched its pre-race commitment, none reappeared as a later active seed, and every settled raced round recomputed under the seed it reported; four raced bets returned a generic server error.
- Impact
- None on fairness — seed tracking held; the four errors were generic rather than structured responses.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-RACE-001PassNonce uniqueness under parallel bets
- Control assessed
- Every accepted bet gets its own server-assigned nonce in sequence, even under parallel load.
- Testing approach
- Bursts of parallel bets from one account; nonces and response order compared.
- Observed result
- All 55 bets were accepted with unique nonces and no server error; ordered by arrival, the nonces ascend across the whole run.
- Impact
- None — no nonce was shared or skipped.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-RACE-002PassIdentical bets in parallel, at scale
- Control assessed
- Each accepted bet settles exactly once, and the balance equals the sum of all settled rounds.
- Testing approach
- Fifteen identical bets sent in parallel; round IDs and balance reconciled.
- Observed result
- All 15 settled once, as distinct rounds; the balance change matched their summed outcome exactly.
- Impact
- None — no lost or duplicated settlement.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-RACE-003PassHigh-stake parallel over-commit
- Control assessed
- Parallel high-stake bets cannot overdraw the balance, settle twice or leave a phantom round.
- Testing approach
- Parallel bets whose combined stake exceeds the balance, with a control bet; wallet and bet history reconciled.
- Observed result
- All 10 settled rounds, the control included, reconciled to the wallet and the bet history: no over-commit, no negative balance, no payout mismatch, no lost or phantom round.
- Impact
- None — settlement stayed consistent under parallel placement.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-RACE-004PassBurst load and error disclosure
- Control assessed
- Excess load is held back by rate limiting or answered with structured errors, with no duplicate nonce and no internal detail in error bodies.
- Testing approach
- Bursts of bets from one account at increasing volume; status codes, error bodies and nonces checked.
- Observed result
- The smaller bursts all settled; the largest was answered with structured busy and rate-limit responses, with no generic server errors. Accepted nonces were unique, and no error body disclosed internal detail.
- Impact
- None — the control held.
- Limitations
- None specific to this test; the general scope is in 5.5.
Input Validation5/5
LIQD-KENO-PARAM-001PassPicks and risk-mode validation
- Control assessed
- Valid selections are accepted; out-of-range and wrong-type selections are refused.
- Testing approach
- The valid boundary selections, out-of-range and wrong-type picks and risk modes, with a control bet.
- Observed result
- The control and every valid boundary were accepted, and every out-of-range and wrong-type selection was rejected by input validation.
- Impact
- None — the control held.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-PARAM-002PassStake edge cases
- Control assessed
- Minimum and free-play stakes are accepted; invalid stakes are refused, with no silent conversion to zero.
- Testing approach
- Minimum-stake and zero-stake controls, then negative, oversized, sub-precision, null and wrong-type stakes.
- Observed result
- Both controls settled; every invalid stake was rejected, with no silent conversion to zero.
- Impact
- None — the control held.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-PARAM-003PassMaximum pick count, recomputed
- Control assessed
- Eleven picks are refused; a 10-pick round settles and reproduces from its revealed seed.
- Testing approach
- An 11-pick and a 10-pick bet; the 10-pick round recomputed after the reveal and checked against LIQD’s verify endpoint.
- Observed result
- Eleven picks were rejected and ten accepted; the revealed seed matched its commitment, and the round recomputed to the settled draw and payout, with LIQD’s verifier agreeing.
- Impact
- None — the control held.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-PARAM-004PassContent-type variations around validation
- Control assessed
- Changing how the request body is encoded cannot let an invalid selection settle.
- Testing approach
- Invalid selections sent under each alternative content type, with a control bet.
- Observed result
- The control settled; both invalid selections were rejected under every content type, so no body-parsing path skipped validation.
- Impact
- None — the control held.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-TOKEN-001PassCurrency identifier manipulation
- Control assessed
- A client-supplied currency identifier cannot redirect or alter the stake.
- Testing approach
- Bets carrying six altered currency identifiers, with a control bet.
- Observed result
- All six were rejected without a server error; only the control settled, in the expected wallet.
- Impact
- None — the stake stayed in the account’s own currency.
- Limitations
- None specific to this test; the general scope is in 5.5.
Atomic Balance / Idempotency3/3
LIQD-KENO-IDEMP-001PassSame-key retry after a timeout
- Control assessed
- A retry with the same key returns the original round, with no second debit.
- Testing approach
- Bets sent with very short client timeouts, then retried with the same key; wallet reconciled over the nonces used.
- Observed result
- All 20 retries returned their original round; all 12 timed-out bets had settled server-side, and the wallet reconciled exactly.
- Impact
- None — the control held.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-IDEMP-002PassOrphaned-debit check at scale
- Control assessed
- Every debit belongs to a settled round.
- Testing approach
- Twenty consecutive bets; the balance reconciled against their outcomes.
- Observed result
- All 20 settled, and the balance change matched their summed outcome exactly.
- Impact
- None — no unexplained debit.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-IDEMP-003PassDisconnect while a bet is processed
- Control assessed
- Every consumed nonce is explained, and no debit is left unexplained.
- Testing approach
- Bets abandoned by the client at different moments; rounds and balance reconciled.
- Observed result
- The abandoned bet still settled, and the other three settled normally; the balance reconciled exactly over the consumed nonces.
- Impact
- None — the control held.
- Limitations
- None specific to this test; the general scope is in 5.5.
Broken Authorization2/2
LIQD-KENO-AUTH-001PassForeign and malformed user identifiers
- Control assessed
- A user identifier supplied in the request is refused; bets stay bound to the caller’s own session.
- Testing approach
- Bets sent with foreign and malformed user identifiers in the request, alongside control bets; the caller’s balance reconciled.
- Observed result
- All 13 injected identifiers were rejected without a server error; the two control bets account for the caller’s entire balance change.
- Impact
- None — no injected identifier was accepted.
- Limitations
- Covers the caller’s balance, from one authenticated account.
LIQD-KENO-AUTH-003PassSession reuse after logout
- Control assessed
- A session ends at logout: its cookie cannot place another bet.
- Testing approach
- A control bet, a logout, then a bet attempted with the old session.
- Observed result
- The control bet settled and the logout completed; the reused session was rejected and no bet was placed.
- Impact
- None — the session was invalidated server-side.
- Limitations
- None specific to this test; the general scope is in 5.5.
Max-win Bypass4/4
LIQD-KENO-INJECT-001PassSingle outcome field injected
- Control assessed
- No client-supplied draw, hits, payout, status or multiplier value can control settlement.
- Testing approach
- Bets each carrying one injected outcome field, with two control bets.
- Observed result
- All five injected fields were rejected by schema validation and none was echoed; the two controls account for the entire nonce advance and balance change.
- Impact
- None — the control held.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-INJECT-002PassPick-selection field injection
- Control assessed
- No injected field changes the selection, and malformed modes are refused.
- Testing approach
- Bets carrying alias selection fields, malformed modes and a parameter override.
- Observed result
- All three alias fields and every malformed mode were rejected; the override bet settled exactly as sent. No selection diverged from the request.
- Impact
- None — the control held.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-MAXWIN-001PassStake boundary enforcement
- Control assessed
- Out-of-range stake values are refused without touching the balance.
- Testing approach
- Oversized, negative and malformed stakes alongside a free-play control.
- Observed result
- The control settled; all four invalid stakes were rejected and none consumed balance.
- Impact
- None — the control held.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-MAXWIN-002PassSeveral outcome fields injected at once
- Control assessed
- Outcome fields supplied by the client cannot steer settlement or consume a nonce.
- Testing approach
- One bet carrying several injected outcome fields together, with a control bet.
- Observed result
- The combined injection was rejected at validation; only the control consumed a nonce and moved the balance.
- Impact
- None — settlement stayed server-computed.
- Limitations
- None specific to this test; the general scope is in 5.5.
Nonce Manipulation4/4
LIQD-KENO-NONCE-001PassReused nonce
- Control assessed
- The client cannot choose the nonce: an injected value is refused, and the server keeps its own sequence.
- Testing approach
- Bets carrying a reused nonce.
- Observed result
- The replayed nonce was rejected at validation and never echoed; the rejected request moved no balance.
- Impact
- None — the server kept its own sequence.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-NONCE-002PassSkipped-ahead nonce
- Control assessed
- The client cannot choose the nonce: an injected value is refused, and the server keeps its own sequence.
- Testing approach
- Bets carrying a skipped-ahead nonce.
- Observed result
- The skipped-ahead nonce was rejected at validation and never echoed; the server did not skip ahead.
- Impact
- None — the server kept its own sequence.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-NONCE-003PassZero and negative nonces
- Control assessed
- The client cannot choose the nonce: an injected value is refused, and the server keeps its own sequence.
- Testing approach
- Bets carrying zero and negative nonces.
- Observed result
- All three zero and negative nonces were rejected at validation; none was honoured.
- Impact
- None — the server kept its own sequence.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-NONCE-004PassOverflow-sized nonces
- Control assessed
- The client cannot choose the nonce: an injected value is refused, and the server keeps its own sequence.
- Testing approach
- Bets carrying overflow-sized nonces.
- Observed result
- All three overflow-sized nonces were rejected at validation, with no server error.
- Impact
- None — the server kept its own sequence.
- Limitations
- None specific to this test; the general scope is in 5.5.
RNG / PF Verification4/4
LIQD-KENO-FAIR-001MediumRemediatedDraw distribution and paytable return
- Control assessed
- The draw shows no bias, and every setting returns the 99% LIQD displays (a 1.00% edge).
- Testing approach
- A 2,000-draw live sample recomputed from its revealed seed and tested for uniformity and hit counts; the paytable’s exact return computed for every setting.
- Observed result
- The draw showed no bias: all 2,000 draws recomputed exactly, the 20,000 drawn numbers passed the uniformity test, and hit counts fit the exact distribution. The paytable in place at the time of testing missed the displayed 1.00% edge by more than 0.1 percentage points on 10 settings (lowest 98.653846%). LIQD adjusted the paytable, and the audited table returns within 0.1 percentage points of 99% on all 50 settings (Section 4).
- Impact
- Medium · return disclosure, not a draw issue. Remediated: LIQD corrected the paytable, and the corrected table was re-verified.
- Limitations
- The re-verification is the exact computation over the audited paytable in Section 4, under the RTP claim rule’s 0.1 percentage-point band.
LIQD-KENO-EXTRA-002PassIndependent draw and outcome check
- Control assessed
- Live draws can be reproduced independently, in order, from the revealed inputs.
- Testing approach
- Rounds recomputed with our own implementation and checked against LIQD’s verify endpoint, then every round captured during the integrity testing recomputed.
- Observed result
- All five rounds recomputed to the settled draw, in order, with LIQD’s verifier agreeing; all 2,770 rounds captured during the integrity testing with a revealed seed reproduced.
- Impact
- None — the control held.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-PF-001PassProvably-fair data and commit / reveal
- Control assessed
- Each bet carries every provably-fair field, the revealed seed matches its commitment, and each outcome reproduces.
- Testing approach
- Three bets checked field by field, then the seed revealed and the rounds recomputed and checked against LIQD’s verify endpoint.
- Observed result
- All fields were present and the nonce advanced by exactly 3; the revealed seed hashed to its commitment, and all three rounds recomputed to the settled draw, with LIQD’s verifier agreeing.
- Impact
- None — the control held.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-PF-002PassNonce sequence within a seed period
- Control assessed
- The nonce advances by exactly one per bet while the commitment stays unchanged.
- Testing approach
- Ten consecutive bets on one server seed, then a reveal.
- Observed result
- The nonce advanced from 0 to 10 with no gap while the commitment stayed unchanged; once the seed was revealed, all 10 rounds recomputed independently.
- Impact
- None — the sequence was complete and the commitment fixed.
- Limitations
- None specific to this test; the general scope is in 5.5.
RNG / Information Leak1/1
LIQD-KENO-DISCLOSE-001PassActive server-seed disclosure
- Control assessed
- The active server seed stays secret until rotation; only then is it revealed.
- Testing approach
- The responses around a bet checked for the active seed before rotation; a rotation then served as the positive control.
- Observed result
- No response checked exposed the active seed; after rotation, the revealed seed matched its commitment.
- Impact
- None — the seed stayed secret until it was due.
- Limitations
- Covers one bet and one seed period.
Entropy / PF Verification1/1
LIQD-KENO-ENTROPY-004PassNonce reset after seed rotation
- Control assessed
- Rotating the seed changes the commitment and restarts the nonce at 0.
- Testing approach
- Bets, a rotation, then further bets.
- Observed result
- The nonce reset from 2 to 0, the commitment changed, and both follow-up bets settled normally.
- Impact
- None — rotation behaved as specified.
- Limitations
- None specific to this test; the general scope is in 5.5.
Precision / Payout1/1
LIQD-KENO-PREC-001PassPayout consistency at scale
- Control assessed
- Every payout follows the game’s settlement rule, and the wallet reconciles over the run.
- Testing approach
- Two hundred consecutive rounds; each payout and the aggregate balance change checked.
- Observed result
- All 200 rounds settled with no payout mismatch; the balance change matched the summed outcome exactly.
- Impact
- None — settlement arithmetic held.
- Limitations
- None specific to this test; the general scope is in 5.5.
Event Replay1/1
LIQD-KENO-REPLAY-001PassDuplicate identical bets sent together
- Control assessed
- Two identical requests sent at the same moment settle as two distinct rounds — none lost, none settled twice.
- Testing approach
- A pair of identical bet requests sent in parallel; rounds and balance reconciled.
- Observed result
- Both requests settled as distinct rounds, and the balance moved by exactly their net outcome.
- Impact
- None — each request settled exactly once.
- Limitations
- None specific to this test; the general scope is in 5.5.
Adversarial Seeds3/3
LIQD-KENO-SEED-001PassClient-seed length and character sets
- Control assessed
- Client seeds have a consistent length limit, and the game stays usable after every accepted or refused change.
- Testing approach
- Client seeds of increasing length and unusual character sets, each followed by a bet.
- Observed result
- Seeds up to 255 characters were accepted and longer ones consistently rejected; empty and path-like seeds were rejected, while other character sets, markup included, were accepted and stored as plain text. Every following bet settled.
- Impact
- None — the control held.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-SEED-002PassUnicode forms of one seed
- Control assessed
- Composed and decomposed Unicode forms of the same seed are both usable and handled consistently.
- Testing approach
- The same visible seed submitted in both forms, each followed by a bet.
- Observed result
- Both forms were accepted and stored exactly as sent, and both following bets settled.
- Impact
- None — the control held.
- Limitations
- None specific to this test; the general scope is in 5.5.
LIQD-KENO-SEED-003PassRapid seed rotation
- Control assessed
- Each bet uses the seed set by the rotation immediately before it.
- Testing approach
- Back-to-back rotations, each followed by a bet.
- Observed result
- Every bet used the seed set just before it; no stale or mixed-up seed appeared.
- Impact
- None — each bet bound to the current seed.
- Limitations
- None specific to this test; the general scope is in 5.5.
Technical Evidence & Verification4 sections
5.3Coverage Summary12 categories
Coverage by category — 34 tests: 33 pass, 1 remediated.
| Category | Tests | Result |
|---|---|---|
| Race Conditions | 5 | All pass |
| Input Validation | 5 | All pass |
| Atomic Balance / Idempotency | 3 | All pass |
| Broken Authorization | 2 | All pass |
| Max-win Bypass | 4 | All pass |
| Nonce Manipulation | 4 | All pass |
| RNG / PF Verification | 4 | 3 pass · 1 remediated (FAIR-001) |
| RNG / Information Leak | 1 | All pass |
| Entropy / PF Verification | 1 | All pass |
| Precision / Payout | 1 | All pass |
| Event Replay | 1 | All pass |
| Adversarial Seeds | 3 | All pass |
5.4Findings & Remediation1 record
1 finding from 1 test, remediated and re-verified. Each test’s card in the matrix above gives its control, approach, result, impact and limitations.
| Tests | Finding | Status |
|---|---|---|
| FAIR-001 | The paytable in place at testing missed the displayed 1.00% edge by more than 0.1 percentage points on 10 settings. LIQD corrected it; all 50 settings are now within 0.1 percentage points of 99%. | Remediated · Medium |
5.5Scope & LimitationsScope note
5.6Evidence & Disclosure
Live-surface record: Section 5 tests LIQD’s running service, so unlike Sections 1–4 it does not reproduce from the audit repository. The published record is the test cards above.
Retained under the engagement record: Per-test procedures, request and response captures, re-test captures, run timestamps and build identifiers — available to LIQD and to regulators on request.
Integrity tests ....................... 34Categories ............................ 12Pass .................................. 33Remediated ............................ 1Warning (open) ........................ 0Findings .............................. 1 · 1 remediated, 0 openProcedures and payloads ............... withheld (5.5)Player Verification
Can a player verify their own bets without trusting anyone?
Every Keno round can be independently reproduced using publicly disclosed inputs. No hidden variables, no private backend data. If the revealed seed matches the hash you saw before betting and your recomputed draw matches the game — the ten numbers in order, your hits and, on WTF, the lightning number — the round was provably fair. This section walks you through the process — with LIQD’s own panel, with the independent ProvablyFair.org verifier, or with a few lines of code you run yourself.
Key principles
- Every Keno round can be independently reproduced
- No hidden variables — no private backend data
- If the revealed seed matches the hash you saw before betting and your recomputed draw matches the game, the round was provably fair
- Most players can verify directly through LIQD’s Provably Fair panel
What you need
- Server Seed — revealed after seed rotation (casino entropy)
- Client Seed — your player-controlled seed
- Nonce — the bet number in sequence (ensures uniqueness)
- Picks & Risk — the 1–10 numbers you picked and the risk you played (Classic, Low, Med, High or WTF)
- Every Keno round can be independently reproduced
- No hidden variables — no private backend data
- If the revealed seed matches the hash you saw before betting and your recomputed draw matches the game, the round was provably fair
- Most players can verify directly through LIQD’s Provably Fair panel
- Server Seed — revealed after seed rotation (casino entropy)
- Client Seed — your player-controlled seed
- Nonce — the bet number in sequence (ensures uniqueness)
- Picks & Risk — the 1–10 numbers you picked and the risk you played (Classic, Low, Med, High or WTF)
Player can access all required inputsPass
Client seed origin transparentPass
Verification process documentedPass
MANIFEST.md, Verify a bet) — field names, commitment encoding, the draw, hits, the paying cell and the WTF rule, with a node:crypto script run against a captured roundCasino UI exposes verification panelPass
Independent verifier availablePass
Manual recomputation possiblePass
No hidden or proprietary stepsPass
Public algorithm documentationInfo
docs/ALGORITHM.md), and we recommend LIQD publish its own, the WTF lightning rule includedOnly disclosed inputs are used. Identical inputs always produce identical output.
- 1
Place a Keno Bet
Pick 1 to 10 numbers on the 40-number board — by hand or with the Number Picker — choose a risk level and play. The ten numbers are drawn from the seeds and the nonce alone: the draw takes no pick or stake input. Your picks decide your hits, the risk decides the paytable row, and on WTF the lightning number is drawn first, so the ten numbers come one cursor later.
- 2
Open the Provably Fair Panel and Rotate the Seed
Open the Provably Fair panel from the game page. Its Seeds tab shows your active seed pair and its commitment — the hashed server seed — with the hash of the next server seed already published. Rotating ends the epoch and reveals the outgoing server seed in plaintext. First check the commitment: the SHA-256 of the revealed seed’s hex text must equal the hash you saw before betting — across this audit’s 51 rotations it always did.
- 3
Enter the Seeds in the Verify Tab
Open the Verify tab, which recomputes any round from a client seed, server seed, nonce, risk and picks, and enter the revealed server seed, your client seed, the round’s nonce, the risk and your picks.
- 4
Recompute & Confirm
Press Verify — or enter the same inputs in the ProvablyFair.org verifier. If the commitment check in step 2 passed and the recomputed draw matches your round — the ten numbers, your hits and the multiplier, and on WTF the lightning number — the round was provably fair. LIQD’s tab shows the ten numbers as a set; the scripts under Manual Verification also check their order and the credit.
Verify a real bet, step by stepVerify a real bet · Swipe for all 4 steps →
Verify with the audit code
Independent verification using the implementation behind this report.
Open independent verifierBuilt-in verification
The operator’s own verification page. This tool is controlled by LIQD.com.
Open LIQD verifierVisual Walkthroughstep-by-step
Pick 1 to 10 numbers on the board — tap them, or set the Number Picker and press Pick for a random set — choose a risk level and play. The platform draws the ten numbers from (serverSeed, clientSeed, nonce): your picks decide your hits and the risk decides the paytable row, and on WTF the lightning number is drawn first. The panel below is LIQD’s Keno bet panel with three numbers picked — 11, 12 and 13 — on Classic; the row under the board is that setting’s paytable: 0×, 1×, 3.1× or 10.4× for 0 to 3 hits.

LIQD Keno on liqd.com — picks 11, 12 and 13 on Classic, with the paytable row for three picks under the board (0×, 1×, 3.1×, 10.4×).
The panel has two tabs, Seeds and Verify. The Seeds tab holds your active seed pair and its commitment. Rotation ends the epoch: the outgoing server seed is revealed for verification, and the next server seed — whose hash LIQD had already published — becomes active with the client seed you set. That advance commitment is the chain the audit’s Step 2 checks across all 51 rotations, including the link into the first epoch.
Active Server Seed (Hashed) — the commitment for the current epoch; the plaintext is revealed when you rotate and must hash to this value.
Active Client Seed — your player-controlled input; the seed set when you rotate becomes the active one.
Total bets made with pair — the bet counter under the active pair: it starts at 0 after every rotation and locates each round’s nonce within the epoch.
Rotate Seed Pair — ends the epoch and reveals the server seed behind the hash you saw.

The Seeds tab before rotating — Active Server Seed (Hashed) 359a1ff3…cf02c2 after 1 bet, and the next server seed’s hash already published. The seed revealed by the rotation, 4c895a3c…73a803, hashes to 359a1ff3…cf02c2.
The Verify tab takes the inputs — client seed, server seed, nonce, the risk and your picks — and recomputes a round on demand. Enter the server seed revealed in step 2, your client seed, the round’s nonce, the risk and your picks.

The Verify tab filled in for that round — client seed, the server seed revealed in step 2, nonce 0, risk Classic and the three picks.
Press Verify. The tab recomputes the ten numbers from the three seed inputs — on WTF the lightning number first — then counts your hits and reads the multiplier from the paytable: it marks the ten drawn numbers on the board (as a set, not in draw order) and the lightning number on WTF, and shows your hits and multiplier. A match shows the draw is the one the committed seed produces. The scripts in the Manual Verification section below do the same with nothing but Node.js or Python, pre-loaded with two rounds from this report — and they add the commitment check, the draw order and the credit.

The result: ten numbers drawn, 12 and 13 hit — “2 of 3 hit: 3.1x”. Our scripts in 6.8–6.9 reproduce it exactly: draw order 13, 22, 14, 18, 40, 12, 15, 10, 26, 17, two hits, 3.1×.
Manual Verification (Advanced)6 sections
6.5Why Manual Verification Matters
Hosted verifiers — including ours — are conveniences. The guarantee only becomes unconditional when you can reproduce a round with tools nobody else controls. The algorithm below is small enough to audit by eye and run anywhere: if the revealed seed matches the hash you saw before betting and your recomputation matches the round you played, no one in the chain — casino, auditor, or tool host — changed it after you bet.
6.6How the Algorithm Works (Plain English)
Six moving parts, in the order they act:
- Commit — before you play, LIQD publishes
SHA-256of the server seed, hashed as the 32-character hex string. The seed itself stays secret until rotation. - Draw — for cursor i = 0 … 9:
HMAC-SHA256(key = decoded seed, message = clientSeed:nonce:i); the first 4 bytes, read big-endian, modulo 40 − i give j′, and board positions i and i + j′ swap on the board 1 … 40. The first ten board entries, in order, are the drawn numbers. - Hits — the number of your picks among the ten drawn numbers; the draw itself never reads your picks.
- Cell — the multiplier is the declared paytable cell for your risk, pick count and hits,
PAYTABLE[risk][picks][hits]— LIQD’s table read 16 October 2026. - Pay — the credit is
floor(stake × multiplier)at the 8th decimal. - WTF — cursor 0 draws the lightning number (its value modulo 40, plus 1), the ten numbers use cursors 1–10, and a paying round whose lightning number is drawn pays 10 × its cell.
Note the two hashing domains: the commitment hashes the hex string as UTF-8 text, while the HMAC key is the 16 decoded bytes. Mixing them up is the most common reason a hand-rolled verifier disagrees with the game — both scripts below get it right, and both list the expected output of two rounds from this report in their comments so you can self-check the tool before trusting it. The other classic slip is the swap: position i trades with position i + j′, counted from i, on a board numbered 1 to 40. LIQD’s own code also rejects the few 32-bit values where plain modulo is uneven; that changes a round about once in 4.8 × 10⁷ (once in 4.1 × 10⁷ on WTF) and changed none of the 3,000 scored rounds (2.3).
6.7Casino Verifier vs ProvablyFair.org Verifier4 records
Both should agree — the point of having an independent one is what happens if they ever don’t:
| LIQD Built-in (in-game panel) | ProvablyFair.org | |
|---|---|---|
| Who controls the code | The casino | ProvablyFair.org — open source |
| What it proves alone | Internal consistency of LIQD’s own math | Independent recomputation from raw inputs |
| Checked against this capture | Directly — its verify endpoint returned the served round on all 1,144 scored rounds it was asked about: every WTF round and every observed Classic–High cell (Step 24, E34) | The scripts in 6.8–6.9 implement the derivation this audit specifies — HMAC-SHA256 draw, WTF lightning rule, the declared paytable — and reproduce all 3,000 scored rounds: commitment, draw order, lightning, hits, multiplier and credit |
| If they disagree | Suspect the casino’s tool or a tampered round | The repo + scripts are the tiebreaker anyone can run |
6.8JavaScript Verification Script
Complete, dependency-free, and pre-loaded with the featured round as its default inputs — run it as-is to see a full match, then swap in your own round. Run on every one of the 3,000 scored rounds, it reproduces each one: commitment, drawn numbers in order, lightning number, hits, multiplier and credit.
// verify-keno.js — standalone LIQD Keno verifier (Node.js, no dependencies)// Usage: node verify-keno.js <serverSeed> <clientSeed> <nonce> <risk> <picks, comma-separated> [stake]const { createHash, createHmac } = require('node:crypto'); const PAYTABLE = { // LIQD's declared paytable, read 16 October 2026: PAYTABLE[risk][picks][hits] classic: [[], [0,3.96], [0,1.9,4.5], [0,1,3.1,10.4], [0,.8,1.8,5,22.5], [0,.25,1.4,4.1,16.5,36], [0,0,1,3.68,7,16.5,40], [0,0,.47,3,4.5,14,31,60], [0,0,0,2.2,4,13,22,55,70], [0,0,0,1.55,3,8,15,44,60,85], [0,0,0,1.4,2.25,4.5,8,17,50,80,100]], low: [[], [.7,1.86], [0,2,3.83], [0,1.1,1.39,26], [0,0,2.2,7.9,90], [0,0,1.5,4.2,13,300], [0,0,1.1,2,6.2,100,700], [0,0,1.1,1.6,3.5,15,225,700], [0,0,1.1,1.5,2,5.5,39,100,800], [0,0,1.1,1.3,1.7,2.5,7.5,50,250,1e3], [0,0,1.1,1.2,1.3,1.8,3.9,13,50,250,1e3]], medium: [[], [.4,2.76], [0,1.8,5.16], [0,0,2.8,50], [0,0,1.71,10,100], [0,0,1.4,4,14,390], [0,0,0,3,9,181,710], [0,0,0,2,7,30,400,800], [0,0,0,2,4,11,67,400,900], [0,0,0,2,2.5,5,15,100,500,1e3], [0,0,0,1.6,2,4,7,26,100,500,1e3]], high: [[], [0,3.96], [0,0,17.16], [0,0,0,81.5], [0,0,0,10,259], [0,0,0,4.51,48,450], [0,0,0,0,11,350,710], [0,0,0,0,7,90,400,800], [0,0,0,0,5,20,270,600,900], [0,0,0,0,4,11,56,500,800,1e3], [0,0,0,0,3.5,8,13,63,500,800,1e3]], wtf: [[], [0,1.218], [0,0,5.28], [0,0,1.34,10], [0,0,0,4,64], [0,0,0,2,10,131], [0,0,0,0,8,45,470], [0,0,0,0,3,18,169,700], [0,0,0,0,0,15,75,330,800], [0,0,0,0,0,7,31,150,500,900], [0,0,0,0,0,0,35,104,300,600,1e3]],}; const argv = process.argv.slice(2);const [serverSeed, clientSeed, nonceArg, risk, picksArg, stake = '0.10'] = argv.length ? argv : ['92c36e93b39a5cde21d275e9747a5794', 'audit42a83c667988', '14', 'classic', '5,40,4,33,10'];const nonce = Number(nonceArg), picks = picksArg.split(',').map(Number), wtf = risk === 'wtf'; // 1) Commitment: SHA-256 of the hex STRING (UTF-8) — the hash LIQD showed before you playedconsole.log('commitment =', createHash('sha256').update(serverSeed, 'utf8').digest('hex')); // 2) The draw — HMAC-SHA256 keyed on the DECODED bytes, message clientSeed:nonce:cursor, first 4 bytes big-endianconst value = cursor => createHmac('sha256', Buffer.from(serverSeed, 'hex')) .update(`${clientSeed}:${nonce}:${cursor}`).digest().readUInt32BE(0);const lightning = wtf ? value(0) % 40 + 1 : null; // WTF only: cursor 0 draws the lightning numberconst board = Array.from({ length: 40 }, (_, k) => k + 1); // the board 1…40for (let i = 0; i < 10; i++) { // forward Fisher–Yates: swap i with i + j′ const j = i + value((wtf ? 1 : 0) + i) % (40 - i); // WTF draws at cursors 1–10 [board[i], board[j]] = [board[j], board[i]];}const drawn = board.slice(0, 10);console.log('drawn =', drawn.join(', '));if (wtf) console.log('lightning =', lightning, drawn.includes(lightning) ? '(drawn)' : '(not drawn)'); // 3) Hits pick the cell; a paying WTF round with its lightning number drawn pays 10 × the cellconst hits = picks.filter(p => drawn.includes(p)).length;const cell = PAYTABLE[risk][picks.length][hits];const factor = wtf && cell > 0 && drawn.includes(lightning) ? 10 : 1;const [ci, cf = ''] = String(cell).split('.'); // the cell digit for digit: 16.5 → 165 / 10const num = BigInt(ci + cf) * BigInt(factor), den = 10n ** BigInt(cf.length);console.log(`hits = ${hits} | multiplier = ${Number(num) / Number(den)}${factor === 10 ? ` (cell ${cell} × 10)` : ''}`); // 4) Payout = floor(stake × multiplier) at the 8th decimal, in exact integer arithmeticconst [si, sf = ''] = stake.split('.');const paid = BigInt(si + sf.padEnd(8, '0')) * num / den; // stake in 1e-8 units; BigInt division floorsconsole.log('payout =', `${paid / 100000000n}.${String(paid % 100000000n).padStart(8, '0')}`);// Expected for the defaults (bet E5tGJUmA4gmbhcUreVar7, Classic / 5, epoch 7 nonce 14, at $0.10):// commitment = 5989c035b566e46540f217cd7aba6bf049c9dd393d1a1d1ceb7f0e5431a46793// drawn = 33, 35, 8, 10, 36, 39, 30, 4, 26, 40// hits = 4 | multiplier = 16.5// payout = 1.65000000// Expected for bet f2t0JeOrZXSsgu0xyMvoQ (WTF / 7, epoch 46 nonce 6, at $0.10):// node verify-keno.js 430373a8b48a104773e948c25dd0b8f7 audit14226566ad31 6 wtf 28,7,4,13,22,2,21 0.10// commitment = 98cf35e524d387eda36334e55c1bef9ee8c03215f8640eec2db2fed4405e1ba2// drawn = 13, 28, 12, 22, 11, 21, 7, 20, 4, 18// lightning = 20 (drawn)// hits = 6 | multiplier = 1690 (cell 169 × 10)// payout = 169.000000006.9Python Verification Script
The same verifier in Python 3, standard library only — the payout is computed in exact rational arithmetic, so it doubles as an independent check on the JavaScript version. It reproduces all 3,000 scored rounds as well:
# verify-keno.py — standalone LIQD Keno verifier (Python 3, stdlib only)# Usage: python3 verify-keno.py <serverSeed> <clientSeed> <nonce> <risk> <picks, comma-separated> [stake]import hashlib, hmac, sysfrom fractions import Fraction PAYTABLE = { # LIQD's declared paytable, read 16 October 2026: PAYTABLE[risk][picks][hits] 'classic': [[], [0,3.96], [0,1.9,4.5], [0,1,3.1,10.4], [0,.8,1.8,5,22.5], [0,.25,1.4,4.1,16.5,36], [0,0,1,3.68,7,16.5,40], [0,0,.47,3,4.5,14,31,60], [0,0,0,2.2,4,13,22,55,70], [0,0,0,1.55,3,8,15,44,60,85], [0,0,0,1.4,2.25,4.5,8,17,50,80,100]], 'low': [[], [.7,1.86], [0,2,3.83], [0,1.1,1.39,26], [0,0,2.2,7.9,90], [0,0,1.5,4.2,13,300], [0,0,1.1,2,6.2,100,700], [0,0,1.1,1.6,3.5,15,225,700], [0,0,1.1,1.5,2,5.5,39,100,800], [0,0,1.1,1.3,1.7,2.5,7.5,50,250,1e3], [0,0,1.1,1.2,1.3,1.8,3.9,13,50,250,1e3]], 'medium': [[], [.4,2.76], [0,1.8,5.16], [0,0,2.8,50], [0,0,1.71,10,100], [0,0,1.4,4,14,390], [0,0,0,3,9,181,710], [0,0,0,2,7,30,400,800], [0,0,0,2,4,11,67,400,900], [0,0,0,2,2.5,5,15,100,500,1e3], [0,0,0,1.6,2,4,7,26,100,500,1e3]], 'high': [[], [0,3.96], [0,0,17.16], [0,0,0,81.5], [0,0,0,10,259], [0,0,0,4.51,48,450], [0,0,0,0,11,350,710], [0,0,0,0,7,90,400,800], [0,0,0,0,5,20,270,600,900], [0,0,0,0,4,11,56,500,800,1e3], [0,0,0,0,3.5,8,13,63,500,800,1e3]], 'wtf': [[], [0,1.218], [0,0,5.28], [0,0,1.34,10], [0,0,0,4,64], [0,0,0,2,10,131], [0,0,0,0,8,45,470], [0,0,0,0,3,18,169,700], [0,0,0,0,0,15,75,330,800], [0,0,0,0,0,7,31,150,500,900], [0,0,0,0,0,0,35,104,300,600,1e3]],} args = sys.argv[1:] or ['92c36e93b39a5cde21d275e9747a5794', 'audit42a83c667988', '14', 'classic', '5,40,4,33,10']server_seed, client_seed, nonce, risk = args[0], args[1], int(args[2]), args[3]picks = [int(p) for p in args[4].split(',')]stake = Fraction(args[5] if len(args) > 5 else '0.10')wtf = risk == 'wtf' # 1) Commitment — SHA-256 of the hex string as UTF-8 textprint('commitment =', hashlib.sha256(server_seed.encode('utf-8')).hexdigest()) # 2) The draw — HMAC-SHA256 keyed on the 16 decoded bytes, message clientSeed:nonce:cursordef value(cursor): digest = hmac.new(bytes.fromhex(server_seed), f'{client_seed}:{nonce}:{cursor}'.encode(), hashlib.sha256).digest() return int.from_bytes(digest[:4], 'big') # the first 4 bytes, big-endian lightning = value(0) % 40 + 1 if wtf else None # WTF only: cursor 0 draws the lightning numberboard = list(range(1, 41)) # the board 1…40for i in range(10): # forward Fisher–Yates: swap i with i + j′ j = i + value((1 if wtf else 0) + i) % (40 - i) # WTF draws at cursors 1–10 board[i], board[j] = board[j], board[i]drawn = board[:10]print('drawn =', ', '.join(map(str, drawn)))if wtf: print('lightning =', lightning, '(drawn)' if lightning in drawn else '(not drawn)') # 3) Hits pick the cell; a paying WTF round with its lightning number drawn pays 10 × the cellhits = sum(p in drawn for p in picks)cell = PAYTABLE[risk][len(picks)][hits]factor = 10 if wtf and cell > 0 and lightning in drawn else 1multiplier = Fraction(repr(cell)) * factor # the cell digit for digit: 16.5 → 33/2, exactshown = str(multiplier.numerator) if multiplier.denominator == 1 else repr(float(multiplier))print(f'hits = {hits} | multiplier = {shown}' + (f' (cell {cell:g} × 10)' if factor == 10 else '')) # 4) Payout = floor(stake × multiplier) at the 8th decimal, in exact rational arithmeticunits = stake * multiplier * 10**8paid = units.numerator // units.denominator # floor, in 1e-8 unitsprint(f'payout = {paid // 10**8}.{paid % 10**8:08d}')# Expected for the defaults (bet E5tGJUmA4gmbhcUreVar7, Classic / 5, epoch 7 nonce 14, at $0.10):# commitment = 5989c035b566e46540f217cd7aba6bf049c9dd393d1a1d1ceb7f0e5431a46793# drawn = 33, 35, 8, 10, 36, 39, 30, 4, 26, 40# hits = 4 | multiplier = 16.5# payout = 1.65000000# Expected for bet f2t0JeOrZXSsgu0xyMvoQ (WTF / 7, epoch 46 nonce 6, at $0.10):# python3 verify-keno.py 430373a8b48a104773e948c25dd0b8f7 audit14226566ad31 6 wtf 28,7,4,13,22,2,21 0.10# commitment = 98cf35e524d387eda36334e55c1bef9ee8c03215f8640eec2db2fed4405e1ba2# drawn = 13, 28, 12, 22, 11, 21, 7, 20, 4, 18# lightning = 20 (drawn)# hits = 6 | multiplier = 1690 (cell 169 × 10)# payout = 169.000000006.10Evidence Screenshots & Records4 records
The walkthrough’s screenshots are page captures from liqd.com; the evidence files below ship in the package.
| File | Shows |
|---|---|
| page-read-2026-09-27/liqd-keno-bet-panel-2026-09-27.webp | E20 · The Keno bet panel — the 40-number board, Manual / Auto, the bet amount, the Number Picker, the five risk levels and Add Second Bet |
| client-paytable/liqd-keno-client-paytable-2026-10-16.js.txt | E31 · LIQD’s client paytable, read 16 October 2026 — the declared table the scripts embed (S3.4, S4.14) |
| operator-verify-api-raw-2026-10-21/liqd-keno-reverify-inpage-2026-10-21T13-02-26-114Z.ndjson | E34 · LIQD’s verify endpoint, every reply stored as received — 1,144 of them on the scored rounds, the evidence behind the casino-verifier row in 6.7 (S3.7) |
| page-read-2026-10-03/liqd-keno-page-description-edge-2026-10-03.png | E26 · The Keno game page’s Description box, “Edge: 1.00%” — the RTP claim (S4.2) |
Verify a real bet, step by stepVerify a real bet · Swipe for all 4 steps →
How it works
- 1
Place a Keno Bet
Pick 1 to 10 numbers on the 40-number board — by hand or with the Number Picker — choose a risk level and play. The ten numbers are drawn from the seeds and the nonce alone: the draw takes no pick or stake input. Your picks decide your hits, the risk decides the paytable row, and on WTF the lightning number is drawn first, so the ten numbers come one cursor later.
- 2
Open the Provably Fair Panel and Rotate the Seed
Open the Provably Fair panel from the game page. Its Seeds tab shows your active seed pair and its commitment — the hashed server seed — with the hash of the next server seed already published. Rotating ends the epoch and reveals the outgoing server seed in plaintext. First check the commitment: the SHA-256 of the revealed seed’s hex text must equal the hash you saw before betting — across this audit’s 51 rotations it always did.
- 3
Enter the Seeds in the Verify Tab
Open the Verify tab, which recomputes any round from a client seed, server seed, nonce, risk and picks, and enter the revealed server seed, your client seed, the round’s nonce, the risk and your picks.
- 4
Recompute & Confirm
Press Verify — or enter the same inputs in the ProvablyFair.org verifier. If the commitment check in step 2 passed and the recomputed draw matches your round — the ten numbers, your hits and the multiplier, and on WTF the lightning number — the round was provably fair. LIQD’s tab shows the ten numbers as a set; the scripts under Manual Verification also check their order and the credit.
Visual Walkthroughstep-by-step
Pick 1 to 10 numbers on the board — tap them, or set the Number Picker and press Pick for a random set — choose a risk level and play. The platform draws the ten numbers from (serverSeed, clientSeed, nonce): your picks decide your hits and the risk decides the paytable row, and on WTF the lightning number is drawn first. The panel below is LIQD’s Keno bet panel with three numbers picked — 11, 12 and 13 — on Classic; the row under the board is that setting’s paytable: 0×, 1×, 3.1× or 10.4× for 0 to 3 hits.

LIQD Keno on liqd.com — picks 11, 12 and 13 on Classic, with the paytable row for three picks under the board (0×, 1×, 3.1×, 10.4×).
The panel has two tabs, Seeds and Verify. The Seeds tab holds your active seed pair and its commitment. Rotation ends the epoch: the outgoing server seed is revealed for verification, and the next server seed — whose hash LIQD had already published — becomes active with the client seed you set. That advance commitment is the chain the audit’s Step 2 checks across all 51 rotations, including the link into the first epoch.
Active Server Seed (Hashed) — the commitment for the current epoch; the plaintext is revealed when you rotate and must hash to this value.
Active Client Seed — your player-controlled input; the seed set when you rotate becomes the active one.
Total bets made with pair — the bet counter under the active pair: it starts at 0 after every rotation and locates each round’s nonce within the epoch.
Rotate Seed Pair — ends the epoch and reveals the server seed behind the hash you saw.

The Seeds tab before rotating — Active Server Seed (Hashed) 359a1ff3…cf02c2 after 1 bet, and the next server seed’s hash already published. The seed revealed by the rotation, 4c895a3c…73a803, hashes to 359a1ff3…cf02c2.
The Verify tab takes the inputs — client seed, server seed, nonce, the risk and your picks — and recomputes a round on demand. Enter the server seed revealed in step 2, your client seed, the round’s nonce, the risk and your picks.

The Verify tab filled in for that round — client seed, the server seed revealed in step 2, nonce 0, risk Classic and the three picks.
Press Verify. The tab recomputes the ten numbers from the three seed inputs — on WTF the lightning number first — then counts your hits and reads the multiplier from the paytable: it marks the ten drawn numbers on the board (as a set, not in draw order) and the lightning number on WTF, and shows your hits and multiplier. A match shows the draw is the one the committed seed produces. The scripts in the Manual Verification section below do the same with nothing but Node.js or Python, pre-loaded with two rounds from this report — and they add the commitment check, the draw order and the credit.

The result: ten numbers drawn, 12 and 13 hit — “2 of 3 hit: 3.1x”. Our scripts in 6.8–6.9 reproduce it exactly: draw order 13, 22, 14, 18, 40, 12, 15, 10, 26, 17, two hits, 3.1×.
Technical evidence
6.5Why Manual Verification Matters
Hosted verifiers — including ours — are conveniences. The guarantee only becomes unconditional when you can reproduce a round with tools nobody else controls. The algorithm below is small enough to audit by eye and run anywhere: if the revealed seed matches the hash you saw before betting and your recomputation matches the round you played, no one in the chain — casino, auditor, or tool host — changed it after you bet.
6.6How the Algorithm Works (Plain English)
Six moving parts, in the order they act:
- Commit — before you play, LIQD publishes
SHA-256of the server seed, hashed as the 32-character hex string. The seed itself stays secret until rotation. - Draw — for cursor i = 0 … 9:
HMAC-SHA256(key = decoded seed, message = clientSeed:nonce:i); the first 4 bytes, read big-endian, modulo 40 − i give j′, and board positions i and i + j′ swap on the board 1 … 40. The first ten board entries, in order, are the drawn numbers. - Hits — the number of your picks among the ten drawn numbers; the draw itself never reads your picks.
- Cell — the multiplier is the declared paytable cell for your risk, pick count and hits,
PAYTABLE[risk][picks][hits]— LIQD’s table read 16 October 2026. - Pay — the credit is
floor(stake × multiplier)at the 8th decimal. - WTF — cursor 0 draws the lightning number (its value modulo 40, plus 1), the ten numbers use cursors 1–10, and a paying round whose lightning number is drawn pays 10 × its cell.
Note the two hashing domains: the commitment hashes the hex string as UTF-8 text, while the HMAC key is the 16 decoded bytes. Mixing them up is the most common reason a hand-rolled verifier disagrees with the game — both scripts below get it right, and both list the expected output of two rounds from this report in their comments so you can self-check the tool before trusting it. The other classic slip is the swap: position i trades with position i + j′, counted from i, on a board numbered 1 to 40. LIQD’s own code also rejects the few 32-bit values where plain modulo is uneven; that changes a round about once in 4.8 × 10⁷ (once in 4.1 × 10⁷ on WTF) and changed none of the 3,000 scored rounds (2.3).
6.7Casino Verifier vs ProvablyFair.org Verifier4 records
Both should agree — the point of having an independent one is what happens if they ever don’t:
| LIQD Built-in (in-game panel) | ProvablyFair.org | |
|---|---|---|
| Who controls the code | The casino | ProvablyFair.org — open source |
| What it proves alone | Internal consistency of LIQD’s own math | Independent recomputation from raw inputs |
| Checked against this capture | Directly — its verify endpoint returned the served round on all 1,144 scored rounds it was asked about: every WTF round and every observed Classic–High cell (Step 24, E34) | The scripts in 6.8–6.9 implement the derivation this audit specifies — HMAC-SHA256 draw, WTF lightning rule, the declared paytable — and reproduce all 3,000 scored rounds: commitment, draw order, lightning, hits, multiplier and credit |
| If they disagree | Suspect the casino’s tool or a tampered round | The repo + scripts are the tiebreaker anyone can run |
6.8JavaScript Verification Script
Complete, dependency-free, and pre-loaded with the featured round as its default inputs — run it as-is to see a full match, then swap in your own round. Run on every one of the 3,000 scored rounds, it reproduces each one: commitment, drawn numbers in order, lightning number, hits, multiplier and credit.
// verify-keno.js — standalone LIQD Keno verifier (Node.js, no dependencies)// Usage: node verify-keno.js <serverSeed> <clientSeed> <nonce> <risk> <picks, comma-separated> [stake]const { createHash, createHmac } = require('node:crypto'); const PAYTABLE = { // LIQD's declared paytable, read 16 October 2026: PAYTABLE[risk][picks][hits] classic: [[], [0,3.96], [0,1.9,4.5], [0,1,3.1,10.4], [0,.8,1.8,5,22.5], [0,.25,1.4,4.1,16.5,36], [0,0,1,3.68,7,16.5,40], [0,0,.47,3,4.5,14,31,60], [0,0,0,2.2,4,13,22,55,70], [0,0,0,1.55,3,8,15,44,60,85], [0,0,0,1.4,2.25,4.5,8,17,50,80,100]], low: [[], [.7,1.86], [0,2,3.83], [0,1.1,1.39,26], [0,0,2.2,7.9,90], [0,0,1.5,4.2,13,300], [0,0,1.1,2,6.2,100,700], [0,0,1.1,1.6,3.5,15,225,700], [0,0,1.1,1.5,2,5.5,39,100,800], [0,0,1.1,1.3,1.7,2.5,7.5,50,250,1e3], [0,0,1.1,1.2,1.3,1.8,3.9,13,50,250,1e3]], medium: [[], [.4,2.76], [0,1.8,5.16], [0,0,2.8,50], [0,0,1.71,10,100], [0,0,1.4,4,14,390], [0,0,0,3,9,181,710], [0,0,0,2,7,30,400,800], [0,0,0,2,4,11,67,400,900], [0,0,0,2,2.5,5,15,100,500,1e3], [0,0,0,1.6,2,4,7,26,100,500,1e3]], high: [[], [0,3.96], [0,0,17.16], [0,0,0,81.5], [0,0,0,10,259], [0,0,0,4.51,48,450], [0,0,0,0,11,350,710], [0,0,0,0,7,90,400,800], [0,0,0,0,5,20,270,600,900], [0,0,0,0,4,11,56,500,800,1e3], [0,0,0,0,3.5,8,13,63,500,800,1e3]], wtf: [[], [0,1.218], [0,0,5.28], [0,0,1.34,10], [0,0,0,4,64], [0,0,0,2,10,131], [0,0,0,0,8,45,470], [0,0,0,0,3,18,169,700], [0,0,0,0,0,15,75,330,800], [0,0,0,0,0,7,31,150,500,900], [0,0,0,0,0,0,35,104,300,600,1e3]],}; const argv = process.argv.slice(2);const [serverSeed, clientSeed, nonceArg, risk, picksArg, stake = '0.10'] = argv.length ? argv : ['92c36e93b39a5cde21d275e9747a5794', 'audit42a83c667988', '14', 'classic', '5,40,4,33,10'];const nonce = Number(nonceArg), picks = picksArg.split(',').map(Number), wtf = risk === 'wtf'; // 1) Commitment: SHA-256 of the hex STRING (UTF-8) — the hash LIQD showed before you playedconsole.log('commitment =', createHash('sha256').update(serverSeed, 'utf8').digest('hex')); // 2) The draw — HMAC-SHA256 keyed on the DECODED bytes, message clientSeed:nonce:cursor, first 4 bytes big-endianconst value = cursor => createHmac('sha256', Buffer.from(serverSeed, 'hex')) .update(`${clientSeed}:${nonce}:${cursor}`).digest().readUInt32BE(0);const lightning = wtf ? value(0) % 40 + 1 : null; // WTF only: cursor 0 draws the lightning numberconst board = Array.from({ length: 40 }, (_, k) => k + 1); // the board 1…40for (let i = 0; i < 10; i++) { // forward Fisher–Yates: swap i with i + j′ const j = i + value((wtf ? 1 : 0) + i) % (40 - i); // WTF draws at cursors 1–10 [board[i], board[j]] = [board[j], board[i]];}const drawn = board.slice(0, 10);console.log('drawn =', drawn.join(', '));if (wtf) console.log('lightning =', lightning, drawn.includes(lightning) ? '(drawn)' : '(not drawn)'); // 3) Hits pick the cell; a paying WTF round with its lightning number drawn pays 10 × the cellconst hits = picks.filter(p => drawn.includes(p)).length;const cell = PAYTABLE[risk][picks.length][hits];const factor = wtf && cell > 0 && drawn.includes(lightning) ? 10 : 1;const [ci, cf = ''] = String(cell).split('.'); // the cell digit for digit: 16.5 → 165 / 10const num = BigInt(ci + cf) * BigInt(factor), den = 10n ** BigInt(cf.length);console.log(`hits = ${hits} | multiplier = ${Number(num) / Number(den)}${factor === 10 ? ` (cell ${cell} × 10)` : ''}`); // 4) Payout = floor(stake × multiplier) at the 8th decimal, in exact integer arithmeticconst [si, sf = ''] = stake.split('.');const paid = BigInt(si + sf.padEnd(8, '0')) * num / den; // stake in 1e-8 units; BigInt division floorsconsole.log('payout =', `${paid / 100000000n}.${String(paid % 100000000n).padStart(8, '0')}`);// Expected for the defaults (bet E5tGJUmA4gmbhcUreVar7, Classic / 5, epoch 7 nonce 14, at $0.10):// commitment = 5989c035b566e46540f217cd7aba6bf049c9dd393d1a1d1ceb7f0e5431a46793// drawn = 33, 35, 8, 10, 36, 39, 30, 4, 26, 40// hits = 4 | multiplier = 16.5// payout = 1.65000000// Expected for bet f2t0JeOrZXSsgu0xyMvoQ (WTF / 7, epoch 46 nonce 6, at $0.10):// node verify-keno.js 430373a8b48a104773e948c25dd0b8f7 audit14226566ad31 6 wtf 28,7,4,13,22,2,21 0.10// commitment = 98cf35e524d387eda36334e55c1bef9ee8c03215f8640eec2db2fed4405e1ba2// drawn = 13, 28, 12, 22, 11, 21, 7, 20, 4, 18// lightning = 20 (drawn)// hits = 6 | multiplier = 1690 (cell 169 × 10)// payout = 169.000000006.9Python Verification Script
The same verifier in Python 3, standard library only — the payout is computed in exact rational arithmetic, so it doubles as an independent check on the JavaScript version. It reproduces all 3,000 scored rounds as well:
# verify-keno.py — standalone LIQD Keno verifier (Python 3, stdlib only)# Usage: python3 verify-keno.py <serverSeed> <clientSeed> <nonce> <risk> <picks, comma-separated> [stake]import hashlib, hmac, sysfrom fractions import Fraction PAYTABLE = { # LIQD's declared paytable, read 16 October 2026: PAYTABLE[risk][picks][hits] 'classic': [[], [0,3.96], [0,1.9,4.5], [0,1,3.1,10.4], [0,.8,1.8,5,22.5], [0,.25,1.4,4.1,16.5,36], [0,0,1,3.68,7,16.5,40], [0,0,.47,3,4.5,14,31,60], [0,0,0,2.2,4,13,22,55,70], [0,0,0,1.55,3,8,15,44,60,85], [0,0,0,1.4,2.25,4.5,8,17,50,80,100]], 'low': [[], [.7,1.86], [0,2,3.83], [0,1.1,1.39,26], [0,0,2.2,7.9,90], [0,0,1.5,4.2,13,300], [0,0,1.1,2,6.2,100,700], [0,0,1.1,1.6,3.5,15,225,700], [0,0,1.1,1.5,2,5.5,39,100,800], [0,0,1.1,1.3,1.7,2.5,7.5,50,250,1e3], [0,0,1.1,1.2,1.3,1.8,3.9,13,50,250,1e3]], 'medium': [[], [.4,2.76], [0,1.8,5.16], [0,0,2.8,50], [0,0,1.71,10,100], [0,0,1.4,4,14,390], [0,0,0,3,9,181,710], [0,0,0,2,7,30,400,800], [0,0,0,2,4,11,67,400,900], [0,0,0,2,2.5,5,15,100,500,1e3], [0,0,0,1.6,2,4,7,26,100,500,1e3]], 'high': [[], [0,3.96], [0,0,17.16], [0,0,0,81.5], [0,0,0,10,259], [0,0,0,4.51,48,450], [0,0,0,0,11,350,710], [0,0,0,0,7,90,400,800], [0,0,0,0,5,20,270,600,900], [0,0,0,0,4,11,56,500,800,1e3], [0,0,0,0,3.5,8,13,63,500,800,1e3]], 'wtf': [[], [0,1.218], [0,0,5.28], [0,0,1.34,10], [0,0,0,4,64], [0,0,0,2,10,131], [0,0,0,0,8,45,470], [0,0,0,0,3,18,169,700], [0,0,0,0,0,15,75,330,800], [0,0,0,0,0,7,31,150,500,900], [0,0,0,0,0,0,35,104,300,600,1e3]],} args = sys.argv[1:] or ['92c36e93b39a5cde21d275e9747a5794', 'audit42a83c667988', '14', 'classic', '5,40,4,33,10']server_seed, client_seed, nonce, risk = args[0], args[1], int(args[2]), args[3]picks = [int(p) for p in args[4].split(',')]stake = Fraction(args[5] if len(args) > 5 else '0.10')wtf = risk == 'wtf' # 1) Commitment — SHA-256 of the hex string as UTF-8 textprint('commitment =', hashlib.sha256(server_seed.encode('utf-8')).hexdigest()) # 2) The draw — HMAC-SHA256 keyed on the 16 decoded bytes, message clientSeed:nonce:cursordef value(cursor): digest = hmac.new(bytes.fromhex(server_seed), f'{client_seed}:{nonce}:{cursor}'.encode(), hashlib.sha256).digest() return int.from_bytes(digest[:4], 'big') # the first 4 bytes, big-endian lightning = value(0) % 40 + 1 if wtf else None # WTF only: cursor 0 draws the lightning numberboard = list(range(1, 41)) # the board 1…40for i in range(10): # forward Fisher–Yates: swap i with i + j′ j = i + value((1 if wtf else 0) + i) % (40 - i) # WTF draws at cursors 1–10 board[i], board[j] = board[j], board[i]drawn = board[:10]print('drawn =', ', '.join(map(str, drawn)))if wtf: print('lightning =', lightning, '(drawn)' if lightning in drawn else '(not drawn)') # 3) Hits pick the cell; a paying WTF round with its lightning number drawn pays 10 × the cellhits = sum(p in drawn for p in picks)cell = PAYTABLE[risk][len(picks)][hits]factor = 10 if wtf and cell > 0 and lightning in drawn else 1multiplier = Fraction(repr(cell)) * factor # the cell digit for digit: 16.5 → 33/2, exactshown = str(multiplier.numerator) if multiplier.denominator == 1 else repr(float(multiplier))print(f'hits = {hits} | multiplier = {shown}' + (f' (cell {cell:g} × 10)' if factor == 10 else '')) # 4) Payout = floor(stake × multiplier) at the 8th decimal, in exact rational arithmeticunits = stake * multiplier * 10**8paid = units.numerator // units.denominator # floor, in 1e-8 unitsprint(f'payout = {paid // 10**8}.{paid % 10**8:08d}')# Expected for the defaults (bet E5tGJUmA4gmbhcUreVar7, Classic / 5, epoch 7 nonce 14, at $0.10):# commitment = 5989c035b566e46540f217cd7aba6bf049c9dd393d1a1d1ceb7f0e5431a46793# drawn = 33, 35, 8, 10, 36, 39, 30, 4, 26, 40# hits = 4 | multiplier = 16.5# payout = 1.65000000# Expected for bet f2t0JeOrZXSsgu0xyMvoQ (WTF / 7, epoch 46 nonce 6, at $0.10):# python3 verify-keno.py 430373a8b48a104773e948c25dd0b8f7 audit14226566ad31 6 wtf 28,7,4,13,22,2,21 0.10# commitment = 98cf35e524d387eda36334e55c1bef9ee8c03215f8640eec2db2fed4405e1ba2# drawn = 13, 28, 12, 22, 11, 21, 7, 20, 4, 18# lightning = 20 (drawn)# hits = 6 | multiplier = 1690 (cell 169 × 10)# payout = 169.000000006.10Evidence Screenshots & Records4 records
The walkthrough’s screenshots are page captures from liqd.com; the evidence files below ship in the package.
| File | Shows |
|---|---|
| page-read-2026-09-27/liqd-keno-bet-panel-2026-09-27.webp | E20 · The Keno bet panel — the 40-number board, Manual / Auto, the bet amount, the Number Picker, the five risk levels and Add Second Bet |
| client-paytable/liqd-keno-client-paytable-2026-10-16.js.txt | E31 · LIQD’s client paytable, read 16 October 2026 — the declared table the scripts embed (S3.4, S4.14) |
| operator-verify-api-raw-2026-10-21/liqd-keno-reverify-inpage-2026-10-21T13-02-26-114Z.ndjson | E34 · LIQD’s verify endpoint, every reply stored as received — 1,144 of them on the scored rounds, the evidence behind the casino-verifier row in 6.7 (S3.7) |
| page-read-2026-10-03/liqd-keno-page-description-edge-2026-10-03.png | E26 · The Keno game page’s Description box, “Edge: 1.00%” — the RTP claim (S4.2) |
Only disclosed inputs are used. Identical inputs always produce identical output.
Reproducibility & Artifacts
Can anyone independently reproduce every scored check in this audit?
This section consolidates the open-source repository, datasets, output artifacts, and reproducibility posture of the audit. Every scored step, every statistic and every pass/fail result of the audit package can be independently reproduced by anyone with a computer and an internet connection. The repository is the credential — not this report.
Repository Details
- GitHub: ProvablyFair-org/liqd-keno
- Release: v1.1.0 · Framework 1.1
- Game: Keno (liqd-keno)
- Public Verifier: provablyfair.org/verify-bets/liqd
Prerequisites
- Node.js 22.x (
package.jsonengines and.nvmrc) — no dependency install fornpm testornode test.js && node verify.js - Git
- TypeScript toolchain via
npm ci(TypeScript 5.9.3 · ts-node 10.9.2 · Mocha 11.7.6) — fornpm run verify,npm run test:fulland the source commands - Python 3 — only for the optional standalone checker in S6
- GitHub: ProvablyFair-org/liqd-keno
- Release: v1.1.0 · Framework 1.1
- Game: Keno (liqd-keno)
- Public Verifier: provablyfair.org/verify-bets/liqd
- Node.js 22.x (
package.jsonengines and.nvmrc) — no dependency install fornpm testornode test.js && node verify.js - Git
- TypeScript toolchain via
npm ci(TypeScript 5.9.3 · ts-node 10.9.2 · Mocha 11.7.6) — fornpm run verify,npm run test:fulland the source commands - Python 3 — only for the optional standalone checker in S6
All scored results reproduce from release v1.1.0, the pinned capture and the commands above. The capture, its request journal and the declared paytable are hash-checked before any step runs, so you are provably testing the same 3,000 rounds against the same table; the simulation and the RTP claim table are pinned by SHA-256, and every run is compared field by field with the committed results. The simulation is deterministic — npm run test:full regenerates it from its pinned seeds, equal to the committed artifact field for field.
Commands to Reproduce
The scored capture, its request journal, the declared paytable and the committed results ship inside the repo — no separate download, and the capture, journal and declared table are SHA-256-checked before any step runs. npm test is zero-install: it runs the stale-build check, the 24 unit tests, the 24-step verification and the release-manifest check, and exits with the verdict’s code; npm ci adds the TypeScript tools for the source commands. Node.js 22.x is required.
Every run writes only to outputs/run/ and compares its results field by field with the committed results (outputs/run/diff.json) — that comparison is the reproduction gate. Exit codes: 0 Full Pass · 2 Conditional Pass · 5 RTP FIGURE OVERSTATED (the RTP claim rule finds a published figure overstated) · 1 NOT PROVABLY FAIR, or a reproduction beyond tolerance. Output as run on Node 22.22.0, abridged where marked “…”; Steps 7, 13 and 16 — to be completed on LIQD’s production site before certification (1.4, 3.8, 4.7) — are not listed.
Regenerates the simulation artifact from its pinned seeds — Pass 1’s 10 streams of 1,000,000 rounds from the master seed, one per pick count, and Pass 2 over the 40 Classic–High epoch seeds × 10,000 nonces — into outputs/run/, after the rebuild check and the 24 mocha tests, and compares it field by field with the committed outputs/simulation-results.json; then verifies. Run for this report on Node 22.22.0: the regenerated artifact equals the committed one with 0 differences, even within tolerance, in about 15 minutes.
Audit Reproducibility Pinning
- Release Tag
- v1.1.0 — github.com/ProvablyFair-org/liqd-keno/tree/v1.1.0
- Framework
- ProvablyFair.org Audit Framework 1.1 · Keno profile 1.0.2 · RTP-CLAIM 1.2.0
- Release Signature
- Release commit and tag signed with the ProvablyFair.org release key (SSH,
SHA256:gEujiJm2pRyq7lQsfYQfQ1r97H/J7GdaKHbOW8GtikY), recorded inRELEASE-MANIFEST.json - Node Version
- 22.x (
package.jsonengines,.nvmrc22) — release runs on 22.23.1, this report’s runs on 22.22.0 - Primary Dataset
- data/liqd-keno-capture-v2-2026-10-20T13-04-03-496Z.json (3,000 rounds, 50 seed epochs)
- Primary Dataset Hash (SHA-256)
- 7744010540dedbc9fe996a5cf9a75d4ee4a6007b08e56c2e7b9b9053b9831b1e
- Request Journal Hash (SHA-256)
- 8f52aceba87bba7a86e9d3651e9f3a96b6597736eb3294f69cb44aaa9f30db76
- Declared Paytable Hash (SHA-256)
- 767b72306634439808976894b9c1794f9cc1b092576306ce9adfc9e414d6f53a
- Simulation Artifact Hash (SHA-256)
- a4a2b1119491c983a1fd95c15470b925ae53dfe50d41fb3f490e53f63f496086
- RTP Claim Table Hash (SHA-256)
- 5025110bbc6ba4d6f47fb63d1341d4341fb7d126de03811d39131ebfa672977d
- LIQD Verifier Evidence Hash (SHA-256)
- 04661d2b15fe26fd485f3f218484a76239ff581bfeedd9d5352fcb57283cb6f9
- Reproduction Gate
- Every run writes only to outputs/run/ and is compared field by field with the committed results (outputs/run/diff.json) — integers and strings exactly, other numbers within 1e-14 + 1e-12 relative; anything beyond tolerance exits 1
- Exit Codes
- 0 Full Pass · 2 Conditional Pass · 5 RTP FIGURE OVERSTATED · 1 NOT PROVABLY FAIR, a reproduction beyond tolerance, or a failed stage
- Mutation Battery
- 94 of 94 live entries caught, 0 survived — every scored step catches at least one
- Runtime
npm test≈ 72 s ·npm run verify≈ 76 s ·node test.js≈ 2.5 s ·npm run test:full≈ 15 min (Node 22.22.0; declared budget 120 s and 900 s)- Capture Environment
- Pre-launch host qa.liqd.com · one authenticated account, one currency (USDC)
- Audit Date
- October 2026 — declared paytable read 16 October, scored capture 20 October, LIQD’s verifier called 21 October 2026; release v1.1.0 21 October 2026
- Audit ID
- PF-2026-LQ06
Output Artifacts3 files committed
| File | Contents | Size |
|---|---|---|
| verification-results.json | Steps 1–24 results — per-step status with evidence detail strings and profile ids, profile coverage (KENO-01 … KENO-32), dataset, journal and paytable hashes, verdict | ~84 KB |
| simulation-results.json | Pass 1 (10 streams × 1,000,000 rounds, one per pick count: board, hit-law and lag-1 counts, from the pinned master seed) + Pass 2 (the 40 Classic–High epoch seeds × 10,000 nonces, the relocation null and both statistics) | ~31 KB |
| rtp-claim-table.json | The RTP claim rule’s table — LIQD’s published 99% against all 50 settings, each with exact RTP, gap and outcome | ~9 KB |
Repository Structure
liqd-keno/ ├── src/ │ ├── rng.ts → commitment hash · HMAC draw (computeDraw) · WTF lightning rule (wtfDraw) — written from docs/ALGORITHM.md │ ├── bias.ts → the draw-bias bound and the rejection-zone census (Step 8) │ ├── config.ts → the game’s declared shape (40-number board, 10 drawn, 1–10 picks, five risks) + population constants │ ├── client-paytable.ts → LIQD’s client paytable read as data — the declared table (E31), parsed, never evaluated │ ├── exact.ts → exact BigInt-rational hit law, two independent derivations (Step 17) │ ├── rtp-claim.ts · rtp-claim-switch.ts → the RTP claim rule (RTP-CLAIM 1.2.0) and its enforcement switch (on) │ ├── rtp-settings.ts → the 50-setting exact RTP table and LIQD’s published claim │ ├── profile-map.ts → which Keno profile check (KENO-01 … KENO-32) each step implements │ ├── pins.ts → integrity pins and the audited population — every count a constant │ ├── loader.ts · v2.ts → capture loader + SHA-256 guard (aborts before any step on a mismatch) · the capture schema │ ├── operator-verify-2026-10-21.ts → reader for LIQD’s verify-endpoint replies on the scored rounds (E34, Step 24) │ ├── sim.ts · simulate.ts · sim-inputs.ts · seed-derivation.ts → Pass 1 (10 × 1,000,000 rounds) + Pass 2, from pinned seeds → outputs/ │ ├── stats.ts → χ² tail, board uniformity, hit-law fit and lag-1, re-derived from integer counts │ ├── domains.ts → value domains for every numeric field of the committed artifacts (Step 23) │ ├── diff.ts → field-level diff of a run vs the committed artifacts (outputs/run/diff.json) │ └── verify-tab.ts · operator-verify-wtf.ts · prod-capture-2026-10-21.ts · types.ts → readers for the pinned Verify-tab, verifier and production evidence · shared types ├── docs/ │ ├── ALGORITHM.md → the specification the RNG is written from (the draw, the WTF lightning rule) │ └── INTERFACE.md → the module interface the RNG implements ├── tests/ │ ├── verify.ts · history.ts · production.ts → 24-step verification runner (results under outputs/run/; PF_EMIT=1 to regenerate the committed record) │ ├── keno/ → 24 unit tests (exact model 8 · verifier-reply reader 3 · spec self-test 1 [96 checks] · RNG adapter 12) │ ├── mutations.ts · mutations.json → the step-named mutation battery: 97 entries (94 live), run in disposable copies │ └── steps/ │ ├── commitment.ts → Steps 1–5 (seed hashes, chain + pre-capture anchor, epoch consistency, nonce windows, population) │ ├── determinism.ts → Steps 6–10 (draw recompute, client-seed controls, bias bound, structure & hits, pick-set independence) │ ├── payouts.ts → Steps 11–16 (paytable provenance, payout arithmetic, bet size, shape & bounds, cell coverage, one multiplier per cell) │ ├── rtp.ts → Steps 17, 18, 21 (anti-circularity, exact RTP vs the claim, live-sample fit) │ ├── simulation.ts → Steps 19–20 (Pass 1 reconciliation and replay, Pass 2 re-derived from the scored seeds) │ ├── standardization.ts → Steps 22–24 (pins, artifacts of record, LIQD verifier parity) │ └── primary.ts → the capture-scoring part of every step ├── scripts/ → npm test’s stage runner, release manifest + release bar, standalone-bundle builder and its check ├── capture/ │ ├── capture-keno-v2.mjs · liqd-capture-core.mjs → the capture runner and its engine: pre-capture anchor, CSPRNG client seeds, request journal │ ├── capture-keno-verify-2026-10-21.mjs · inpage/ → the call of LIQD’s verify endpoint on the scored rounds (E34), run in the logged-in tab │ ├── console/ · prod-2026-10-21/ · capture-keno-two-set.mjs · capture-keno-verify-raw.mjs → further runners and browser-console readers │ ├── selftest-*.mjs · mock-*.mjs · req-*.py → offline self-tests against a simulated endpoint · the HTTP transport │ └── out/ → raw run output, copied byte for byte into data/ and evidence/ ├── data/ │ ├── liqd-keno-capture-v2-2026-10-20T13-04-03-496Z.json → the scored capture: 3,000 rounds · 50 epochs · SHA-256 pinned (E32) │ ├── liqd-keno-capture-v2-2026-10-20T13-04-03-496Z.journal.ndjson → its request journal: 6,103 records · SHA-256 pinned (E33) │ └── … further pinned data files ├── outputs/ │ ├── verification-results.json → 24 steps + profile coverage + dataset and paytable hashes + verdict │ ├── simulation-results.json → Pass 1 (10 streams × 1,000,000 rounds) + Pass 2 (40 epoch seeds × 10,000 nonces) │ └── rtp-claim-table.json → the RTP claim rule over all 50 settings ├── evidence/ │ ├── client-paytable/ → E31 LIQD’s client paytable, read 16 October 2026 — the declared table │ ├── operator-verify-api-raw-2026-10-21/ → E34 LIQD’s verify endpoint, every reply stored as received — 1,144 on the scored rounds │ ├── page-read-2026-10-03/ → E26 the game page’s “Edge: 1.00%” — the RTP claim │ ├── page-read-2026-09-27/ → E20 the bet panel (S6) │ └── … 7 further evidence folders and files, SHA-256 pinned ├── .github/workflows/reproduce.yml → second-machine reproduction on a clean runner (ubuntu-latest, Node from .nvmrc, npm ci && npm test) ├── test.js · verify.js → dependency-free bundles (Node 22) ├── README.md · MANIFEST.md → the report (findings, evidence register, worked examples, limitations register) ├── RELEASE-MANIFEST.json → release identity: tag, framework, artifact hashes, verdict, signing key ├── package.json → npm test = scripts/run-stages.mjs (build check, unit tests, verifier, release manifest) · verify · test:full · mutations └── package-lock.json · tsconfig.json · .mocharc.yml · .nvmrc · .gitattributes · .gitignore · standalone-manifest.json · hygiene-allow.json · LICENSE
Step-to-Section Cross-Reference24 verification steps mapped
| Step | Test Name | Report Section |
|---|---|---|
| 1 | Seed hash integrity | S1, S3 |
| 2 | Forward commitment chain | S1, S6 |
| 3 | Hash consistency within epoch | S1, S6 |
| 4 | Nonce continuity | S1, S3, S6 |
| 5 | Population & epoch binding | S3 |
| 6 | Outcome recomputation (drawn list) | S1, S2, S3, S4 |
| 7 | Client-seed influence & controls | S1, S2 · in production (1.4) |
| 8 | Draw bias bound | S2 |
| 9 | Draw structure & hit counting | S1, S3, S4 |
| 10 | Pick-set independence | S2, S3, S4 |
| 11 | Paytable provenance & served-vs-declared | S3, S4 |
| 12 | Payout arithmetic | S3, S4 |
| 13 | Bet-size invariance (elevated stake) | S3, S4 · in production (3.8, 4.7) |
| 14 | Paytable shape & bounds | S3, S4 |
| 15 | Configuration & cell coverage | S3, S4 |
| 16 | Stake invariance (same multiplier at every stake) | S3, S4 · in production (3.8, 4.7) |
| 17 | Anti-circularity (hit probability) | S4 |
| 18 | Per-config RTP by exact enumeration vs claim | S4 |
| 19 | Simulation Pass 1 (fresh seeds) | S2, S4 |
| 20 | Simulation Pass 2 (cherry-pick) | S4 |
| 21 | Live-sample fit (informational) | S2, S4 |
| 22 | Dataset & config pins | S1, S3, S7 |
| 23 | Artifacts of record | S4, S7 |
| 24 | Operator-verifier parity | S2, S3, S4, S6 |
Game Profile CoverageKeno profile 1.0.2 · 32 checks
Every check in the ProvablyFair.org Keno profile, version 1.0.2, is covered by a scored step, by a package check, or marked n/a with a reason — 24 by scored steps, 7 by package checks, 1 n/a. KENO-06, KENO-12 and KENO-31 will be completed on LIQD’s production site before certification; for KENO-09, KENO-14, KENO-24, KENO-28 and KENO-32 the row states the exact scope it covers. The map ships in src/profile-map.ts and is written beside the step results on every run; the committed outputs/verification-results.json carries it.
| Check | What it requires | Covered by | Coverage |
|---|---|---|---|
| KENO-01 | Every revealed seed hashes to its commitment | Step 1 | Full |
| KENO-02 | Forward commitment chain, anchored before the first bet | Step 2 | Full |
| KENO-03 | Every round carries its epoch’s commitment and client seed | Step 3 | Full |
| KENO-04 | Nonce continuity — every epoch holds its nonces exactly once | Step 4 | Full |
| KENO-05 | Audited population bound to code constants, not the dataset header | Step 5 | Full |
| KENO-06 | Client-seed influence and controls, player-set seeds included | Step 7 | On LIQD’s production site — the seed controls pass on the scored capture (1.4) |
| KENO-07 | Every served draw recomputes in draw order — WTF with its lightning number | Step 6 | Full |
| KENO-08 | Draw-bias bound computed from the draw’s own ranges | Step 8 | Full |
| KENO-09 | Known-input unit vectors reproduce captured rounds | Package check: tests/keno/ (rngTests, rngSpecTests, exactTests) | Spec self-test (96 checks) and captured-round vectors; the RNG is our own implementation, written from the specification |
| KENO-10 | Draw structure and hit counting | Step 9 | Full |
| KENO-11 | Settlement arithmetic — floor(stake × cell [× 10]) to 8 decimals; zero cells pay 0 | Step 12 | Full |
| KENO-12 | Bet-size invariance at an elevated stake | Step 13 | On LIQD’s production site (3.8) |
| KENO-13 | Paytable provenance — every served multiplier equals its declared cell | Step 11 | Full |
| KENO-14 | Paytable shape and bounds — every payout within the quoted maximum | Step 14 | Every scored payout within stake × its row’s top cell, × 10 on WTF lightning rounds (Step 14) |
| KENO-15 | Every setting played at or above the profile floor; cell coverage | Step 15 | Full |
| KENO-16 | Wallet balance ledger reconciliation | — | N/A — no response carries a per-bet balance; Step 12 checks the settle-response wallet fields on 3,000 / 3,000 |
| KENO-17 | The draw is independent of the pick set | Step 10 | Full |
| KENO-18 | Anti-circularity — hit probability from the board alone, two derivations | Step 17 | Full |
| KENO-19 | Exact RTP per setting against the published claim (RTP-CLAIM) | Step 18 | Full |
| KENO-20 | Simulation Pass 1 — fresh seeds, convergence, uniformity; replayable | Step 19 | Full |
| KENO-21 | Cherry-pick test — Simulation Pass 2 | Step 20 | Full |
| KENO-22 | Live-sample fit (informational) | Step 21 | Full |
| KENO-23 | Framework fairness-integrity rows S5-1 … S5-8, kept as a package checklist (Section 5 reports the live integrity tests) | Package check: MANIFEST.md fairness-integrity matrix | Full |
| KENO-24 | Verifier parity — LIQD’s verifier on the served rounds; the ProvablyFair.org site verifier matched to the derivation | Step 24 | LIQD’s verifier: 1,144 / 1,144 replies — every WTF round and every observed Classic–High cell (E34) |
| KENO-25 | Player verification path, in LIQD’s field names | Package check: MANIFEST.md Verify a bet | Full |
| KENO-26 | Captures, journals and evidence pinned by SHA-256; abort on a mismatch | Step 22 | Full |
| KENO-27 | Artifacts of record — no run metadata, every numeric value in a declared domain | Step 23 | Full |
| KENO-28 | Release gate — zero-install entry points, pinned runtime, audit ID | Package check: scripts/check-standalone.mjs, package.json, .nvmrc, src/pins.ts | Zero-install npm test exits 0 on Node 22.23.1 (release runs) and 22.22.0 (this report); runtime pinned in package.json and .nvmrc; audit ID PF-2026-LQ06 in src/pins.ts |
| KENO-29 | Mutation battery — every scored step fires on at least one mutation | Package check: tests/mutations.json + tests/mutations.ts | Full |
| KENO-30 | Limitations register and numbered evidence | Package check: MANIFEST.md | Full |
| KENO-31 | The same multiplier at every stake | Step 16 | On LIQD’s production site (4.7) — each of the 174 observed Classic–High cells paid one multiplier at the scored stake |
| KENO-32 | Auto-picked rounds settle as manual ones | Package check: the bet panel (E20) | The bet panel offers Pick (E20); the 3,000 scored rounds carry the pick sets the capture chose, and the draw takes no pick input (Step 10) |
reproduce-audit.sh
All scored results reproduce from release v1.1.0, the pinned capture and the commands above. The capture, its request journal and the declared paytable are hash-checked before any step runs, so you are provably testing the same 3,000 rounds against the same table; the simulation and the RTP claim table are pinned by SHA-256, and every run is compared field by field with the committed results. The simulation is deterministic — npm run test:full regenerates it from its pinned seeds, equal to the committed artifact field for field.